An agent running inside your WordPress install or your Magento store holds shell_exec, db_query, and file_write. A model decides which of those to call, and phpClaw executes whatever the provider returns. That is the whole deal — and it means the only thing standing between a crafted message and your production database is the guard layer.

phpClaw’s guard layer is not advisory. Every guard registered in GuardRegistry enforces by throwing, and the throw halts the turn. Seven of them are on by default, identically, on all 8 adapters. One more is off by default. One sanitizer sits outside the registry entirely and redacts instead of blocking. This article covers all nine, what each adapter does with the violation record, and where the enforcement genuinely stops.

The contract is one method, and it returns nothing

// core/src/Guards/Contracts/GuardInterface.php:13
public function scan(string $message): void;

There is no GuardResult. No ->isAllowed(). No decision object handed back to a caller who might choose to ignore it. scan() takes a message, and it either returns void — meaning pass — or it throws GuardException.

That single signature decision is the whole enforcement model. A caller cannot forget to check a return value, because there isn’t one. A guard that finds something objectionable does not report it; it ends the turn. This is worth being blunt about because the opposite framing is common in this category of tooling: “the guard detects, the application decides.” That is not what happens here.

This signature is confirmed at core/src/Guards/Contracts/GuardInterface.php:13, and the same throw-to-block behavior is independently confirmed on all 8 adapters.

The three-part structure, stated precisely

There are exactly three categories, and the distinctions between them matter operationally:

1. Enforce by default (the registry). Every guard registered in GuardRegistry throws GuardException on violation and halts the turn. This is the default and it is not configurable per-guard beyond enabling or disabling the guard.

2. One passive exception. PiiDetectionGuard enforces by default like the rest, but it accepts a blockOnDetection=false constructor argument. Constructed that way, it detects and does not throw. It is the only guard in the product with a passive mode.

3. One mechanism outside the registry. ToolOutputGuard has no #[Guard] attribute and is not in GuardRegistry at all. It is wired directly into Agent’s constructor, it is always on, and it never blocks — it redacts tool output and lets the turn continue.

That is the complete picture. There is no third behavior, no “some guards warn,” no severity ladder. If you have read a description of phpClaw’s guards as a mix of blocking and non-blocking policies, that description is wrong in a way that matters: it implies you have a tuning surface you do not have, and it obscures the one real passive mode (PiiDetectionGuard) behind a vaguer generalization.

The seven that run by default

Identical set, identical priorities, on all 8 adapters. This uniformity is confirmed independently on every one of them, and it is the single most useful fact in this article: whatever platform you are on, the default enforcement surface is the same one.

GuardPriorityDefaultBehavior
MessageLengthGuard0ONEnforce — throws
InjectionGuard1ONEnforce — throws
UnicodeGuard2ONEnforce — throws
HomoglyphGuard3ONEnforce — throws
RoleSwitchGuard4ONEnforce — throws
CodeInjectionGuard5ONEnforce — throws
PiiDetectionGuard30ONEnforce by default; passive with blockOnDetection=false
RateLimitGuard-1OFFEnforce, once enabled
ToolOutputGuardn/aAlways on, outside GuardRegistryRedacts tool output; never blocks

Defaults and priorities come from each guard’s own #[Guard] attribute — the priority is a property of the guard class, not of the adapter that loads it. RateLimitGuard carries enabledByDefault:false, which is why it is the one guard an operator has to turn on deliberately.

Two things to read off that table that are easy to miss. RateLimitGuard has priority -1, ahead of everything else — when you do enable it, it runs first, before the content guards spend work on a message from a caller who has already exceeded their budget. And PiiDetectionGuard sits at 30, well clear of the 0–5 block, running after the structural and injection checks have had their pass.

The exact pattern sets each guard matches on live in the guard classes themselves. This article does not reproduce them: what’s confirmed here is each guard’s attributes, priority, and throw behavior — not the contents of its pattern constants. Read the class if you need the specific expressions; do not trust a secondhand list of them, including one you might find in this product’s own older documentation.

