Skip to content

feat: safe-by-default tool curation — write-capable tools opt-in for autonomous principals#411

Merged
chubes4 merged 3 commits into
Automattic:mainfrom
chubes4:safe-default-write-tools
Jul 6, 2026
Merged

feat: safe-by-default tool curation — write-capable tools opt-in for autonomous principals#411
chubes4 merged 3 commits into
Automattic:mainfrom
chubes4:safe-default-write-tools

Conversation

@chubes4

@chubes4 chubes4 commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Addresses #408. Part of #412.

Makes write-capable / mutating ability categories opt-in (not ambient) for autonomous principals (no human in the loop), while leaving human-backed (interactive) principals unchanged. This is tool-surface CURATION (defense-in-depth) — it reduces prompt surface and stops an autonomous agent from even trying a write tool. It is NOT the security boundary: the real boundary is capability-ceiling ENFORCEMENT at ability/tool execution, tracked separately in #412.

Production incident this prevents: a pipeline agent silently published 224 junk posts by calling a generic publish ability it was never meant to reach. (Consumer-side emergency fix: Extra-Chill/data-machine#2853; this PR is the durable substrate curation layer.)

Update (this push) — repositioned onto principal autonomy

The gate now keys off the execution principal's autonomy, which the substrate already models precisely, instead of a mode/interactive string (a weaker proxy). Per the deeper analysis in #412, the gate is also now explicitly documented as curation, not enforcement.

  • Deleted WP_Agent_Tool_Policy::INTERACTIVE_MODES (the chat/interactive/rest mode-marker list) and the is_non_interactive_context() mode check entirely. The write-gate now names zero consumer modes.
  • Replaced is_non_interactive_context($context) with is_autonomous_principal_context($context), keyed off the principal.
  • Kept RUNTIME_CHAT = 'chat' — it is still the default value for the generic tool mode filter (filter_by_mode(), a substrate-native interactive surface that lets tools declare the modes they are available in). The write-gate no longer touches it.
  • Documented the gate as tool-surface CURATION (defense-in-depth); capability enforcement is Enforce capability ceiling at ability/tool execution — the Core-worthy boundary for autonomous writes (beneath #408/#411) #412.

How autonomy is determined

A principal is autonomous when EITHER:

  • its auth_source is an automation source (system / runtime / agent_token), OR
  • it has no human backing it (acting_user_id === 0).

The principal is resolved, in order, from:

  1. a principal entry on the context — a WP_Agent_Execution_Principal instance or its array shape (to_array()), or
  2. flat auth_source / acting_user_id fields on the context root (mirrors how WP_Agent_Action_Policy_Resolver already reads acting_user_id).

When no principal information is present at all, the gate falls back safe: autonomy is assumed and the gate engages. Prefer the principal at the call site; the fallback exists so an unannotated context cannot silently expose write tools.

The system / runtime / agent_token strings here are the principal's authentication source identifiers (substrate primitive constants on WP_Agent_Execution_Principal::AUTH_SOURCE_*), not consumer orchestration mode names. The gate contains zero consumer mode-name vocabulary.

Why autonomy, not mode

The substrate already models who/what is driving the run via the principal. A mode string only describes the runtime surface (chat/rest/cli/…), not whether a human is accountable for the loop. Two examples the old mode-based gate got wrong:

  • An agent-token principal driving an unattended loop is autonomous even though it may carry an acting_user_id — a mode-based gate that treats rest as "interactive" would surface write tools to it.
  • A user-session principal in an opaque consumer mode is not autonomous — but the old gate gated them because the mode wasn't a recognized interactive marker.

Keying off autonomy fixes both. git grep -nE "INTERACTIVE_MODES|'pipeline'|'system'|RUNTIME_PIPELINE|RUNTIME_SYSTEM|NON_INTERACTIVE" src/Tools/ now returns only the auth_source allow-list ('system', 'runtime', 'agent_token').

What's excluded by default (for autonomous principals)

Tools classified as write-capable are removed unless preserved or explicitly opted in. A tool is write-capable when either:

  1. It declares a per-tool flag'write' => true or 'mutating' => true on the tool/ability-tool definition. (Layer-pure: the tool declares its own nature, the policy enforces the default. Mirrors the existing action_policy per-tool declaration precedent.)
  2. One of its categories matches a write-capable category — built-in set is ['write', 'publishing', 'mutating', 'destructive'], all generic classification vocabulary (not consumer tool names).

The substrate never hardcodes consumer tool slugs (e.g. publish-wordpress) and never enumerates consumer mode names. Classification is by declared nature or generic category.

What's exempt (unchanged behavior)

Path Effect
Human-backed principal (auth_source user/application_password with acting_user_id > 0) Gate not applied. Request-user-scoped and already access-gated.
Mandatory tools/categories $preserve_tool closure still exempts them — mandatory plumbing survives.
Declared-mandatory tools ('mandatory' => true) Preserved.
allow_only Names the write tool explicitly → kept.
runtime_tools Names the write tool explicitly → kept.
runtime_categories Category match → kept.
ALLOW-mode named policy (mode: allow, tools: [...]) Named tool/category → kept.

The gate reuses the existing collect_runtime_tool_opt_in() aggregate, so every opt-in path works uniformly — no parallel opt-in path was built.

Escape hatches

Mechanism Effect
Context key 'allow_write_tools' => true Disables the curation gate entirely for that resolution.
Policy fragment 'allow_write_tools' => true Same, via agent config / tool_policy / provider.
Filter agents_api_write_capable_categories Extend or replace the built-in category set (Core/consumer).

The final agents_api_resolved_tools filter still runs after the write gate, so host-level overrides compose on top.

Where it sits in resolve()

filter_by_mode → access_checker → named policies → categories
→ allow_only → runtime opt-in → [WRITE-CAPABLE CURATION GATE] → deny → final filter

The gate runs after the runtime opt-in gate (so it can reuse the aggregated opt-in set) and before deny (so explicit deny always wins) and before the final filter (so hosts can still override the result).

Smoke test

tests/write-capable-default-smoke.php (37 assertions) proves the gate keys off principal autonomy, not a mode string:

  1. Curation contracts + mode-name neutralityINTERACTIVE_MODES / NON_INTERACTIVE_MODES / RUNTIME_PIPELINE / RUNTIME_SYSTEM are gone; RUNTIME_CHAT retained (as the generic mode-filter default); filter methods present.
  2. Autonomous runtime principal → every write-capable tool gated (category or flag); read + mandatory survive.
  3. Declared write/mutating flags gated exactly like category-classified tools.
  4. Both autonomy signals independently engage the gateagent_token auth_source (even with an acting user) and acting_user_id=0 (even for a non-automation auth source).
  5. Human-backed principals keep write tools → user-session and application-password principals.
  6. The gate keys off the principal, NOT the mode (decisive contract) → autonomous principal gated even in chat mode; user principal keeps write tools even in pipeline mode.
  7. Principal supplied as array shape OR flat context fields → both supported.
  8. Safe fallback → no principal in context → treated as autonomous (gate engages).
  9. allow_only / runtime_categories / ALLOW policy / runtime_tools → write tool restored for an autonomous principal.
  10. mandatory_tools → write tool preserved even for an autonomous principal.
  11. allow_write_tools (context + policy) → ambient write tools restored.
  12. agents_api_write_capable_categories filter → extended category gated for autonomous principal, visible for user principal.
  13. agents_api_resolved_tools final filter still runs after the gate.

tests/tool-policy-contracts-smoke.php section [5] was updated to model an interactive user principal so the generic runtime-tool opt-in behavior stays in focus without the write curation gate interfering.

All 37 Write-capable default assertions passed.

Full suite: composer smoke (all existing tests still green) and composer phpstan (clean).

Consumer impact: Data Machine needs zero changes

Data Machine (the primary consumer) requires no code changes for this repositioning. The curation gate is a no-op for DM's tools: every DM ability category is datamachine-* namespaced (datamachine-publishing, datamachine-content, …) and no DM tool sets a write/mutating flag. None of DM's tools match the substrate's generic write-capable set (write/publishing/mutating/destructive). DM's own pipeline write-gating (filterPipelineWriteOptInTools, which targets requires_pipeline_opt_in + datamachine-publishing) continues to own product-specific gating exactly as before. The substrate gate simply composes as defense-in-depth without dropping anything DM currently exposes.

When DM (or any consumer) wants the substrate to engage for an autonomous principal, it passes the execution principal into the resolver context — the substrate no longer guesses from a mode string.

Relationship to #412

Shipping curation as if it were the security boundary would give Core sites a false sense of safety. This PR is explicitly scoped as curation, with enforcement tracked in #412.

Notes

chubes4 added 2 commits July 5, 2026 19:38
…odes

Add a safe-default gate to WP_Agent_Tool_Policy that excludes
write-capable tools in non-interactive runtime modes (pipeline,
system) unless they are mandatory plumbing or explicitly opted in.

Classification mechanism (layer-pure):
- Per-tool declaration: 'write' => true or 'mutating' => true on the
  tool/ability-tool definition. The tool declares its own nature; the
  policy enforces the default.
- Category fallback: a tool whose category matches a write-capable
  category slug (write, publishing, mutating, destructive by default)
  is also gated.

Exemptions (unchanged behavior):
- Interactive chat mode is not affected.
- Mandatory tools/categories and declared-mandatory plumbing survive.
- Explicit opt-in via allow_only, runtime_tools, runtime_categories,
  or an ALLOW-mode named policy restores write tools.

Escape hatch:
- Context or policy fragment key 'allow_write_tools' => true disables
  the gate entirely for that resolution.
- The 'agents_api_write_capable_categories' filter lets Core and
  consumers extend or replace the built-in category set.

The gate reuses the existing collect_runtime_tool_opt_in() aggregate
so every opt-in path works uniformly. No vendor/consumer tool slugs
are hardcoded — classification is by declared nature or generic
category, never by tool name.

Addresses Automattic#408.
The tool policy is a neutral substrate and must not enumerate
consumer-specific automation mode names. Remove the layer-purity
violation introduced by RUNTIME_PIPELINE/RUNTIME_SYSTEM and the
NON_INTERACTIVE_MODES membership list:

- Delete RUNTIME_PIPELINE and RUNTIME_SYSTEM constants (consumer
  orchestration vocabulary the substrate defined but never acted on).
- Delete the NON_INTERACTIVE_MODES enumeration added in this PR.
- Replace is_non_interactive_mode($mode) with a descriptive
  is_non_interactive_context($context): non-interactive = NOT
  interactive, reusing the same interactive detection the consent
  policy already uses (interactive => true OR mode in chat/interactive/rest).
- Keep RUNTIME_CHAT = 'chat' (the substrate's own interactive vocabulary).
- Rewrite the conversation-store $context docstrings to describe modes
  as consumer-defined opaque strings with interactive/non-interactive as
  the substrate's axis.
- Rewrite the write-capable smoke test to drive the gate via the
  interactive axis rather than nuked constants, including cases for an
  opaque consumer mode string, interactive => true/false, and the
  interactive markers.

The substrate is now fully mode-name neutral. Consumers keep their own
product-shaped mode vocabulary and map onto the interactive axis at the
call site.

Data Machine (the primary consumer) needs zero changes: its chat path
already passes interactive => true, and all of its ability categories
are datamachine-* namespaced, so none match the substrate's generic
write-capable category set (write/publishing/mutating/destructive) and
no tool sets write/mutating flags.
Reposition the write-capable gate to key off the execution principal's
autonomy instead of a mode/interactive string. The substrate already
models autonomy precisely (WP_Agent_Execution_Principal auth_source /
acting_user_id), so a mode string was a weaker proxy.

A principal is autonomous when its auth_source is an automation source
(system / runtime / agent_token) OR it has no human backing it
(acting_user_id === 0). For autonomous principals, write-capable tools
remain opt-in (curation); human-backed principals are unchanged. The
principal is read from a `principal` entry (instance or array shape) or
flat auth_source/acting_user_id on the context root. With no principal
information present, the gate falls back safe (autonomous = engages).

Drop the INTERACTIVE_MODES mode-marker list and the is_non_interactive_context
mode check entirely so the gate names NO consumer modes at all. RUNTIME_CHAT
is retained as the generic tool mode-filter default (a substrate-native
interactive surface the gate no longer touches).

Document the gate explicitly as TOOL-SURFACE CURATION (defense-in-depth),
NOT the enforcement boundary. Capability-ceiling enforcement at
ability/tool execution is tracked separately in Automattic#412.

Rewrite tests/write-capable-default-smoke.php to drive the gate via
principal autonomy (autonomous principal -> write tools curated out;
user/interactive principal -> retained; explicit opt-in -> retained;
no-principal -> safe fallback engages; mode string does not move the
gate). Update tests/tool-policy-contracts-smoke.php section [5] to
model an interactive user principal so the generic runtime-tool opt-in
behavior stays in focus without the write curation gate interfering.

Part of Automattic#412.
@chubes4 chubes4 changed the title feat: safe-by-default tool policy — write-capable tools opt-in in non-interactive modes feat: safe-by-default tool curation — write-capable tools opt-in for autonomous principals Jul 6, 2026
@chubes4 chubes4 merged commit ad2e27b into Automattic:main Jul 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant