fix: verify name-grant program identity on all auto-approve surfaces (#6361) - #6504
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound consolidation, but the Watch
Suggestions
[DESIGN-REVIEWED] 23cca72 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts verified: every First-Principles-Verdict: CONCERNS One verified surface — the gateway What this change shipsIntent: stop a PATH-shadowed or agent-writable program from riding a name-based shell auto-approve on unattended surfaces (#6361) — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 23cca72 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
f40996f to
8edc7c5
Compare
ai-review-disposition — First Principles laneFinding: the decline-not-raise guard shipped as two hand-maintained copies, leaving the dashboard's trusted-pattern and trust-reads tiers unguarded. Disposition: accepted — the Subtraction is adopted as proposed, in head
One consequence worth stating: a raising STUB installed at a rung seam no longer gets a second-chance catch in the wrapper — the guard protects failures inside the real check, which is the failure the invariant is about. |
8edc7c5 to
d2272dd
Compare
d2272dd to
7e6db99
Compare
7e6db99 to
3d0777d
Compare
CI status — blocked on a main-side breakage, not this PRRebased onto current main (
All of this PR's own owned checks are green (GPT/Opus/Design/First-Principles AI lanes settled, the four earlier CI fix rounds resolved). Per the pipeline's main-inheritance policy I am NOT patching main's test on this branch. Holding on a low-frequency watch of main's own CI; will rebase and re-verify once main is green again. |
3d0777d to
bd33fc2
Compare
bd33fc2 to
d5ca344
Compare
…6361) The name-grant check refuses a name-based shell auto-approve when a program name no longer resolves to the program it names, but it was wired into the dashboard chat loop only. The task runner, subagents, the channel turn driver, and the native Slack handler honoured the same hook grant verbatim - the unattended surfaces, where a shadowed PATH entry is the cheaper attack path. Promote the off-loop entry point into kiro_crew.name_grant (refusal_for_command_off_loop / refusal_for_event) and await it at each surface's honour point; a refusal downgrades to that surface's normal non-auto-approve path (interactive prompt, deny-by-default), never a hard block, and is audited as auto_approve_declined. Closes #6361
d5ca344 to
23cca72
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Re-approving after conflict-resolution rebase: code rebased clean; three doc conflicts resolved editorially (main's newer structure kept, PR's name-grant verification prose woven in; single commit, 22 files unchanged in scope).
The auto_approve_subagent_spawn rung consulted only the model-authored event title on every channel surface, so a SHELL command whose title was forged to "spawn_run" was auto-approved when the hook was enabled -- bypassing the hook gate's command inspection and the name-grant verification (#6504). The rung now keys on canonical, non-model-authored identity via one shared predicate, hooks.event_is_spawn_run: - Tier 1 (canonical): event.tool_name (from _meta.kiro) decides when present — accepted only as "spawn_run" WITH the mcp_identity_trusted provenance flag AND mcp_server_name pinned to kirocrew-core, so a foreign MCP server or built-in that merely names a tool spawn_run cannot ride the rung; any other canonical name refuses regardless of the title. - Tier 2 (no _meta.kiro): the title check hardened with a RESOLVED non-shell classification (shell_classified and not is_shell) — is_shell alone miss-defaults to False on the same correlated cache miss that empties tool_name, which would collapse both tiers back to the title-only check. Genuine spawn_run MCP calls stay auto-approved, so unattended fan-out remains unblocked. - messaging/driver.py: AutoApprovePredicate now takes the PERMISSION EVENT; the honour point passes the event, never the title. - messaging/dispatch.py build_auto_approve: predicate takes the event and delegates to event_is_spawn_run. - discord/ + telegram/ transport_dispatch: the inline title-only replicas are deleted in favour of the shared build_auto_approve. - slack/handler.py _should_auto_approve_spawn (native rung + transport lambda): takes the event, delegates to event_is_spawn_run. - Tests pin BOTH directions per surface (genuine spawn approved, forged shell title falls to the ladder) plus a structural pin that no surface re-inlines a title-only check. Mutation-verified: reverting the predicate to title-only or the driver call site to title-passing fails the new tests. Closes #6506
The auto_approve_subagent_spawn rung consulted only the model-authored event title on every channel surface, so a SHELL command whose title was forged to "spawn_run" was auto-approved when the hook was enabled -- bypassing the hook gate's command inspection and the name-grant verification (#6504). The rung now keys on canonical, non-model-authored identity via one shared predicate, hooks.event_is_spawn_run — deny-by-default, no title fallback: event.tool_name (from _meta.kiro) must be "spawn_run", carry the mcp_identity_trusted provenance flag, and be served by the crew's own MCP server (session_directive.CORE_MCP_SERVER). A foreign MCP server or built-in that merely names a tool spawn_run cannot ride the rung, and an event without canonical identity (no _meta.kiro, or the correlated provenance-cache miss) falls to the channel's normal approval ladder — a downgrade, never a hard block. Genuine spawn_run MCP calls stay auto-approved, so unattended fan-out remains unblocked. - messaging/driver.py: AutoApprovePredicate now takes the PERMISSION EVENT; the honour point passes the event, never the title. - messaging/dispatch.py build_auto_approve: predicate takes the event and delegates to event_is_spawn_run. - discord/ + telegram/ transport_dispatch: the inline title-only replicas are deleted in favour of the shared build_auto_approve. - slack/handler.py _should_auto_approve_spawn (native rung + transport lambda): takes the event, delegates to event_is_spawn_run. - Tests pin BOTH directions per surface (genuine spawn approved, forged shell title falls to the ladder) plus a structural pin that no surface re-inlines a title-only check. Mutation-verified: reverting the predicate to title-only or the driver call site to title-passing fails the new tests. Closes #6506
The auto_approve_subagent_spawn rung consulted only the model-authored event title on every channel surface, so a SHELL command whose title was forged to "spawn_run" was auto-approved when the hook was enabled -- bypassing the hook gate's command inspection and the name-grant verification (#6504). The rung now keys on canonical, non-model-authored identity via one shared predicate, hooks.event_is_spawn_run — deny-by-default, no title fallback: event.tool_name (from _meta.kiro) must be "spawn_run", carry the mcp_identity_trusted provenance flag, and be served by the crew's own MCP server (session_directive.CORE_MCP_SERVER). The title must ALSO read "spawn_run" — not as identity, but because the channel PreToolUse gate keys deny rules on the title, so a rephrased-title spawn falls to the ladder instead of approving past a title-keyed deny. This exactly preserves the rung's pre-fix approval surface minus the forgeries. A foreign MCP server or built-in that merely names a tool spawn_run cannot ride the rung, and an event without canonical identity (no _meta.kiro, or the correlated provenance-cache miss) falls to the channel's normal approval ladder — a downgrade, never a hard block. Genuine spawn_run MCP calls stay auto-approved, so unattended fan-out remains unblocked. - messaging/driver.py: AutoApprovePredicate now takes the PERMISSION EVENT; the honour point passes the event, never the title. - messaging/dispatch.py build_auto_approve: predicate takes the event and delegates to event_is_spawn_run. - discord/ + telegram/ transport_dispatch: the inline title-only replicas are deleted in favour of the shared build_auto_approve. - slack/handler.py _should_auto_approve_spawn (native rung + transport lambda): takes the event, delegates to event_is_spawn_run. - Tests pin BOTH directions per surface (genuine spawn approved, forged shell title falls to the ladder) plus a structural pin that no surface re-inlines a title-only check. Mutation-verified: reverting the predicate to title-only or the driver call site to title-passing fails the new tests. Closes #6506
The auto_approve_subagent_spawn rung consulted only the model-authored event title on every channel surface, so a SHELL command whose title was forged to "spawn_run" was auto-approved when the hook was enabled -- bypassing the hook gate's command inspection and the name-grant verification (#6504). The rung now keys on canonical, non-model-authored identity via one shared predicate, hooks.event_is_spawn_run — deny-by-default, no title fallback: event.tool_name (from _meta.kiro) must be "spawn_run", carry the mcp_identity_trusted provenance flag, and be served by the crew's own MCP server (session_directive.CORE_MCP_SERVER). The title must ALSO read "spawn_run" — not as identity, but because the channel PreToolUse gate keys deny rules on the title, so a rephrased-title spawn falls to the ladder instead of approving past a title-keyed deny. This exactly preserves the rung's pre-fix approval surface minus the forgeries. A foreign MCP server or built-in that merely names a tool spawn_run cannot ride the rung, and an event without canonical identity (no _meta.kiro, or the correlated provenance-cache miss) falls to the channel's normal approval ladder — a downgrade, never a hard block. Genuine spawn_run MCP calls stay auto-approved, so unattended fan-out remains unblocked. - messaging/driver.py: AutoApprovePredicate now takes the PERMISSION EVENT; the honour point passes the event, never the title. - messaging/dispatch.py build_auto_approve: predicate takes the event and delegates to event_is_spawn_run. - discord/ + telegram/ transport_dispatch: the inline title-only replicas are deleted in favour of the shared build_auto_approve. - slack/handler.py _should_auto_approve_spawn (native rung + transport lambda): takes the event, delegates to event_is_spawn_run. - Tests pin BOTH directions per surface (genuine spawn approved, forged shell title falls to the ladder) plus a structural pin that no surface re-inlines a title-only check. Mutation-verified: reverting the predicate to title-only or the driver call site to title-passing fails the new tests. Closes #6506
Summary
The name-grant check (
src/kiro_crew/name_grant.py) refuses to honour a name-based shell auto-approve when a program name no longer resolves to the program it appears to name — a PATH-shadowing shim (~/.local/bin/headover/usr/bin/head), a resolution inside an agent-writable tree, or a file no approval ever identified. It was wired into the dashboard chat loop only. Every other surface honoured the samehooks.on_tool_callTOOL_AUTO_APPROVEgrant verbatim — and those are the unattended surfaces, where a shadowed name is the cheaper attack path, not the rarer one.This PR makes the check surface-agnostic and wires every honour point:
name_grant.pygains the public async entry pointsrefusal_for_command_off_loop(the ONE off-loop dispatch — worker thread for the filesystem work, the constant Windows decline answered on-loop) andrefusal_for_event(duck-typed onis_shell/shell_command; returnsNonefor non-shell/commandless events).refusal_for_eventnever raises (cancellation excepted): an unexpected check failure answers as anUNINSPECTABLErefusal, so a provider event loop is never wedged with an unanswered ACP permission.dashboard/chat_runner.py) — behavior identical:_name_grant_refusal_off_loopbecomes a module alias of the promoted helper (the seam its tests stub),_name_grant_refusal_forstays a thin wrapper (now carrying the same decline-not-raise guard),_audit_name_grant_refusaldelegates to the shared writer with a byte-identical SEL row.task_executor.py) — the hook auto-approve branch verifies before honouring; a refusal downgrades to the interactive prompt, or the pre-existing headless deny-by-default.subagent.py) — same, downgrading to the remaining rungs (parent policy, interactive factory/fallback, headless reject).messaging/driver.py) — verification happens atTurnDriver's gate"auto_approve"honour point, the one place shared by every channel's_tool_gate(messaging pipeline + the Slack/Discord/Telegram transports). The gates stay synchronous and check-free (they run on the loop); the honour point is already async, so no gate contract change was needed. The driver gains caller-injectedaudit_session_key/audit_agent(audit-only), wired at all four construction sites.slack/handler.py) — same shape as the dashboard rung.llm_helpers._resolve_permission(cron / autonudge / heartbeat turns) — verified only when the caller passed an interactive approver (that is the downgrade target); without one the helper's fall-through approves by default, so a decline would change nothing while writing a misleading audit row.Every decline is audited through ONE shared writer,
name_grant.log_decline(outcome=auto_approve_declined,reason=name_grant+ refusal code + tier,error=Refusal.log_text— a table constant — and a credential/exfiltration-redacted title; neverRefusal.detail, which names resolved paths). Callers pass their module-levelselbinding (sel_factory, required) so each module's audit test seam observes the row.Closes #6361
Deliberate decisions
kirocrew run, subagents with no approver) that path is the pre-existing deny-by-default — so a shadowed/project-local (.venv/bin) program that used to be auto-approved is now rejected there. This is the issue's intent: unattended is precisely where a planted shim would be honoured with nobody watching. Called out in the shipped doc (src/kiro_crew/docs/task-runner.md) including the two audit rows (name_grantdecline, thenheadless_no_authorizationreject).cmd.exesearch order nor POSIX tokenization — the fail-closed answer the original dashboard wiring already established). On the new unattended surfaces this means shell auto-approve is effectively off on Windows; documented intask-runner.md,subagent.md,messaging.md,heartbeat.md,messaging-transport.md.messaging/dispatch.py's_tool_gateis unchanged (no sync-gate decline, no async conversion): the honour point inTurnDriveris already async and covers all four channel gates at once. Loop-safety pins keep the gates check-free.parent_policy=auto,run_auto_approve,APPROVAL_TRUST_READSkind-rung, thespawn_runtitle predicate): none is a name-based shell grant. The trust-reads/kind-rung boundary is documented inmessaging.mdand pinned by test.security_posture.py:name_grant.pyregistered inNON_EGRESS_REDACTION_MODULES(audit-side log hygiene, not an egress boundary) per that registry's drift guard.Pre-push adversarial review
4 rounds, two blind model-pinned lanes per round (GPT + Opus mirrors), ~20 findings fixed with pinning tests — among them: redacted decline audits with disclosure assertions on all surfaces, the shared audit writer replacing five hand-copied rows, the never-raise guard (a check exception previously orphaned the ACP permission), the
llm_helperscron/autonudge honour point the first exemption rationale missed, driver audit attribution, a test order-dependence on the process-global trusted-session set, and doc corrections (subagent cascade YOLO-snapshot, sel outcome enum, heartbeat interaction).Dispositioned (documented, not changed here):
auto_approve_subagent_spawntitle predicate: an event titledspawn_runis approved by that rung today regardless of hook verdicts or name grants — pre-existing, not widened by this PR; hardening it means changing the title-only predicate signature across four channels + the native Slack helper (follow-up issue to be filed).Refusal.detailis human-facing and could ride the approval prompt — follow-up UX./statscounters don't count declines (no other outcome-specific counter exists for this shape).task_planner's decomposition phase approves every non-denied permission by design (like_resolve_permissionwithout an approver) — not a name-based grant; the shipped task-runner doc's verification claim is scoped to step execution.Testing
test/test_name_grant_surfaces.py(new): per-surface downgrade/clean/audit regression tests for the task runner, subagents, TurnDriver (incl. trust-reads boundary + audit identity), native Slack, andstream_and_collect; promoted-entry-point unit tests; decline-not-raise guard tests (shared helper + dashboard wrapper); platform-independent loop-safety pins (every surface routes through the one off-loop entry point; all four channel gates stay check-free) living outside the POSIX-skipped module.test/test_name_grant.py: loop-safety pins updated to the promoted entry point; all existing fix(security): stop a name-based auto-approve from honouring a shadowed program name #4920 verdict/wiring tests pass unchanged.no linked issue screenshots: backend security change, no UI surface touched.