Skip to content
This repository was archived by the owner on Aug 27, 2026. It is now read-only.

feat(guard): lead ask reasons with workspace-guard: too - #173

Merged
karlkfi merged 2 commits into
mainfrom
claude/admiring-mclean-bc4f13
Aug 21, 2026
Merged

feat(guard): lead ask reasons with workspace-guard: too#173
karlkfi merged 2 commits into
mainfrom
claude/admiring-mclean-bc4f13

Conversation

@karlkfi

@karlkfi karlkfi commented Aug 21, 2026

Copy link
Copy Markdown
Owner

What

#172 put the workspace-guard: opener on deny reasons only, on the argument that an ask was already attributable because "the prompt itself already names the hook". That argument is false — Claude Code names the plugin in neither the ask prompt nor the text handed back from a deny. This repo is where it matters most: ask is the dominant verdict (1,398 asks vs 0 denies in the measured corpus).

Both blocking verdicts now lead with workspace-guard: .

allow keeps the bare reason. It surfaces to neither the operator nor the agent — no prompt, no refusal text — and the decision stream that does carry it already carries hookName and the hook command. That is the argument #172 made; it is wrong for ask and correct for allow, so it moved rather than disappeared.

Reconciled the two spellings. handle_powershell's missing-command case emits directly rather than through decide, and named the guard in prose. Left alone it would now read workspace-guard: workspace-guard could not read ..., so it uses the constant instead. A sweep of the script confirms this was the only prose self-naming site — workspace-guard now appears exactly once in it, in the constant.

DENY_ATTRIBUTIONATTRIBUTION, since it is no longer deny-only.

Docs corrected in the same diff

Both claims #172 merged, plus three the brief did not name:

  • README.md — "ask reasons stay bare: the prompt itself already names the hook."
  • skills/reduce-workspace-guard-prompts/SKILL.md — the paragraph explaining the prefix as deny-only. Its instruction ("match the category name anywhere in the reason rather than at the start") stays correct and is now universally required; only its justification was wrong.
  • Not in the brief: the same skill's items 1–3 each said Reason starts with "<category>". The prefix now leads, so all three were false for the same reason as the paragraph below them. Changed to Reason carries.

Testing

python3 scripts/run-tests.py — 1328 tests, OK (skipped=8), run over the final tree after the diff review.

The trap, checked on the ask side directly. REASON_PATTERNS are applied with an unanchored pat.search, so a leading prefix survives — but #172 only ever prefixed denies, and asks are the population the report actually sees. All three categories are now pinned with the prefix on, asserting the opener is not captured into the token list:

reason categorize()
workspace-guard: Outside-workspace path(s): a, ../b. Fix: x. {'outside': ['a', '../b']}
workspace-guard: Runtime-expanded arg(s) …: $f. Fix: y. {'expand': ['$f']}
workspace-guard: Relative path(s) after an untracked cd: c. Fix: z. {'untracked': ['c']}

Proved the tests can fail, out-of-tree (no tmp: commit on the branch). Two mutants built in the session scratchpad:

  1. decide() reverted to (ATTRIBUTION + reason) if deny_now else reason7 failures, including test_both_verdicts_are_attributed, test_ask_reason_is_attributed, and test_attribution_appears_once. The deny-side tests still passed, confirming the new assertions are specifically sensitive to the ask side.
  2. The prose self-naming reintroduced alongside the prefix → test_missing_command_field_asks_rather_than_defers failed with AssertionError: 2 != 1, quoting the doubled workspace-guard: workspace-guard ....

The first attempt at mutant 1 passed, because it ran from the worktree cwd and imported the in-tree code rather than the mutant. Re-run with cwd inside the mutant it failed as above; the verdicts here are from that second run.

End-to-end decisions, all four classes:

ask/outside     -> ask   | workspace-guard: Outside-workspace path(s): /q5-fake-target. Fix: …
deny/hosttemp   -> deny  | workspace-guard: Host-wide temp path(s): /tmp/q5-fake-target. …
deny/bypass     -> deny  | workspace-guard: Outside-workspace path(s): /q5-fake-target. Fix: …
deny/kill       -> deny  | workspace-guard: Unanchored process kill(s) blocked: …
allow/in-tree   -> allow | Guarded commands target workspace/pipe only

Backlog

Q83 narrowed, not filed new. Its note planned to recover deny counts from tool-result text by matching the workspace-guard: opener — a discriminator that worked only while #172's deny-only scope held. Asks carry it now, so the opener marks a block, not a deny.

Reason wording is not a substitute discriminator either. Verified against decide that every deny driver also asks under configuration: host-temp under tmp_action=ask, sibling and kill under WORKSPACE_GUARD_OVERRIDE, outside under bypassPermissions versus not. My first draft of the row asserted host-temp and kill wordings were deny-only; that was wrong and the check caught it before it shipped.

scripts/lint-backlog.sh passes (the row is at 248 of 250 permitted chars).

Release note

Permission prompts now name the hook: an ask reason leads with workspace-guard: , as a deny already did. Nothing about which commands prompt has changed.

172 put the attribution opener on deny reasons only, arguing that an ask
was already attributable because "the prompt itself already names the
hook". It does not: Claude Code names the plugin in neither the ask
prompt nor the text handed back from a deny. The operator answering a
prompt and the agent reading a refusal are in the same position, so both
blocking verdicts now carry the opener.

`allow` keeps the bare reason. It surfaces to neither the operator nor
the agent, and the decision stream that does carry it already carries
`hookName` and the hook `command` — the argument 172 misapplied to ask
is the correct one for allow.

Reconciles the one reason that named the guard in prose rather than in
the colon form: the PowerShell missing-command ask at handle_powershell
emits directly rather than through `decide`, so it would have read
"workspace-guard: workspace-guard could not read ...". It now uses the
constant, and a test pins the guard's name appearing exactly once.

`DENY_ATTRIBUTION` -> `ATTRIBUTION`, since it is no longer deny-only.

Verified the friction report survives this. REASON_PATTERNS are applied
with an unanchored `pat.search`, so a leading prefix does not break
categorization — but 172 only ever prefixed denies, and asks are the
population the report actually sees (1,398 asks vs 0 denies in the
measured corpus). All three ask-side categories are now pinned with the
prefix on, including that the opener is not captured into the token list.

Docs corrected in step: the README claim that ask reasons stay bare, and
the skill's three "Reason starts with <category>" claims plus the
paragraph explaining the prefix as deny-only.
Q83 planned to recover deny counts from tool-result text by matching the
`workspace-guard: ` opener, which identified a deny only while 172's
deny-only scope held. Asks carry it now, so the opener marks a block
rather than a deny.

Reason wording is not a substitute discriminator either: verified against
`decide` that every deny driver also asks under configuration — host-temp
under `tmp_action=ask`, sibling and kill under WORKSPACE_GUARD_OVERRIDE,
and outside under `bypassPermissions` versus not.
@karlkfi
karlkfi merged commit 9d25fdf into main Aug 21, 2026
8 checks passed
@karlkfi
karlkfi deleted the claude/admiring-mclean-bc4f13 branch August 21, 2026 13:32
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant