This repository was archived by the owner on Aug 27, 2026. It is now read-only.
feat(guard): lead every deny reason with workspace-guard: - #172
Merged
Conversation
A deny is refused before the tool runs, so it leaves no prompt for the agent to attribute — the reason text handed back is the only trace of which hook blocked the command. Debugging a refusal therefore starts by working out which guard produced it. `decide` is the single place that pairs a decision with a reason, so the prefix goes there and reaches Bash, the native read tools, the Edit/Write handlers, and the PowerShell frontend alike. `ask` keeps the bare reason: the decision stream already carries `hookName` and the hook `command` for those, and ask is this guard's dominant verdict. The `<name>-guard: ` shape is a cross-guard convention rather than a wording choice — foreground-guard 0.5.1's friction report keys its deny attribution on exactly this opener, and its own comment records that a guard wording its reason differently under-counts its denies under `--plugin all`. Brackets would break the one reader that exists. Verified our own friction-report survives it: REASON_PATTERNS are applied with an unanchored `.search()`, so a prefixed reason still categorizes as `outside` rather than falling into `other`. Pinned as a fixture.
Q82: CLAUDE.md tells fixtures to name synthetic placeholders, and the suite names `/etc/passwd`, `~/.ssh/id_rsa`, and `$HOME/.aws/credentials`. All subprocess-only, which the rule itself calls technically safe — so either the fixtures change or the rule narrows to what the tree does. Q83: measured while verifying the deny-attribution contract — `friction-report.py --since all` reports allow 56,796, ask 1,398, deny 0 over 58,194 decisions, yet host-temp deny is this guard's default. The attachment stream carries no denies at all, which foreground-guard hit first and recovers by reading the tool-result text keyed on the `<name>-guard: ` opener. The opener landed in the previous commit; the reader is the follow-up.
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
Every deny reason now opens with
workspace-guard:. Prompts (ask) are unchanged.A deny is refused before the tool runs, so it leaves no prompt for the agent to
attribute — the reason text handed back is the only trace of which hook blocked the
command. Debugging a refusal starts by chasing attribution instead of the defect.
The prefix goes in
decide(), the single place that pairs a decision with a reason,so it reaches Bash, the native read tools, the Edit/Write handlers, and the PowerShell
frontend alike.
askkeeps the bare reason: the decision stream already carrieshookNameand the hookcommandfor those, andaskis this guard's dominant verdict— widening the prefix to it would be noise for no attribution gain.
The
<name>-guard:shape is a cross-guard contract, not a wording choice.foreground-guard 0.5.1 already emits it and parses it:
DENY_TEXT = re.compile(r'^(?:Error:\s*)?([a-z0-9-]+-guard):\s')at itsscripts/friction-report.py:107, with the reasoning at:100-106. Its own commentstates that a guard wording its reason differently under-counts its denies under
--plugin all. Brackets — the shape originally proposed — would break the one readerthat exists.
Testing
python3 scripts/run-tests.py— 1324 tests, OK, 8 skipped, exit 0. Run twice: onceafter the code landed, once over the final tree including the docs and backlog edits.
New coverage:
DenyAttributionTestsdrivesdecide()directly across all four deny drivers(
bypassPermissionsoutside, host-temp, sibling-checkout write, unanchored kill),asserting each reason matches foreground-guard's
DENY_TEXTregex withworkspace-guardin group 1 — the contract, not just the literal prefix.ask, a host-temp denydowngraded by
WORKSPACE_GUARD_TMP_ACTION=ask, and a sibling/kill downgraded byWORKSPACE_GUARD_OVERRIDEall stay bare.DenyAttributionEndToEndTestsasserts the prefix survives to the emitted JSON viathe subprocess path.
tests/test_friction_report.pypins the round-trip: a prefixed reason stillcategorizes as
outsiderather than falling intoother.Verified, not assumed — this guard's own
scripts/friction-report.pymatchesreason text at
:43-45and applies those patterns with an unanchored.search()at:361, read in this tree rather than in an installed cache copy. The prefix survives.Docs
README.md— thedenybullet in What it does states the prefix and whyaskstays bare.
skills/reduce-workspace-guard-prompts/SKILL.md— its diagnosis list claimed eachreason starts with its category name. True for a prompt, false for a deny once this
landed, so the skill now says to match the category name anywhere in the reason.
Backlog
Two rows filed (isolated commit), neither in scope here:
CLAUDE.mdtells fixtures to name synthetic placeholders; the suite names/etc/passwd(~20 sites),~/.ssh/id_rsa, and$HOME/.aws/credentials. Allsubprocess-only, which the rule itself calls technically safe — so either the
fixtures change or the rule narrows to what the tree does.
friction-report.py --since allreports
allow 56,796, ask 1,398, deny 0over 58,194 decisions, yet host-temp denyis this guard's default. The attachment stream carries no denies at all, so this
guard's own friction report is blind to every verdict this PR just made
attributable. foreground-guard hit that first and recovers it by reading tool-result
text keyed on the
<name>-guard:opener. This PR is the prerequisite; the reader isthe follow-up.
Release note
Deny messages now open with
workspace-guard:, so a refusal names the guard that produced it. Prompts are unchanged.