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_edit, code_searchyes, 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 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:

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 wc date uptime hostname whoami
new 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, 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 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:

  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 whose IPv4 falls in one of those ranges 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.

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:

Blockedfile_readfile_editfile_write
sensitive extensions (list A)yesyesyes
.env and .env.* file namesyesyesyes, and any name starting .env, so .envrc too
sensitive file names (list B)yesyesyes
sensitive directories (list C)yesyesyes
vendor, node_modulesyesyesyes
.github, .circleciyes
build and CI files (list D)yesyes
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_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.


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. 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.

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 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.

WhereMutating tool call
web chat or REST, any adapterrefused; paused for a decision on Laravel and Symfony with durable_runs and store_messages on
cron, queue worker, piped CLIrefused; 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 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 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 answersWhat happens
dangerous with confidence at or above thresholdthe 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 failsthe 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.


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/list marks every tool that can change data with readOnlyHint: false and destructiveHint: true
  • 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 and fallbacks, or the outbound rate limit wait ran out (status 429)
MaxIterationsExceptionyesthe loop hit its cap
MemoryExceptionyes, from conversation() and sendInConversation()the driver failed to save; a failed recall during send() is logged and skipped
AdapterExceptionyes, from build()no provider or no API key
TokenBudgetExceededExceptionyesthe token budget would be passed
StructuredOutputException, UnsupportedSchemaExceptionyes, from sendStructured()the reply never matched the schema, or the schema uses an unsupported keyword
RunSuspendedExceptionyes, with durable runsthe run paused for approval or spent its step budget or deadline; resume it later
RunConflictException, RunStateExceptionyes, from resume(), approve() and deny()another process changed the run first, or the run or paused call does not exist
ToolExceptiononly when the model names an unregistered tool again after the one retry without toolsotherwise 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 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. PiiDetectionGuard checks the user’s message only. Turn on redactPiiInToolResults() 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.