Security
phpClaw’s protections sit in layers. Guards scan what goes in. Each built-in tool enforces its own rules when it runs. An approval gate can stop state-changing calls. This page lists each layer as the code implements it, and ends with what the layers do not protect against.
The layers
Section titled “The layers”| Layer | Applies to | On by default |
|---|---|---|
| Guards | every message | yes, eight guards |
| Shell rules | shell_exec | yes, when the tool is registered |
| SSRF protection | http_request | yes, when the tool is registered |
| File sandbox | file_read, file_write, file_edit | yes, when the tools are registered |
| Read-only SQL | db_query and every adapter database tool | yes, when the tool is registered |
| Approval gate | mutating tools | not in core; yes in every adapter |
| Output cleanup | the final answer | yes |
| MCP transport | the MCP HTTP server | token required |
Guards
Section titled “Guards”Eight guards scan every message before the model sees it: message length, injection, Unicode,
homoglyph, role-switch, code injection, destructive SQL and PII. A match throws GuardException.
Text injected from memory and skills is scanned too. See Guards for each guard, the three scan paths, and the known gap in tool-output redaction.
shell_exec runs a command only after four checks, in order.
1. Metacharacters are refused. A command containing any of these is denied outright, never escaped:
; & | > < ` $ ( ) { } [ ] \ " ' !2. Hard-blocked commands are refused, even if you add them to the allowlist:
| Category | Commands |
|---|---|
| destructive | rm mv dd mkfs fdisk shred mkfifo mknod |
| permissions | chmod chown chattr |
| network | curl wget nc ncat netcat socat ssh scp sftp ftp rsync |
| system control | kill killall pkill reboot shutdown halt poweroff init |
| privilege | sudo su doas pkexec newgrp chroot |
| shells | bash sh zsh fish csh ksh tcsh dash |
| interpreters | python python2 python3 ruby perl node |
| package managers | npx npm pip pip3 |
| deferred execution | at batch nohup crontab |
| debugging | gdb strace ltrace ptrace |
| enumeration | ps pstree top htop du find env printenv set export artisan |
3. The command must be on the allowlist. The default:
ls pwd df cat head tail grep wc date uptime hostname whoaminew ShellTool(allowlist: ['ls', 'pwd', 'df', 'date', 'free']);4. File arguments are checked. Every argument, including the value in --option=value, is
refused if it names:
/etc/passwd,shadow,group,gshadow,sudoers,hosts,fstaborcrontab- anything under
/etc/ssh/,/etc/ssl/,/proc/,/sys/, or starting/dev/ - a
.envfile, including.env.local - a blocked file name or extension from the file sandbox lists below
A trailing ~ or backup extension (.bak, .old, .orig, .save, .swp, .swo, .tmp,
.copy, .backup) is removed before checking, so wp-config.php.bak and id_rsa~ are refused like
the originals.
Commands then run through array-form proc_open, with no shell string, and are killed after
5 seconds. Output is capped at 8192 bytes of stdout and 10000 of stderr, and ANSI escape
sequences and control characters are stripped.
http_request accepts only http:// and https://, and only GET and POST.
Before any connection, the host is validated:
- Hosts starting with
localhost,127.,0.,10.,169.254.,192.168.,::1,fc00:orfe80:are refused. - Numeric encodings used to disguise an address are refused: decimal (
2130706433), hex (0x7f000001), dotted hex (0x7f.0x0.0x0.0x1,0x7f.0.0.1) and dotted octal (0177.0.0.1). - The host is resolved, and every returned address must be public. Private and reserved ranges,
carrier-grade NAT (
100.64.0.0/10), IPv6 loopback, unique-local and link-local addresses, and IPv4-mapped IPv6 addresses are all refused. A host that does not resolve is refused. - The validated address is pinned with
CURLOPT_RESOLVE, so the connection cannot be re-resolved to a different address after validation.
Redirects are never followed. Header names and values have \r and \n removed. Responses are cut
at 8192 bytes and requests time out after 10 seconds; both are constructor options.
The same validation protects remote tool profiles, remote skills and the security-alert webhook.
file_read, file_write and file_edit work inside one workspace directory, by default
storage/phpclaw under the current working directory.
Every path is resolved to its real location and must stay inside the workspace. A path walking through a symlink at any point is refused, so a link inside the workspace cannot point outside it.
The three tools do not share one block list. Each checks its own:
| Blocked | file_read | file_edit | file_write |
|---|---|---|---|
| sensitive extensions (list A) | yes | yes | yes |
| sensitive file names (list B) | yes | yes | no |
| sensitive directories (list C) | yes | yes | yes |
vendor, node_modules | yes | yes | yes |
.github, .circleci | yes | ||
| build and CI files (list D) | yes | ||
scripts: sh bash exe bat cmd ps1 | yes | ||
php phtml phar | yes, unless allowPhpWrite: true | ||
core, system, sysext | yes |
- A, extensions:
envkeypemcrtp12pfxcerderjkskeystoretruststoresqlitesqlite3dbmdbkdbxkwallet - B, file names:
wp-config.phpwp-config-sample.phpconfig.phpconfiguration.phpdatabase.phpsettings.phplocal_settings.phpsettings.local.phplocal.phpapp.phpenv.phpconfig.local.phpconfig.prod.phpsettings.inc.phpparameters.phpparameters.ymlparameters.yaml.htpasswd.htaccess.htdigestnginx.confhttpd.confphp.ini.npmrc.pypirc.netrc.envrc.my.cnf.pgpasscredentials.jsonservice-account.jsonid_rsaid_ed25519id_ecdsaid_dsaknown_hostsauthorized_keysdocker-compose.ymldocker-compose.yaml.dockerenv - C, directories:
.git.ssh.gnupg.aws.azure.gcloud.kube.docker.configwp-adminwp-includes - D, build and CI files:
composer.jsoncomposer.lockpackage.jsonpackage-lock.json.gitignorephpunit.xmlphpunit.xml.distdockerfile.gitlab-ci.ymlartisan
file_edit also refuses to change a file that file_read has not read during the same run, so the
model cannot edit blind.
Adapters set allowPhpWrite from their console detection, so it is on only when running from the
command line. file_write is also a mutating tool, so each write still needs approval at the terminal.
See Approval.
Database
Section titled “Database”db_query, and every adapter’s database tool, pass the query through the same SqlReadOnlyGuard
before running it.
| Query | Result |
|---|---|
SELECT id FROM users | allowed |
WITH t AS (SELECT 1) SELECT * FROM t | allowed |
SELECT 1 UNION SELECT 2, EXCEPT, INTERSECT | allowed |
SELECT 1; SELECT 2 | refused: one statement only |
DELETE FROM users | refused |
WITH t AS (DELETE FROM users RETURNING *) SELECT * FROM t | refused: a write keyword anywhere |
... INTO OUTFILE '/tmp/x' | refused |
SELECT LOAD_FILE('/etc/passwd') | refused |
SELECT * FROM INFORMATION_SCHEMA.TABLES | refused |
... FOR UPDATE, ... LOCK IN SHARE MODE | refused |
-- comment, # comment, /* comment */, /*! ... */ | refused, never stripped |
WHERE note = '-- not a comment' | allowed: the guard reads string literals as strings |
These words are refused anywhere in the query, outside string literals: INSERT, UPDATE, DELETE,
REPLACE, MERGE, UPSERT, DROP, CREATE, ALTER, TRUNCATE, RENAME, GRANT, REVOKE, LOCK,
UNLOCK, CALL, EXEC, EXECUTE, HANDLER, DO, SET, LOAD, LOAD_FILE, IMPORT, INTO,
OUTFILE, DUMPFILE, ATTACH, DETACH, PREPARE, DEALLOCATE, RESET, FLUSH, KILL,
SHUTDOWN, INSTALL, UNINSTALL, DELIMITER, START, BEGIN, COMMIT, ROLLBACK, SAVEPOINT,
RELEASE, INFORMATION_SCHEMA, PG_READ_FILE, PG_LS_DIR, SLEEP, BENCHMARK and PG_SLEEP, plus
FOR UPDATE and FOR SHARE. A column that shares a name with one of them, such as do, is refused
too.
Because comments are refused rather than removed, the query that passes validation is exactly the
query that runs. db_query runs it as a PDO prepared statement. Results are capped at 100 rows.
Credential columns
Section titled “Credential columns”After the read-only check, a query naming a credential identifier as a table or column is refused. Each adapter tailors the list to its schema:
| Package | Count | Identifiers |
|---|---|---|
| core | 9 | password passwd secret private_key api_key api_token access_token secret_key auth_token |
| Laravel | 9 | core, minus secret_key and auth_token, plus auth_key remember_token |
| Symfony | 10 | core, minus secret_key and auth_token, plus auth_key salt secure_key |
| Magento | 10 | as Symfony |
| OpenCart | 10 | as Symfony |
| Joomla | 12 | as Symfony, plus otep otpkey |
| PrestaShop | 12 | as Symfony, plus wholesale_price product_supplier_price_te |
| WordPress | 13 | as Symfony, plus user_pass user_activation_key session_tokens |
| Drupal | 16 | as Symfony, plus pass information_schema mysql performance_schema pg_catalog sys |
PrestaShop’s two extra entries protect supplier pricing rather than secrets. Drupal’s refuse the database system schemas.
Approval
Section titled “Approval”Tools that change state implement MutatingToolInterface: core’s file_write, file_edit and
zip_package, shell_exec for any command outside its read-only list, and the WordPress and Joomla
ZIP builders. shell_exec’s read-only list is its own default allowlist, the 12 commands above, so
anything else, including git status and php -v, counts as mutating.
Core installs no gate. Add the built-in one with withHumanApproval(), or your own with
approvalGate():
use PhpClaw\Claw;use PhpClaw\Tools\FileWriteTool;
$agent = Claw::builder() ->tools([new FileWriteTool]) ->withHumanApproval() ->build();Every adapter installs CliApprovalGate, on every request.
CliApprovalGate asks Y/n on an interactive terminal. With no terminal, as in any web request,
cron job or piped command, it refuses. A refused call does not run, and the model is told it was
denied and continues.
| Where | Mutating tool call |
|---|---|
| web chat or REST, any adapter | refused |
| cron, queue worker, piped CLI | refused |
| interactive CLI | runs after y |
| MCP server | runs; the gate is not consulted |
Hook listeners cannot block a tool call. A listener that throws is logged and the call still runs. Only the approval gate stops a mutating call.
Output
Section titled “Output”The final answer is cleaned before it is returned: <?php, <?= and ?> become [PHP_REMOVED], and
eval(, system(, exec(, shell_exec(, passthru(, proc_open( and pcntl_exec( become
[REDACTED](. The list is scanned in that order, so exec( matches first and shell_exec( comes back
as shell_[REDACTED]( and pcntl_exec( as pcntl_[REDACTED](. This also alters legitimate code in
answers; turn it off with sanitiseOutput(false).
See Architecture.
Tool results are cleaned separately, before the model sees them. See Guards: tool output.
The MCP HTTP transport refuses to start unless PHPCLAW_MCP_TOKEN is set, and compares the
bearer token in constant time.
It also accepts connections only from 127.0.0.1 or ::1, and refuses any request carrying an
Origin header, so a browser page cannot reach it. Both return 403.
Two differences from the agent loop matter on MCP:
- the approval gate is never consulted
- tool-call arguments are guard-scanned, but without the three prompt-only guards,
code_injection,pii_detectionandmessage_length
Every tool’s own rules above still apply. See MCP.
What reaches your code
Section titled “What reaches your code”Most failures inside the loop are handed to the model, not thrown to you.
| Exception | Reaches the caller of send() | When |
|---|---|---|
GuardException | yes | a guard blocked the message |
ProviderException | yes | the model API failed after any retries |
MaxIterationsException | yes | the loop hit its cap |
MemoryException | yes | a memory driver failed |
AdapterException | yes, from build() | no provider or no API key |
ToolException | only when the model calls an unregistered tool | otherwise the model receives {"error": ...} |
ShellDeniedException | no | the model receives {"error": ...} |
HumanDeniedException | no | the model receives a denied result |
If the model repeats a call that already failed, with identical arguments, the run stops and returns that failure message as its answer.
What this does not cover
Section titled “What this does not cover”These are the current limits, stated so you can plan around them.
- Core’s nine tools have no capability check in practice. No authorizer is bound, so any caller who can reach an agent can use them. Adapter tools do check capabilities. See Tools.
- The shell is a denylist, not a sandbox, as described above.
- Homoglyph injections in tool output reach the model unredacted. See Guards.
- Tool-call arguments are not guard-scanned in the agent loop. Only the MCP path scans them.
- Guards scan text, not intent. A request phrased to avoid every pattern passes them; the tool rules above are what bound its effect.