Skip to content

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.


LayerApplies toOn by default
Guardsevery messageyes, eight guards
Shell rulesshell_execyes, when the tool is registered
SSRF protectionhttp_requestyes, when the tool is registered
File sandboxfile_read, file_write, file_edityes, when the tools are registered
Read-only SQLdb_query and every adapter database toolyes, when the tool is registered
Approval gatemutating toolsnot in core; yes in every adapter
Output cleanupthe final answeryes
MCP transportthe MCP HTTP servertoken required

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:

CategoryCommands
destructiverm mv dd mkfs fdisk shred mkfifo mknod
permissionschmod chown chattr
networkcurl wget nc ncat netcat socat ssh scp sftp ftp rsync
system controlkill killall pkill reboot shutdown halt poweroff init
privilegesudo su doas pkexec newgrp chroot
shellsbash sh zsh fish csh ksh tcsh dash
interpreterspython python2 python3 ruby perl node
package managersnpx npm pip pip3
deferred executionat batch nohup crontab
debugginggdb strace ltrace ptrace
enumerationps 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 whoami
new 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, fstab or crontab
  • anything under /etc/ssh/, /etc/ssl/, /proc/, /sys/, or starting /dev/
  • a .env file, 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:

  1. Hosts starting with localhost, 127., 0., 10., 169.254., 192.168., ::1, fc00: or fe80: are refused.
  2. 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).
  3. 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.
  4. 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:

Blockedfile_readfile_editfile_write
sensitive extensions (list A)yesyesyes
sensitive file names (list B)yesyesno
sensitive directories (list C)yesyesyes
vendor, node_modulesyesyesyes
.github, .circleciyes
build and CI files (list D)yes
scripts: sh bash exe bat cmd ps1yes
php phtml pharyes, unless allowPhpWrite: true
core, system, sysextyes
  • A, extensions: env key pem crt p12 pfx cer der jks keystore truststore sqlite sqlite3 db mdb kdbx kwallet
  • B, file names: wp-config.php wp-config-sample.php config.php configuration.php database.php settings.php local_settings.php settings.local.php local.php app.php env.php config.local.php config.prod.php settings.inc.php parameters.php parameters.yml parameters.yaml .htpasswd .htaccess .htdigest nginx.conf httpd.conf php.ini .npmrc .pypirc .netrc .envrc .my.cnf .pgpass credentials.json service-account.json id_rsa id_ed25519 id_ecdsa id_dsa known_hosts authorized_keys docker-compose.yml docker-compose.yaml .dockerenv
  • C, directories: .git .ssh .gnupg .aws .azure .gcloud .kube .docker .config wp-admin wp-includes
  • D, build and CI files: composer.json composer.lock package.json package-lock.json .gitignore phpunit.xml phpunit.xml.dist dockerfile .gitlab-ci.yml artisan

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.


db_query, and every adapter’s database tool, pass the query through the same SqlReadOnlyGuard before running it.

QueryResult
SELECT id FROM usersallowed
WITH t AS (SELECT 1) SELECT * FROM tallowed
SELECT 1 UNION SELECT 2, EXCEPT, INTERSECTallowed
SELECT 1; SELECT 2refused: one statement only
DELETE FROM usersrefused
WITH t AS (DELETE FROM users RETURNING *) SELECT * FROM trefused: a write keyword anywhere
... INTO OUTFILE '/tmp/x'refused
SELECT LOAD_FILE('/etc/passwd')refused
SELECT * FROM INFORMATION_SCHEMA.TABLESrefused
... FOR UPDATE, ... LOCK IN SHARE MODErefused
-- 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.

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:

PackageCountIdentifiers
core9password passwd secret private_key api_key api_token access_token secret_key auth_token
Laravel9core, minus secret_key and auth_token, plus auth_key remember_token
Symfony10core, minus secret_key and auth_token, plus auth_key salt secure_key
Magento10as Symfony
OpenCart10as Symfony
Joomla12as Symfony, plus otep otpkey
PrestaShop12as Symfony, plus wholesale_price product_supplier_price_te
WordPress13as Symfony, plus user_pass user_activation_key session_tokens
Drupal16as 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.


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.

WhereMutating tool call
web chat or REST, any adapterrefused
cron, queue worker, piped CLIrefused
interactive CLIruns after y
MCP serverruns; 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.


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_detection and message_length

Every tool’s own rules above still apply. See MCP.


Most failures inside the loop are handed to the model, not thrown to you.

ExceptionReaches the caller of send()When
GuardExceptionyesa guard blocked the message
ProviderExceptionyesthe model API failed after any retries
MaxIterationsExceptionyesthe loop hit its cap
MemoryExceptionyesa memory driver failed
AdapterExceptionyes, from build()no provider or no API key
ToolExceptiononly when the model calls an unregistered toolotherwise the model receives {"error": ...}
ShellDeniedExceptionnothe model receives {"error": ...}
HumanDeniedExceptionnothe 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.


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.