Skip to content

feat(host-core): decide contract-mode admission by effect - #1720

Open
chengzhi-c wants to merge 7 commits into
vastsa:mainfrom
chengzhi-c:feat/deny-unsafe-plan-goal
Open

chengzhi-c wants to merge 7 commits into
vastsa:mainfrom
chengzhi-c:feat/deny-unsafe-plan-goal

Conversation

@chengzhi-c

@chengzhi-c chengzhi-c commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Plan and Goal decided tool admission from a read-only allowlist: a builtin that was not on the list was refused until someone classified it, so a read-only local tool was refused with it and every new builtin became a contract-mode regression. The gate now answers by effect. It refuses the builtins that act on state: the ones that write files or session state (Write, Edit, TodoWrite, GenerateImages, PluginScaffold, PluginPack, ScheduledTask*), the delegation family that spawns and controls work (Task*), and JevClassify. Every other name is admitted by the gate; Read, Glob, Grep, BrowserPreview, and an unclassified builtin then fall through to the ordinary permission path, which still applies risk, the effective permission mode, session grants, and the external-path rule.

Bash and user MCP tools keep taking a permission card in every mode, auto included. A plugin tool still needs a non-empty planSafeActions list, a plugin-contributed MCP tool is still refused in both modes, and a trusted-extension tool stays unavailable. What a contract mode offers the model is unchanged; this changes the gate's answer, not the catalog.

crates/host-core/src/permissions.rs and packages/agent-runtime/src/mode-tool-access.ts hold the same names, and mode-tool-access.test.ts fails when the two copies drift apart.

A state-changing builtin added later is admitted until someone classifies it, because the gate no longer refuses unknown names. ADR 0330 records that consequence: deciding what a new builtin does to state is part of adding it.

Checks:

  • cargo test -p host-core -- permissions:: 22 passed; -- scheduled_tools:: todos:: 26 passed
  • node scripts/e2e-plan.mjs 14 passed, 0 failed, 3 skipped
  • agent-runtime focused 384 passed; full 1390 passed / 9 failed against a base control of 1387 / 5, same four unrelated files (5s timeouts)
  • cargo test -p host-core full 889 passed, 38 failed: the same shell-spawn (os error 5) and git-worktree failures the pristine base shows on this machine, and the two RPC authorization tests fail only where they execute an approved Bash command
  • cargo clippy -p host-core --all-targets, cargo fmt --check, biome lint, scripts/check-architecture.mjs, check:pr-base clean

Specs, ADR 0330, and the decision log carry the new posture.

plan-deny-overview plan-deny-closeup

Plan with Auto still asks before Bash; the card is the decision, not the mode.

…g safe ones

Plan and Goal decided what they allowed by naming tools. That list answered
the wrong question: it asked where a tool came from rather than what the call
would do, so a read-only MCP search was refused for being an MCP tool while
the mode prompt — guidance, not a boundary — told the model not to mutate
anything.

The gate now classifies by effect. Read-only builtins reach the ordinary
permission path; a builtin outside that set is denied outright, so a tool
added later is denied until someone classifies it. Bash and an unclassifiable
MCP tool take a permission card in every permission mode, because neither
carries a signal the host can read: what a command does is decided by the
shell that runs it, and an MCP tool's name means nothing while its server's
own risk annotation is not evidence. A plugin tool is admitted when it
declares a non-empty planSafeActions list, which the plugin runtime also
enforces, and refused otherwise — an approval the callee would reject is not
a gate.

A trusted-extension tool is withheld in a contract mode. It executes in the
sidecar, so no host call is made for it and the permission path cannot see it.

The runtime's mode filter moves to the same shape so the catalog and the host
agree, and a refused card now reports an ordinary denial rather than a mode
denial.
Plan and Goal gate Bash and user MCP tools behind an approval card, but the
card came back on every call: the gate answered who decides and left the
repeated question in place. An exact-tool `allow-session` grant now
suppresses later cards for that tool in that session, which is what the card
is for, and it does not carry to another tool or another session.

The same change closes the gap the ADR left open for plugin-contributed MCP
tools. That registration path carries no enforced plan-safe action list, so
the host could only take the caller's word for it. The execution callback now
refuses Plan and Goal itself, before the server is called, instead of trusting
admission metadata the caller supplies.

MCP tools stay discoverable through ToolSearch rather than being declared
core, so a server's schemas do not enter every contract-mode request. The
catalog and the execution guard keep one predicate for what a contract mode
permits, and the extension and retained-reference denials are covered by
tests that fail without the guard.

