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
Conversation
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.
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
#172 put the
workspace-guard:opener on deny reasons only, on the argument that anaskwas 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:askis the dominant verdict (1,398 asks vs 0 denies in the measured corpus).Both blocking verdicts now lead with
workspace-guard:.allowkeeps 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 carrieshookNameand the hookcommand. That is the argument #172 made; it is wrong foraskand correct forallow, so it moved rather than disappeared.Reconciled the two spellings.
handle_powershell's missing-command case emits directly rather than throughdecide, and named the guard in prose. Left alone it would now readworkspace-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-guardnow appears exactly once in it, in the constant.DENY_ATTRIBUTION→ATTRIBUTION, 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— "askreasons 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.Reason starts with "<category>". The prefix now leads, so all three were false for the same reason as the paragraph below them. Changed toReason 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_PATTERNSare applied with an unanchoredpat.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: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:decide()reverted to(ATTRIBUTION + reason) if deny_now else reason→ 7 failures, includingtest_both_verdicts_are_attributed,test_ask_reason_is_attributed, andtest_attribution_appears_once. The deny-side tests still passed, confirming the new assertions are specifically sensitive to the ask side.test_missing_command_field_asks_rather_than_defersfailed withAssertionError: 2 != 1, quoting the doubledworkspace-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
cwdinside the mutant it failed as above; the verdicts here are from that second run.End-to-end decisions, all four classes:
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
decidethat every deny driver also asks under configuration: host-temp undertmp_action=ask, sibling and kill underWORKSPACE_GUARD_OVERRIDE, outside underbypassPermissionsversus 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.shpasses (the row is at 248 of 250 permitted chars).Release note
Permission prompts now name the hook: an
askreason leads withworkspace-guard:, as adenyalready did. Nothing about which commands prompt has changed.