What a block actually looks like

Because scan() throws, a block surfaces to your code as an exception, not as a status field. On the adapters that expose an HTTP surface, that exception is caught at the boundary and converted. PrestaShop’s REST controller is the clearest case: a GuardException becomes HTTP 422 with a JSON body carrying "code":"phpclaw_guard". Other fields accompany it in the real response; only the code field is confirmed here, so this article does not print a full body it cannot vouch for.

OpenCart takes the same exception and does something different with it: the controller catches GuardException, writes it to error_log(), and returns a JSON error to the caller. Same throw, same halt, different boundary translation. This is the pattern across all 8 — one enforcement mechanism in core, eight boundary translations.

For a rejection where the exact string is verified end to end, the clearest example is not a GuardInterface guard at all.

ShellTool’s metacharacter check is not a guard

ShellTool refuses commands containing a disallowed shell metacharacter. When it does, it throws ShellDeniedException — a different exception type from GuardException — with this message, verbatim:

Command contains a disallowed shell metacharacter.

Verified at core/src/Tools/ShellTool.php:237. The metacharacter set it checks against is defined at core/src/Tools/ShellTool.php:58-61; read it there rather than trusting any reproduction of it, including this one’s absence of one.

Why does this check live on the tool instead of in the guard registry? The evidence does not record a design rationale, so this is reasoning from the contract rather than a documented intent — but the contract makes it close to forced. GuardInterface::scan() takes one argument: string $message. A guard sees the message. It does not see the arguments a model later produces for a specific tool call. A shell command that the model constructs during a turn is not the message that entered the turn, so a check on that command cannot be expressed as a GuardInterface guard without changing the contract.

The operational consequence is what matters, and it is worth internalizing: GuardRegistry does not validate tool arguments. There is no argument validation in the core agent loop at all — each tool validates its own execute() input. ShellTool happens to do that thoroughly and throw its own typed exception. Whether any other tool you enable does the same is a property of that tool, not something the guard layer provides for you. This is the most important limit on the guard layer and the reason the Tools article is the necessary companion read: the guard surface and the execution surface are not the same surface.

ToolOutputGuard: the one that redacts

ToolOutputGuard runs on what tools return, not on what enters the turn, and it never throws. It redacts and the turn proceeds.

On WordPress, it fires three named actions on redaction:

  • phpclaw_guard.tool_output_redacted
  • phpclaw_guard.output_php_tag_removed
  • phpclaw_guard.output_function_redacted

Those three names are confirmed. What triggers each — the full PATTERNS constant driving redaction — was not read for this article, so it names the events and stops there.

The structural fact about ToolOutputGuard has a consequence you will hit if you ever try to introspect the enforcement surface programmatically. Because it carries no #[Guard] attribute and is not registered, it does not appear in GuardRegistry. Any count you compute from GuardRegistry::count() describes the registry, not the enforcement surface. See the limitations section.

Per-adapter: the same seven, eight different violation records

The guards are uniform. What is not uniform is where the record of a block ends up. That is the per-platform question, and it is the one an operator has to answer before shipping.

Across all 8 adapters, the guard.blocked lifecycle event is re-fired onto the host platform’s own event system by HookEventBridge. That bridge is one-directional — phpClaw fires into the platform, nothing flows back — and a listener on it cannot mutate the payload or abort anything. The block has already happened by the time your listener runs. The Hooks article covers the bridge’s semantics in full; for guards, the relevant part is that a guard.blocked listener is an observer, never a veto.

WordPress

No database table for guard violations. The adapter calls error_log() and fires do_action('phpclaw_guard.blocked', ...), which third-party plugins can hook.

// WordPress: any plugin file or your theme's functions.php
add_action('phpclaw_guard.blocked', function ($context) {
    error_log('phpClaw guard blocked a turn: ' . wp_json_encode($context));
}, 10, 1);

That runs as written. The payload’s exact keys were not confirmed here, which is why the example serializes the whole context rather than reaching into it.

WordPress’s risk surface is the combination of a broad default tool set and a thin violation record. WordPress ships 20 tools with WooCommerce inactive and 30 with it active — 11 WP-native tools, the 7 core utility tools, zip_package, and wp_zip_plugin. shell_exec is among those 7 core tools. So the platform most likely to be on shared hosting is also the one where the agent’s default kit includes shell access, and where a blocked turn leaves nothing behind but an error_log() line and a fired action. If error_log() on your host writes somewhere you never read, a block is effectively invisible.

Joomla (5/6, one registration)

Joomla 5 and 6 are one adapter, one registration, one tool list — no version branching exists in the adapter’s business logic. No database table for guard violations; HookEventBridge re-fires the block as the Joomla event onPhpClawGuardBlocked, which a system plugin can subscribe to.

Joomla’s tool surface is a fully enumerated 14: 5 Joomla-native, the 7 core tools, zip_package, and joomla_zip_extension. That matters more than it sounds. Joomla and WordPress are the two adapters whose totals were confirmed end to end; on the other six, the adapter-native count is confirmed but the core-catalogue merge on top of it depends on local package discovery and was not re-derived. On Joomla you can say exactly what the guards are the last line in front of.

Laravel

Laravel has the most structured in-platform violation record of the eight, and it is opt-in. When Telescope is installed and PHPCLAW_TELESCOPE is true — it defaults to true — the adapter’s PhpClawWatcher records TYPE_GUARD_BLOCKED entries. If Telescope is not installed, the watcher no-ops silently. That silence is the trap: the config flag being on by default does not mean you have the record. You have it only when the package is actually there.

Laravel also has a genuinely narrower default attack surface than the CMS adapters, and it is deliberate rather than incidental. Ten entries ship commented out in config/phpclaw.php:41-52 — four of them are core tools that auto-load anyway, so uncommenting the block is a net addition of 6 Laravel-specific tools. The adapter loads 7 tools by default and 13 at maximum. Enabling them takes php artisan vendor:publish --tag=phpclaw-config and then uncommenting fully-qualified class names — an explicit act by someone with repository access, not a checkbox in an admin panel.

Be precise about what that narrowness covers, though. The opt-in gate is on the framework-integration tools — DB, log, route, config, cache, queue access through Laravel’s own facilities. The 7 core utility tools still auto-load. Laravel’s default is narrower than WordPress’s, but it is not shell-free by default.

Symfony (6.4+7.x)

No dedicated guard log. guard.blocked bridges to the Symfony event phpclaw.guard.blocked; register an event listener or subscriber against that name to capture blocks in whatever logging you already run.

Symfony loads the 7 core tools automatically and gates two Symfony-specific ones, DatabaseTool and LogTool, behind explicit declaration in phpclaw.tools config — neither carries #[Tool(default:true)]. One behavior to know about, because it is silent: DatabaseTool no-ops without a Doctrine DBAL connection. It does not error. An operator who enables it in an app without DBAL configured gets a tool that appears in the schema menu and quietly does nothing, which is a different failure mode from a guard block and will not show up in your phpclaw.guard.blocked listener.

Drupal (10/11)

No dedicated table. Blocks bridge to Drupal’s EventDispatcher as phpclaw.guard.blocked.

Drupal has the most interesting default-surface split of the eight. The agent path gets 15 Drupal-specific tools plus ShellTool and HttpTool; the chat path gets the 15 only. Chat, the surface most likely to be exposed to a less-trusted user, does not carry shell access. That is a meaningful architectural decision and it is worth knowing which surface you are exposing.

Drupal also accepts external tools through phpclaw.tool-tagged services, meaning any contrib or custom module in the container can extend the execution surface. The same seven guards run regardless, but the thing they are guarding grew — and guards scan the message, not the arguments those new tools receive.

One caveat specific to Drupal: PiiDetectionGuard’s priority is independently confirmed as 30 on seven of the eight adapters; the exact value wasn’t separately confirmed for Drupal. Treat 30 as correct on Drupal too — it’s an unread value, not a contradiction.

Magento (2.4+)

No local phpclaw_audit_* table. Blocks bridge to the Magento event phpclaw_guard_blocked, and if Cloud is configured, they are forwarded to the Cloud trace receiver (MongoDB). Magento is the one adapter in this set with a documented path to durable off-box storage of guard events — and it depends on Cloud being configured, not on anything Magento provides.

Magento’s wiring is DI-based: the console command is registered at etc/di.xml:15 and MCP at etc/di.xml:21. And Magento carries the sharpest platform-specific risk surface of the eight, which has nothing to do with guards and everything to do with what guards sit in front of. Four of its 12 wired tools — magento_orders, magento_customer, db_query, and shell — are ACL-gated. In CLI, that gate does not hold: the area-code check throws, the throw is caught, and the tool is treated as allowed. Every ACL-gated tool passes in CLI. If you run the Magento agent from the command line, the ACL is not doing what its name suggests, and the guard layer — which scans messages, not tool arguments — is not a substitute for it.

OpenCart (3+4)

No persistent record at all. GuardException is caught in the controller, written to error_log(), and returned to the caller as a JSON error. The event is also forwarded to OpenCart’s native event dispatcher so a third-party extension can listen.

OC3 and OC4 use identical tool classes and identical schemas — only controller routing and file paths diverge. OpenCart wires 10 OC-native tools plus core defaults. Of the eight adapters, this is the one where a blocked turn leaves the least behind: an error_log() line, a dispatched event nobody is listening to by default, and an HTTP response the caller sees and you do not.

PrestaShop (8.2+9)

The REST layer catches GuardException and returns HTTP 422 with "code":"phpclaw_guard" in the JSON body. Separately, boot and tool failures — not guard blocks — are written to PrestaShop’s native ps_log table via PrestaShopLogger::addLog(). Read that distinction carefully: PrestaShop has a native log table and phpClaw uses it, but not for guard violations.

PrestaShop wires 14 PS-specific tools plus core defaults, and there is no PS8/PS9 divergence — every tool goes through PsDbAdapter wrapping Db::getInstance(), identical on both versions. PrestaShop’s source is also where the GuardRegistry::count() observation surfaced, covered below.

Writing your own guard

A custom guard is one method. The contract is the same one the seven defaults implement:

// Illustrative shape — the interface's own namespace is declared in
// core/src/Guards/Contracts/GuardInterface.php and is not reproduced here.
final class NoCustomerEmailGuard implements GuardInterface
{
    public function scan(string $message): void
    {
        if (preg_match('/@yourcompany\.example/i', $message) === 1) {
            throw new GuardException('Message references an internal address.');
        }
    }
}

Return void to pass. Throw to block. There is no third option, and that is the point — you cannot write a guard that flags something and then gets ignored, unless you write one that never throws.

Registration is attribute-driven: guards declare a #[Guard] attribute carrying their priority and their enabledByDefault value, which is exactly how the seven defaults and RateLimitGuard differ from one another. Priority is a property of your guard class, so you choose where in the chain you sit relative to the built-ins — below MessageLengthGuard at 0 if you want to run before any content matching, above PiiDetectionGuard at 30 if you want to run last.

Each adapter additionally exposes a boot-time extension point through which third-party code can register new tools, guards, hooks, and skills — phpclaw.booting on Laravel and Symfony, tagged services on Drupal, an observer on Magento, a filter on WordPress, and phpclaw/extra/* events on OpenCart and PrestaShop. This is a separate mechanism from the lifecycle bridge: it registers new handlers at boot rather than firing events at runtime.

One caveat on that paragraph: the concrete per-adapter extension-point names are confirmed for the skills case on all eight adapters, and the mechanism — the same boot-time extension points cover guards — is confirmed too. The exact guard-specific hook or event name on each adapter was not separately confirmed. The mechanism is solid; for the precise per-adapter string, check your adapter’s boot file rather than guessing from the skills name.

Limitations

No adapter has a dedicated persistent audit table for guard violations. Not one of the eight. WordPress writes an error_log() line and fires an action. Joomla fires a Joomla event. Symfony, Drupal, and Magento bridge to their platform’s event dispatcher. OpenCart logs and returns JSON. PrestaShop returns a 422 and reserves ps_log for boot and tool failures, not guard blocks. Laravel’s Telescope path is the only structured record, it is opt-in, and it silently no-ops when Telescope is absent. Magento’s Cloud forwarding is the only durable off-box path, and it requires Cloud. If you need a queryable history of what your agent tried and what stopped it, you build it — subscribe to your platform’s guard.blocked event and write the row yourself.

A guard.blocked listener cannot intervene. The bridge is one-directional. Hook handlers cannot mutate their payload — the contract is handle(array $context): void and the docblock states mutations are ignored — and cannot abort execution; a throwing handler is caught, logged, and the loop continues. Your listener observes a block that already happened.

RateLimitGuard is off by default. Priority -1, enabledByDefault:false. Until an operator enables it, there is no rate limiting in the guard layer on any of the eight adapters. If your agent is reachable by an untrusted caller, this is the default you most likely want to change.

GuardRegistry::count() undercounts the enforcement surface. ToolOutputGuard carries no #[Guard] attribute and is wired directly in Agent’s constructor rather than through the registry. Any inventory, health check, or admin panel that reports “N guards active” by counting the registry is describing the registry and omitting the always-on sanitizer. This surfaced during PrestaShop’s own source review and applies to all eight.

Guards do not validate tool arguments. GuardInterface::scan() receives a message string. There is no argument validation in the core agent loop; each tool validates its own execute() input. ShellTool’s metacharacter check is a tool-level defense with its own exception type, not a guard, and its existence does not imply an equivalent check in any other tool you enable.

No provider-specific guard behavior was found, and none should be assumed either way. No differences in guard behavior across the 8 provider slugs turned up in source, and the guard layer operates on the message rather than on anything provider-shaped. But guard call sites per provider path weren’t separately enumerated either. Read that as “no differences recorded,” not as “differences were ruled out.”

Two pattern-level details are deliberately not stated here. The PATTERNS constant driving ToolOutputGuard’s redaction was not read in full, and core/src/Agent/OutputSanitiser.php was not read at all — it plausibly explains the guard.output_php_tag_removed and guard.output_function_redacted events, but that connection is inference, not verification, and this article does not assert it. Likewise, this article quotes exactly one verbatim rejection string — ShellTool’s — because that is the one confirmed here. Per-guard rejection message text for the seven defaults is not reproduced anywhere here, deliberately. Read the classes.

The evidence behind this article is pinned to a date, not a commit. All claims were checked against the monorepo working tree on 2026-08-22. No git SHA was captured. If source has moved since, re-verify rather than assume.

phpClaw Tools: How the Agent Calls Code is the necessary companion. The guard layer’s most important limit — that it scans messages and not tool arguments — only becomes actionable once you know exactly which tools are enabled on your adapter and what each one validates for itself.

phpClaw Hooks: The Agent Lifecycle You Can Intervene In covers the HookEventBridge mechanism that carries guard.blocked onto your platform’s native event system, including why “intervene” means observe rather than veto, and what the other 39 lifecycle events give you.

If you take one operational action from this article, make it two lines of config: turn RateLimitGuard on, and write a guard.blocked listener that persists a row somewhere you will actually look.