closes vastsa#1112
…owlist

Plan and Goal classified builtins from a read-only allowlist, so a name nobody
classified was refused and a read-only local tool was refused with it. Ask the
question from the effect side instead: the contract modes deny the builtins
whose purpose is to change files, session state, or spawned work, and every
other name reaches the ordinary permission path, which still applies risk, the
permission mode, session grants, and the external-path rule. Bash and user MCP
tools keep taking a card in every mode.

The list exists in Rust and TypeScript, so a test now fails when the two copies
disagree. The spec, ADR 0330, the decision log, the RPC authorization gate, and
the plan E2E scenario record the new posture.
@chengzhi-c chengzhi-c changed the title feat(host-core): decide contract-mode admission by effect, not by allowlist feat(host-core): decide contract-mode admission by effect Oct 16, 2026
`context-compaction.test.mjs` asserted that `new_context` appears in
`crates/host-core/src/permissions.rs`. A contract mode keeps a deny list
instead of a read-only allowlist, so the name is no longer listed there. The
guard now asserts the name stays off that list, which is the same wiring
requirement ADR 0064 states.
@muzimu217

Copy link
Copy Markdown
Contributor

Read the core gate in an isolated worktree (head fdfccc3ea, 39 commits behind main — hence the CONFLICTING state; the runtime tests below were run on the PR tree after building @pi-desktop/shared/i18n: 53/53 pass in mode-tool-access.test.ts + fixed-tool-runtime.test.ts).

The direction is right — deciding by effect beats the old name allowlist, the cross-language mirror test is the correct guard, and the three-tier Denied/Approval/Allowed split matches ADR 0330. Two findings, one of them a behavior change I'd want called out explicitly:

1. Allowed is a widening for anything not in either list. On main, plan_mode_allows() is a positive allowlist: Plan/Goal deny every tool that isn't Read/Glob/Grep/Bash/BrowserPreview/new_context (permissions.rs:215-229 on main). The PR replaces that with "deny only the 14 state-changing names, ask for Bash/mcp_*, and let everything else fall through to the ordinary path" (:185-205). Consequence: builtins that are neither state-changing nor in the old allowlist — WebFetch, WebSearch, Skill, PluginCheck, Browser* — are now admitted in Plan/Goal where they were previously denied. Some of those (WebFetch/WebSearch) make outbound network calls with model-chosen URLs, which is a materially different risk posture for a read-only contract mode. If this is intended, it deserves a line in ADR 0330 saying the contract modes are now effect-scoped rather than capability-scoped, and the security spec's Plan/Goal section should say so too. If it isn't intended, Allowed should be a positive list and Denied the fallback.

2. The Approval branch short-circuits on a session grant. has_session_grant.then_some(AllowSession) (:269-272) means a session that has ever granted Bash (or an mcp_* tool) keeps running it in Plan/Goal with no card, because evaluate_auto... returns AllowSession before the card path is reached. That is arguably fine (the user did grant it), but it is a new path: on main Bash in Plan fell through to the normal risk path, where High risk + auto was the only way to skip the card. Worth one test that pins the intended semantics — contract_modes_require_approval_unless_the_tool_has_a_session_grant covers the positive case but I don't see one asserting that auto permission mode cannot skip the card for Bash (the doc comment claims it, the code path for a grantless session does reach None → card, so I believe it holds — just not pinned).

Minor: the plugin carve-out still keys off plan_safe_actions being non-empty at the host and defers per-action enforcement to plugin-runtime.ts:2858-2878; that two-layer split is pre-existing and correct, but the host comment could point at the enforcing line so a future reader doesn't assume the host validates actions.

…-goal

Only docs/adr/README.md conflicted. Main merged its own ADR 0330 while this
branch was open, so the contract-modes ADR moves to 0331 and every reference
to it follows; the index keeps both rows.
Review asked for the consequence of replacing the read-only allowlist with a
deny list to be visible where the posture is defined: the gate admits every name
it does not deny, so a builtin nobody classified reaches the ordinary permission
path. ADR 0331 and the security spec now say so, name the builtins admitted by
omission, and put network access in the deny-list criterion so a future egress
builtin is classified instead of inherited.

The host comment on the plugin carve-out points at the plugin-runtime execute
path that enforces the declared actions.

This branch has not been deployed

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

2 participants