Skip to content

feat(gate): argument-conditional approval cards (gate.ask_when) - #296

Open
CrazyWillBear wants to merge 2 commits into
mainfrom
feat/gate-ask-when
Open

feat(gate): argument-conditional approval cards (gate.ask_when)#296
CrazyWillBear wants to merge 2 commits into
mainfrom
feat/gate-ask-when

Conversation

@CrazyWillBear

@CrazyWillBear CrazyWillBear commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Blocked on #297 (PR #298) — that lands first, then this rebases on top.

Why

One tool can be two actions. hound's smart_fetch reads a page, but the same call carrying actions clicks, fills, and submits on it — and hound ships no read-only variant and no flag to disable it.

GatePolicy.decide(tool_name) only ever saw the name, so the owner's options were: leave the tool off gate.approved and approve every page read by hand, or accept that approving fetch also approves clicking. Neither is what they wanted.

What

gate.ask_when maps a tool to argument names that pull it back into the card path however it was approved:

gate:
  approved: [mcp_hound_smart_fetch]
  ask_when:
    mcp_hound_smart_fetch: [actions]
  • Outranks every auto-approve: "*", an explicit approve, and the read_only fast path. gate.never still wins.
  • Presence-matching, not value-matching — smaller, and it covers actions.
  • "always" behaves like it does on every other card (deliberate — no strange exception for these). It persists mcp_hound_smart_fetch:actions rather than the bare tool name, so one tap grants the tool+argument the rule named and never the tool's other watched arguments.

A call carrying a watched argument is decided by its grants alone — the bare tool name is neither required nor sufficient. So a tool:argument grant genuinely approves that call on its own, while a tool nothing else approves still cards for every call not carrying a granted argument.

The two approved-set stores are unchanged: config.yaml is declared intent, gate_approved.json accretes taps, unioned at boot. An argument-scoped grant is just another entry in the same file, so a tap still never rewrites config.

