Skip to content
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
karlkfi merged 2 commits into
mainfrom
claude/epic-mendeleev-19098d
Aug 21, 2026
Merged

feat(guard): lead every deny reason with workspace-guard: #172
karlkfi merged 2 commits into
mainfrom
claude/epic-mendeleev-19098d

Conversation

@karlkfi

@karlkfi karlkfi commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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. 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
— 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 its
scripts/friction-report.py:107, with the reasoning at :100-106. Its own comment
states that a guard wording its reason differently under-counts its denies under
--plugin all. Brackets — the shape originally proposed — would break the one reader
that exists.

Testing

python3 scripts/run-tests.py — 1324 tests, OK, 8 skipped, exit 0. Run twice: once
after the code landed, once over the final tree including the docs and backlog edits.

New coverage:

  • DenyAttributionTests drives decide() directly across all four deny drivers
    (bypassPermissions outside, host-temp, sibling-checkout write, unanchored kill),
    asserting each reason matches foreground-guard's DENY_TEXT regex with
    workspace-guard in group 1 — the contract, not just the literal prefix.
  • The same class pins the negative half: a plain outside ask, a host-temp deny
    downgraded by WORKSPACE_GUARD_TMP_ACTION=ask, and a sibling/kill downgraded by
    WORKSPACE_GUARD_OVERRIDE all stay bare.
  • DenyAttributionEndToEndTests asserts the prefix survives to the emitted JSON via
    the subprocess path.
  • tests/test_friction_report.py pins the round-trip: a prefixed reason still
    categorizes as outside rather than falling into other.

Verified, not assumed — this guard's own scripts/friction-report.py matches
reason text at :43-45 and 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 — the deny bullet in What it does states the prefix and why ask
    stays bare.
  • skills/reduce-workspace-guard-prompts/SKILL.md — its diagnosis list claimed each
    reason 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:

  • Q82CLAUDE.md tells fixtures to name synthetic placeholders; the suite names
    /etc/passwd (~20 sites), ~/.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 this 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, 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 is
    the follow-up.

Release note

Deny messages now open with workspace-guard: , so a refusal names the guard that produced it. Prompts 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 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.
@karlkfi
karlkfi merged commit 5b9744d into main Aug 21, 2026
8 checks passed
@karlkfi
karlkfi deleted the claude/epic-mendeleev-19098d branch August 21, 2026 07:40
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