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, code_search | 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 how tool output is redacted.
shell_exec first applies its capability check and refuses an empty command. It then 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 wc date uptime hostname whoaminew ShellTool(allowlist: ['ls', 'pwd', 'df', 'date', 'free']);The command name checked in steps 2 and 3 is the first whitespace-separated word, split the same
way the process arguments are built. Step 2 compares its lower-cased base name, so RM and
/bin/rm are refused as hard-blocked even when they are on the allowlist. Step 3 compares it
exactly and case-sensitively, so LS and ls/../bin/ls are refused as not on the allowlist.
4. File arguments of a file-reading command are checked. When the command is cat, head,
tail, less, more, tac, nl, grep, egrep, fgrep, awk, sed, wc, sort, uniq,
cut, paste, tr, strings, xxd, hexdump or od, 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 path through a sensitive directory from list C below, checked on the argument and on its real path
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 over 8192 bytes of stdout, or over 10000 bytes of stderr when a failed command
printed nothing to stdout, returns a preview and the path of a temp file holding the full output, 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 whose IPv4 falls in one of those ranges 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.
Steps 1 to 4 also protect remote tool profiles and remote skills. The security-alert webhook gets steps 1 to 3, without the pinning in step 4.
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 |
.env and .env.* file names | yes | yes | yes, and any name starting .env, so .envrc too |
| sensitive file names (list B) | yes | yes | yes |
| sensitive directories (list C) | yes | yes | yes |
vendor, node_modules | yes | yes | yes |
.github, .circleci | yes | ||
| build and CI files (list D) | yes | 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_write checks lists B and D on the lower-cased file name even when allowPhpWrite is on, so
it refuses wp-config.php or composer.json in any workspace folder.
file_edit also refuses to change a file that file_read has not read during the same run, so the
model cannot edit blind.
code_search skips the same secret files while it searches: .env and .env.*, list A extensions,
list B file names and list C directories, plus vendor, node_modules, .git and .idea. A path
inside a list C directory is refused.
Adapters set allowPhpWrite from their console detection, so it is on only when running from the
command line, and never for the MCP server’s tools. On Symfony it is on for bin/console phpclaw; the
default engine reads the console flag when it is built, and an engine injected into a command’s
constructor is built before that flag is set, so it is off there. 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. An unquoted column that shares a name with one of them, such as do, is
refused too. The guard reads backtick-quoted and double-quoted names as string literals, so these
words are not checked inside them: `do` or `information_schema`.tables passes this check.
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 an allowed command outside its read-only list, the WordPress and Joomla ZIP
builders, and the Laravel and Symfony test_run tool. shell_exec’s read-only list is a fixed set of 12 commands: the 8 in the default
allowlist above plus cat, head, tail and grep. Any other command you add to the allowlist,
such as git, counts as mutating and needs approval. A command the tool refuses anyway (a shell
metacharacter, a hard-blocked program, or a program not on the allowlist, such as php -v with the
default list) is refused straight away, without asking.
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. The exception is Laravel and Symfony
with durable_runs and store_messages both on: runs without an interactive terminal get
SuspendableApprovalGate, which pauses the run until someone approves or denies the call. See
Laravel.
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; paused for a decision on Laravel and Symfony with durable_runs and store_messages on |
| cron, queue worker, piped CLI | refused; with durable_runs and store_messages on, a Laravel or Symfony queue worker, and a Symfony command whose engine is injected into its constructor (built before the console flag is set), pauses instead |
| 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.
A second opinion from a model
Section titled “A second opinion from a model”The static check only knows whether a tool is marked as changing state, so an http_request that
posts customer data to an outside host runs without a prompt. ClassifierApprovalGate wraps either
gate and asks a model about each call the static check lets through:
use PhpClaw\Agent\ClassifierApprovalGate;use PhpClaw\Agent\CliApprovalGate;use PhpClaw\Claw;use PhpClaw\Providers\OpenAIProvider;use PhpClaw\Tools\HttpTool;
$classifier = new OpenAIProvider( apiKey: '', model: 'qwen2.5:7b', endpoint: 'http://127.0.0.1:11434/v1/chat/completions', name: 'ollama', authStyle: 'none',);
$agent = Claw::builder() ->tools([new HttpTool]) ->approvalGate(new ClassifierApprovalGate(new CliApprovalGate, $classifier, threshold: 0.8)) ->build();| The model answers | What happens |
|---|---|
dangerous with confidence at or above threshold | the call goes to the wrapped gate as a state-changing call: CliApprovalGate asks Y/n (refuses without a terminal), SuspendableApprovalGate pauses the run |
safe, a lower confidence, an unreadable reply, or the model call fails | the wrapped gate decides as it would without the model |
The model is asked for JSON, {"value": "dangerous" or "safe", "confidence": 0 to 1}. It can only
add a prompt, never remove one: a call the static check already stops reaches the wrapped gate
without asking the model. The tool name and input are sent to the model you pass, so pick one you
trust with that data.
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;
tools/listmarks every tool that can change data withreadOnlyHint: falseanddestructiveHint: true - 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 and fallbacks, or the outbound rate limit wait ran out (status 429) |
MaxIterationsException | yes | the loop hit its cap |
MemoryException | yes, from conversation() and sendInConversation() | the driver failed to save; a failed recall during send() is logged and skipped |
AdapterException | yes, from build() | no provider or no API key |
TokenBudgetExceededException | yes | the token budget would be passed |
StructuredOutputException, UnsupportedSchemaException | yes, from sendStructured() | the reply never matched the schema, or the schema uses an unsupported keyword |
RunSuspendedException | yes, with durable runs | the run paused for approval or spent its step budget or deadline; resume it later |
RunConflictException, RunStateException | yes, from resume(), approve() and deny() | another process changed the run first, or the run or paused call does not exist |
ToolException | only when the model names an unregistered tool again after the one retry without tools | 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 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.
- Tool-call arguments are not guard-scanned in the agent loop. Only the MCP path scans them.
- Personal data in tool results reaches the provider by default.
PiiDetectionGuardchecks the user’s message only. Turn onredactPiiInToolResults()to redact tool results too; see Guards: personal data in tool results. - Guards scan text, not intent. A request phrased to avoid every pattern passes them; the tool rules above are what bound its effect.