Two consequences of presence-matching, both now documented in docs/CONFIG.md: one "always" covers every future value of that argument (the gate does not read what's inside), and the argument name is not validated against the tool's schema — a typo silently leaves the tool on whatever its ordinary listing says.

Structure

GatePolicy + load_approved/save_approved move to gate_policy.py. gate.py was at the 200-line hard cap, and the decision rules and the enforcement path (cards, announce, audit) are separate concerns. One drive-by: a 5-line import in wiring.py collapsed to one line (86 chars) to stay under the cap without an escape-hatch comment.

Review fixes (commit 2)

high-1 — a composite grant approved nothing. decide checked pending_arguments (which empties once every carried watched argument holds a grant) and then fell through to the bare-name check, so a grant only ever lifted the ask_when veto. For a tool not independently in approved / "*" / read_only, "always" was a no-op that re-carded forever:

p = GatePolicy(approved={'peek:actions'}, ask_when={'peek': ('actions',)})
p.pending_arguments('peek', {'actions': []})   # ()
p.decide('peek', {'actions': []})              # was ASK, now APPROVED

Grants are now checked before the bare name. Both pre-existing policy tests seeded the bare name alongside the grant, which is why this slipped through; the two new tests do not.

Docs the review also flagged: SECURITY.md documented decide(name) with no ask_when step at all (rewritten with the real order and the read_only override), and CONFIG.md was missing the presence-matching consequences above.

Tests

Precedence matrix in tests/test_gate.py — watched argument vs. "*", vs. an explicit approve, vs. read_only, vs. never — plus what "always" persists on each card type, that a grant covers only the argument it names, and that a grant approves without the bare name.

Done-check: 799 passed, ruff check clean, mypy clean.

Still open from the review

Follow-up (not in this PR)

chief-packages feat/hound currently hard-refuses actions via a stdio shim — the stopgap this replaces. It should only be stripped once this is merged and installed, and a card is verified firing against a real smart_fetch; removing it earlier leaves actions ungated on any box running current core.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EP49QtT1zxHvm1PPCmCpUz

One tool can be two actions: hound's smart_fetch reads a page, but the
same call carrying `actions` clicks and submits on it. The gate could
only decide on a tool name, so the owner had to either approve every
page read or accept that approving fetch also approved clicking.

`gate.ask_when` maps a tool to argument names that pull it back into the
card path however it was approved — it outranks `"*"`, an explicit
approve, and the read-only fast path; `gate.never` still wins. Matching
is on presence, not value.

"always" stays available on these cards like any other, but persists
`tool:argument` rather than the bare name, so one tap never approves the
tool's other watched arguments.

Splits GatePolicy and the approved-set persistence into gate_policy.py —
gate.py was at the 200-line cap, and the decision rules and the
enforcement path are separate concerns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EP49QtT1zxHvm1PPCmCpUz
@CrazyWillBear

Copy link
Copy Markdown
Collaborator Author

Blocked on #297 — do not merge yet.

Review found two highs:

  1. gate_policy.py:59-68 — "always" is a no-op for a tool that isn't independently approved. A composite grant lifts the ask_when veto but never approves, so decide falls through to the bare-name check and returns ASK forever. Verified:

    p = GatePolicy(approved={'peek:actions'}, ask_when={'peek': ('actions',)})
    p.pending_arguments('peek', {'actions': []})   # ()
    p.decide('peek', {'actions': []})              # Decision.ASK

    The hound example in this PR works only because the tool is also in gate.approved. Fail-closed, but the PR body's "one tap grants the tool+argument the rule named" is only the veto-lifting half — the approving half isn't implemented. Fix is in decide: return APPROVED when the call has watched arguments and all of them are granted, before the bare-name check. Both existing policy tests seed the bare name alongside the grant, which is why it slipped through.

  2. Subagent calls bypass the gate entirely — filed as Subagent tool calls bypass the gate entirely (blocks #296) #297 with a worked-out design. This PR's docs/CONFIG.md claims ask_when "outranks every auto-approve", which is false for the subagent path (and it's the whole gate that's skipped there, never included). Subagent tool calls bypass the gate entirely (blocks #296) #297 lands first, then this rebases on top and the docs claim becomes true.

Also outstanding from the review: grant_key's : namespace is unenforced against remote-supplied MCP tool names; a typo'd ask_when argument silently disables the rule with nothing logged; docs/SECURITY.md still documents decide(name) with no ask_when step; and docs/CONFIG.md never states that presence-only matching makes one "always" cover every future value of the argument.

Review high-1 on #296, owner-approved.

`decide` checked `pending_arguments` (which returns () once every carried
watched argument holds a grant), then fell through to the bare-name check. So
a composite grant only ever lifted the ask_when veto — it never approved. For
a tool not independently in `approved` / `"*"` / `read_only`, answering
"always" to an ask_when card was a no-op that re-carded forever:

    p = GatePolicy(approved={'peek:actions'}, ask_when={'peek': ('actions',)})
    p.pending_arguments('peek', {'actions': []})   # ()
    p.decide('peek', {'actions': []})              # was ASK, now APPROVED

A call carrying a watched argument is now decided by its grants alone, before
the bare-name branch: all granted → APPROVED, any pending → ASK. The bare name
is neither required nor sufficient there, so the grant stays narrow — it never
approves a call without that argument, and never covers a sibling argument.

Both existing policy tests seeded the bare name alongside the grant, which is
why this slipped through; the two new tests do not.

Docs the review also flagged:
- SECURITY.md documented `decide(name)` with no ask_when step at all. Rewritten
  with the real order, the read_only override, and why grants are checked first.
- CONFIG.md never said that presence-only matching makes one "always" cover
  every future value of the argument, nor that the argument name is unvalidated
  against the tool's schema (a typo silently disables the rule).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EP49QtT1zxHvm1PPCmCpUz
CrazyWillBear added a commit that referenced this pull request Aug 18, 2026
Both denial texts contradicted the PR's own argument, in opposite directions.

card_denied said "do not retry, do not re-ask" absolutely, which erases the
distinction the change exists to draw: the body argues a card decline is a
decision the owner can revisit, and the string forbade the retry revisiting
needs. The model tells the owner, the owner says go ahead, and the model holds
a standing instruction not to. Scoped to "on your own", with the owner's
go-ahead named as the exception.

never_denied claimed "no approval can lift it", which is false here —
gate.never is config, and self-edit/SKILL.md teaches the model to edit
gate.never/gate.approved and restart as routine. So the single highest-leverage
route around a never-list denial was the one workaround the string did not
name. It now says what is true (only the owner, in config.yaml) and forbids
that route explicitly. Not an enforcement hole (editing config still cards),
but a false absolute is worse than a scoped truth in a string whose whole job
is to be believed.

The perseveration breaker (agent/loop.py) fires on exactly this path and said
"take a different approach" — the workaround both denials forbid, from a second
string the model reads at the same moment. Reworded to respect a refusal while
keeping the advice for the ordinary repeated-failure case.

Lows: SECURITY.md documents the two texts and that they are behavioural, not
enforcement; cron/tools.py dropped the same "the owner declined" overclaim on
the sibling path. Rationale had been restated in four places — module docstring
is now the single home.

Left: the split vs #296 (gate_policy.py) — orthogonal concerns out of the same
overfull file, mechanical rebase whichever lands second.
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