Skip to content

fix(repo-hygiene): close the three HIGH clean-skill audit findings - #3887

Merged
kyle-sexton merged 7 commits into
mainfrom
claude/3850-repo-hygiene
Sep 7, 2026
Merged

fix(repo-hygiene): close the three HIGH clean-skill audit findings#3887
kyle-sexton merged 7 commits into
mainfrom
claude/3850-repo-hygiene

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #3850

Summary

Four small, independently reversible fixes to repo-hygiene:clean, bundled because the operating-model contract on #3346 says PR 1 lands them together. Each was a case of the skill promising or implying a safety property the code did not deliver: a guard registered where it could never fire, a pull-request lookup that discarded evidence without saying so, a repository-root resolution that ended the run, and a frontmatter comment naming a gate that does not cover what it claimed.

A follow-up commit (400845c3) answers the fresh-context review: the guard now has an acknowledgement path on the PowerShell tool, section 4.3 consumes the stash audit's PR-map status line, and the shell-portability note below is corrected. A second follow-up (04bbe657) hardens the ack dispatch to default-deny and quotes the PowerShell stash selector, both described under 1c below.

Fix

1. The destructive guard never saw PowerShell. The frontmatter hook registered matcher: "Bash" while destructive-guard.sh has always carried a PowerShell spelling (Remove-Item[[:space:]].*-Recurse). A Bash-only matcher can never hand the guard a PowerShell tool call, so that pattern was unreachable and on a host whose primary shell is PowerShell the guard was absent, not merely partial. Now matcher: "Bash|PowerShell". The spelling was verified against the local marketplace rather than assumed: it is what plugins/guardrails/hooks/hooks.json, plugins/disk-hygiene/skills/clean/SKILL.md, scripts/check-hook-exec-form.test.sh and docs/conventions/hook-budget/README.md all use. A PowerShell tool call carries its command in the same .tool_input.command field Bash uses (plugins/guardrails/hooks/block-dangerous-git.sh reads exactly that).

1b. The guard accepts a PowerShell-valid acknowledgement (review follow-up). Registering the guard on the PowerShell tool exposed a gap: the only documented ack, the Bash prefix CLEAN_GUARD_ACK=1 <command>, is not something PowerShell can execute (pwsh -NoProfile -Command 'CLEAN_GUARD_ACK=1 Write-Host hi' exits 1, "term 'CLEAN_GUARD_ACK=1' is not recognized"), and the PowerShell-valid $env:CLEAN_GUARD_ACK=1; Remove-Item -Recurse -Force obj still exited 2 from the guard. So a confirmed Remove-Item -Recurse, git reset --hard, or git stash drop on the PowerShell tool had no ack path short of the kill switch, and the test case "ack prefix works on the PowerShell tool too" asserted a spelling PowerShell cannot run. The guard now reads tool_name and accepts, on the PowerShell tool only, $env:CLEAN_GUARD_ACK=1; <command>: optional leading whitespace, the $env:CLEAN_GUARD_ACK assignment with the literal value 1 (bare or quoted), then the ; that ends the statement, and nothing else before it. The Bash prefix stays Bash-only. Each spelling is a real assignment only in its own shell (Bash treats $env:CLEAN_GUARD_ACK=1; as a failed command lookup and runs the rest anyway), so the guard accepts it only there. The block reason names the spelling for the tool that was blocked. SKILL.md documents both spellings in the guard paragraph and in the stash-drop instruction.

1c. Tool gating hardened to default-deny, and the PowerShell stash selector quoted (second review follow-up, 04bbe657). Two bounded changes, neither of which closes a live bypass.

Tool gating. The ack dispatch was written as "PowerShell gets the $env: spelling, everything else gets the Bash prefix", so tool_name absent, "", null, 123, powershell, Powershell, "PowerShell " and Foo all took the Bash path and the Bash prefix counted as an ack there. Nothing was open: a bare destructive command exited 2 under every one of those, and PowerShell cannot execute the Bash prefix anyway. The problem was future-facing, a third shell tool added later would silently inherit an ack path nobody chose to give it. The dispatch is now default-deny on tool_name: the Bash prefix only when the tool is exactly Bash, the $env: spelling only when it is exactly PowerShell, and any other or missing tool_name gets no ack path at all. Blocking is byte-identical for every tool; the block reason for an ungated tool now says there is no acknowledgement path instead of naming a spelling that tool cannot honour. The test corpus gains 42 gating cases (Foo, powershell, Powershell, "PowerShell ", " Bash", bash, Bash2, string "123", string "null", empty string, JSON null, JSON 123, and an absent tool_name key), each asserting that neither ack spelling unblocks, that a bare destructive command still exits 2, and that a benign command still exits 0. Mutating the dispatch back to the old "not PowerShell" form kills 15 of the new assertions, and leaves the blocking assertions green, which is the correct signature for a grant removal rather than a bypass fix.

Stash selector quoting. The documented PowerShell drop and the matching test payload used a bare git stash drop stash@{0}. Under pwsh that is splatting syntax, git receives a mangled argument and reports unknown switch `e', and the stash count does not change, so the documented example did not do what it said. Verified live: unquoted exits 129 with two stashes still present, git stash drop 'stash@{0}' exits 0 and drops one. The ack itself always propagated correctly; only the selector needed quoting, and only on the PowerShell lane. Fixed in SKILL.md §4.3 and in the test corpus, with the live pwsh run now part of the suite (guarded by command -v pwsh, skipping cleanly without it). The Bash lane needs no quoting and is untouched. context/git-branch-cleanup.md carries no stash example, and a sweep for stash@ across the plugin found no other PowerShell-context occurrence.

Out of scope and unchanged, per the review: the ack regex itself (a 61-payload attack pass found no bypass) and the guard's destructive-command coverage (#3852, which also records the unmatched git stash drop;).

2. The pull-request map was silently truncated, at both sites. git-branch-audit.sh and git-stash-audit.sh each ran gh pr list --state all --limit 200 with errors suppressed on both the lookup and its parsing. That lookup is the documented mitigation for squash merges, which git branch --merged structurally cannot see, so a short or missing map did not soften a verdict, it inverted one: a branch whose work had landed lost the only evidence that it landed and reported as unmerged or needing review. Worse, an unauthenticated or missing gh produced an empty map indistinguishable from a repository with no pull requests.

Both sites now share one clean_pr_map helper in scripts/lib/clean-common.sh that:

  • raises the cap to an explicit high --limit (per the parent's recorded correction, gh pr list has no unlimited sentinel and rejects --limit 0), overridable with CLEAN_PR_LIST_LIMIT;
  • detects truncation by equality, returned count against requested limit, and emits PRDataTruncated:;
  • emits PRCount: <n> for a usable map, so PRCount: 0 is a real answer;
  • emits PRDataUnavailable: <why> when gh or jq is missing, the call fails, or the output is not a PR array, and emits no count in that case, so a failed lookup cannot be read as an empty repository.

Fixing only the branch-audit site (the one the audit reported) would have left half the bug, so the stash-audit site is fixed with it. Section 4.3 of SKILL.md now tells the reader to read the status line first and surface it in the confirmation gate, worded the way section 4.2 already did for the branch audit (review follow-up).

3. An unguarded repository-root resolution aborted the menu path. Step 0 ran a bare REPO_ROOT=$(git rev-parse --show-toplevel) on a path the skill documents as reachable from anywhere; outside a repository that exits 128 and takes the run down with it. Step 0 now resolves no root at all, and the tiers that need one (§4 git, §6 tree) resolve it themselves with 2>/dev/null and stop that tier on an empty result. The bundled scripts already self-resolve and print Error: not a git repository.

4. The frontmatter claimed guard coverage that does not exist. The allowed-tools comment said the six mutating scripts "stay behind the PreToolUse destructive guard and the permission flow". They do not: the guard matches destructive command shapes and matches none of those scripts, nor git branch -D, nor git push --delete. Withholding the grant, so the permission flow and the confirmation gate apply, is the actual mechanism. The claim is corrected, the guard is not extended. The guard's header honestly declares it a best-effort net rather than a security boundary with accepted gaps; that posture is preserved and pointed at #3852, which owns the threat-model decision. The same overstatement in allowed-tools-pairing.test.sh's header comment and in plugin.json's userConfig description is corrected too.

Manifest bumped to 0.10.34 with a matching CHANGELOG entry. Re-verified free at 04bbe657: origin/main is at bf24e0e5 carrying 0.10.33, and no other open pull-request head claims 0.10.34 (all 28 other open heads were checked; the highest is 0.10.33).

Verification

Every acceptance criterion is met. Stated per criterion, with the commands that show it:

Criterion Status Evidence
PowerShell recursive force-removal is inspected and blocked, shown by a test met destructive-guard.test.sh asserts both halves: the registered matcher parsed out of SKILL.md reaches PowerShell, and three Remove-Item -Recurse -Force spellings driven through a tool_name: "PowerShell" payload exit 2. Asserting only the verdict would have passed with the bug fully intact, since the guard script was never the broken part.
A confirmed destructive command on the PowerShell tool has an acknowledgement path met Five $env:CLEAN_GUARD_ACK=1; spellings (bare, "1", '1', spaced) exit 0 on the PowerShell tool. When pwsh is on PATH the test executes the accepted spelling for real and asserts the value reaches the following command (ack=1), and asserts the Bash prefix exits 1 under pwsh.
Each ack spelling is accepted only on its own tool; an unknown tool gets none met 42 gating cases over Foo, powershell, Powershell, "PowerShell ", " Bash", bash, Bash2, "123", "null", the empty string, JSON null, JSON 123, and an absent key: neither ack spelling unblocks, a bare destructive command still exits 2, a benign one still exits 0, and the block reason for an ungated tool contains no ack spelling at all.
The ack widening is tight met A 16-payload bypass corpus on the PowerShell tool stays at exit 2: token in a trailing comment, in a leading comment, inside a double-quoted and a single-quoted string, after the destructive command, inside a later pipeline segment (ForEach-Object { ... }), as the second statement, value 0, '', $null, 10, no ; separator, `
The documented PowerShell stash drop actually drops met Live pwsh against a two-stash fixture, now in the suite: git stash drop stash@{0} exits 129 with unknown switch `e' and both stashes still present; git stash drop 'stash@{0}' exits 0 and one stash is gone.
Existing Bash coverage unchanged met Every pre-existing Bash case in destructive-guard.test.sh is untouched and still passes; 131/131 green.
Neither audit script caps at a plausibly exceeded value; truncation reported met Both call sites go through clean_pr_map. Tests in both audit suites set CLEAN_PR_LIST_LIMIT=1 to reach the boundary without a 100000-row fixture and assert PRDataTruncated:.
A failed lookup is distinguishable from a repo with genuinely no PRs met Both suites assert PRCount: 0 with no PRDataUnavailable: for an empty-array gh, and PRDataUnavailable: with no PRCount: for a gh that exits 1 and for one that exits 0 with non-array output.
The menu path outside a repository does not terminate with an error status met Confirmed empirically in an empty non-repo directory: git rev-parse --show-toplevel exits 128 there, while resolve-clean-action.sh prints Action: menu and exits 0. The aborting call is gone from that path.
Frontmatter no longer claims coverage the guard lacks; posture preserved met allowed-tools-pairing.test.sh gains two assertions: one bans the specific overstatement, one requires any mention of the guard in allowed-tools to state its real scope, so a reworded overstatement cannot slip past.
affected-tests.sh --run selects and passes the mapped suites met, with one pre-existing unrelated failure noted below See the commands below.

Revert-sensitivity checked, not assumed. Each new contract was proven to fail when its fix is undone:

  • reverting matcher to "Bash"FAIL: [23] hook registration reaches the PowerShell tool — expected PowerShell in: - matcher: "Bash";
  • reverting SKILL.md to HEAD → all three new allowed-tools-pairing assertions fail, including unguarded '$(git rev-parse --show-toplevel)' ends the run outside a repo: skills/clean/SKILL.md:122;
  • the PowerShell ack cases were run against four guard mutations on a scratch copy, each caught: the accepted value changed from 1 to 2 (5 failures, the five accepted spellings), the start anchor dropped from the PowerShell regex (5 failures, the comment, quoted-string, and second-statement bypasses go green), the tool check removed so the PowerShell spelling counts on every tool (4 failures), and the guard as committed at 7d382d31 before the first follow-up (7 failures, including the replaced case that used to assert the Bash prefix on PowerShell);
  • mutating the 04bbe657 dispatch back to "PowerShell gets $env:, everything else gets the Bash prefix" → 15/131 checks failed, every one of them a new gating assertion (13 "Bash ack prefix grants nothing" cases plus the two block-reason assertions). The "bare destructive still blocked" and "benign still allowed" cases stay green under that mutation, which is the expected signature: the old dispatch handed out an ack nobody chose, it did not fail open.

Commands run, in the foreground, with actual results:

  • bash scripts/affected-tests.sh --run --shard <i>/4 for legs 0 through 3, run one after another (the unsharded run exceeds the tool ceiling; the legs are the same selection partitioned four ways). Legs 0, 2 and 3 exit 3 (success, with 14 NOT RUN non-shell suites delegated to their own lanes). Leg 1 exits 1 on a single pre-existing failure in a file this PR does not touch: plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh, 2 of 24 process-budget cases, and the same 2 fail identically on a clean git archive origin/main export at bf24e0e5 (measured -1 — the pid-stamped PS4 did not reach the traced shell). destructive-guard.test.sh runs in leg 1 and passes. scripts/check-shell-portability.test.sh and plugins/desktop-notification/hooks/desktop-notification.test.sh, both intermittent pre-existing failures noted in earlier runs, passed in this one.
  • bash plugins/repo-hygiene/skills/clean/scripts/destructive-guard.test.shAll 131 checks passed. (was 76 before this follow-up), including the six live pwsh cases.
  • bash plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.shOK: git-branch-audit.sh tests passed (29 cases).
  • bash plugins/repo-hygiene/skills/clean/scripts/git-stash-audit.test.shOK: git-stash-audit.sh tests passed (19 cases).
  • bash plugins/repo-hygiene/scripts/allowed-tools-pairing.test.shAll allowed-tools pairing checks passed.
  • bash scripts/check-changelog-parity.sh --check, --check-order, --check-bump origin/main, --check-preserved origin/main → all four exit 0 at 04bbe657.
  • bash scripts/check-purged-em-dashes.sh → no em dashes.
  • shellcheck -x and shfmt -d over all changed shell files → clean, exit 0.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob


Generated by Claude Code

@kyle-sexton
kyle-sexton marked this pull request as ready for review September 7, 2026 01:41
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 56s —— View job


Code review complete

  • Gather context (PR body, changed files list, skill instructions)
  • Run skip-gate checks (PR open, ready for review, not trivial — proceeded)
  • Fetch full diff (gh pr diff 3887, origin/main unreachable for git diff in this sandbox — network fetch required approval, so gh pr diff was used instead)
  • Review changed files for correctness/maintainability
  • Post findings

Findings

No blocking correctness or maintainability issues found.

What I checked closely

  • destructive-guard.sh tool dispatch (is_acknowledged, the case "$TOOL" blocks): confirmed the Bash/PowerShell exact-match default-deny dispatch is consistent between the ack check and the block-reason message, and traced the PowerShell ack regex (plugins/repo-hygiene/skills/clean/scripts/destructive-guard.sh#L102-L120) against the bypass payloads in the test file — none of the comment/quoted-string/second-statement/wrong-tool spellings match. The # shellcheck disable=SC2016 placed before the case "$TOOL" in block (not directly above the offending $env: line) is valid ShellCheck usage — a directive before a compound command suppresses the whole block, not just the next physical line — so that isn't a lint gap despite the visual distance.
  • clean_pr_map in clean-common.sh (plugins/repo-hygiene/skills/clean/scripts/lib/clean-common.sh#L811-L847): verified the dynamic jq filter built from ${fields//,/,.} actually expands to a syntactically valid [.field1,.field2,...] construction for both 3- and 4-field callers, and that truncation/unavailable/count status lines are mutually exclusive as documented.
  • Both audit scripts' new PR_MAP_FILE + trap ... EXIT: no pre-existing EXIT trap in either script that this would silently clobber.
  • SKILL.md step-0 root-resolution removal: confirmed resolve-clean-action.sh never consumed the old REPO_ROOT line, so dropping it from step 0 is safe, and confirmed no other doc in the skill assumes step 0 set it.
  • allowed-tools-pairing.test.sh's new frontmatter-overstatement checks: traced the awk/grep extraction against the actual SKILL.md frontmatter text to confirm the new assertions actually exercise the corrected comment (they do — it matches none of these six scripts satisfies the "states real scope" check, and the corrected text no longer contains the banned behind the ... destructive guard phrasing).
  • Cross-referenced the tool_name: "PowerShell" spelling against other guardrails hooks in the marketplace (plugins/guardrails/hooks/block-dangerous-git.sh and others) — consistent.

One minor documentation wrinkle, not worth a blocking finding: SKILL.md §4's note "(§4 and §6 are the tiers that need one)" (permalink) is followed by a REPO_ROOT=$(...) snippet that only appears once, under §4 — §6 delegates to git-tree-reset.sh, which resolves its own root internally, so there's nothing to add there. Not a functional issue since the script self-resolves either way, just a slightly imprecise cross-reference.

The PR's own verification section (revert-sensitivity checks, mutation testing on the guard, 131/131 local test pass) is author-claimed and I did not independently re-run it — noting that per the review contract rather than restating it as confirmed.

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 9s —— View job


Security review complete

  • Load /review:security-review skill instructions
  • Run skip gate (open PR, security-relevant surface: destructive-command guard + PreToolUse hook matcher, no prior applicable review)
  • Fetch PR diff (gh pr diff) and read changed files
  • Hunt for logic / trust-boundary / injection / credential-handling issues in the diff
  • Adversarially verify candidate findings
  • Post findings or report clean

No security issues found.

This PR is net security-positive: it closes a guard-registration gap (the PreToolUse matcher was Bash-only while the guard's own patterns already covered PowerShell, so PowerShell hosts had no guard at all — SKILL.md#L176), tightens the acknowledgement dispatch in destructive-guard.sh to default-deny by tool_name rather than "anything that isn't PowerShell" (destructive-guard.sh#L102-L120), and replaces silent PR-lookup truncation/failure with explicit PRCount: / PRDataTruncated: / PRDataUnavailable: status lines so a failed gh pr list can no longer be misread as "no PRs, safe to delete" (clean-common.sh#L811-L847).

Specifically checked and found sound:

  • Ack regex bypass surface (destructive-guard.sh's is_acknowledged): the PowerShell $env: regex is anchored at the start, requires the literal value 1/'1'/"1" immediately followed by ;, and the tool dispatch (Bash → prefix spelling, PowerShell$env: spelling, anything else → no ack path) is a proper allowlist, not a denylist. The PR's own 16-payload bypass corpus (comment, quoted string, post-command, pipeline segment, falsy values, wrong tool) and 42-case tool-gating corpus exercise exactly the cases that would matter here; nothing in the diff contradicts that coverage.
  • Injection surface in clean_pr_map (clean-common.sh): $fields is always a call-site literal ('headRefName,state,number,headRefOid' / 'headRefName,state,number'), never attacker- or repo-controlled, so the jq-filter string built from it (".[] | [.${fields//,/,.}] | @tsv") has no injection path. $limit (from CLEAN_PR_LIST_LIMIT) is passed quoted to gh pr list --limit, so no command injection; a non-numeric override just degrades to a harmless gh error caught by the existing failure path.
  • Predictable temp-file fallback (PR_MAP_FILE="${TMPDIR:-/tmp}/clean-pr-map.$$" when mktemp fails): this is a pre-existing pattern already used elsewhere in this same plugin (clean-batch.sh, clean-common.sh's manifest path), not something this PR introduces — out of scope as a pre-existing issue per the review's high-signal bar.
  • Shell-quoting changes (PowerShell stash selector, SKILL.md §4.3): correctness fix for git receiving a mangled argument, not a security boundary; no new injection surface.

No CRITICAL/IMPORTANT/SUGGESTION findings to report.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa1b6d8881

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/repo-hygiene/skills/clean/SKILL.md Outdated
Comment thread plugins/repo-hygiene/skills/clean/scripts/lib/clean-common.sh Outdated
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: aa1b6d888133d0e4979f538eb43e69cd9eb0acb9. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

claude and others added 5 commits September 7, 2026 09:48
The `clean` skill's destructive guard registered against `Bash` alone while its
own patterns carried a PowerShell spelling (recursive `Remove-Item`), so that
pattern was unreachable and on a PowerShell host the guard was absent rather
than partial. Register `Bash|PowerShell`, the spelling every other guard in this
marketplace uses; a PowerShell tool call carries its command in the same
`.tool_input.command` field, so the guard script itself needed no change.

Both audit scripts capped the pull-request lookup at 200 and suppressed its
errors. That lookup is the only mechanism that detects a squash merge, so a
short or missing map did not soften a verdict, it inverted one: a branch whose
work had landed lost its evidence and reported as unmerged. `git-branch-audit.sh`
and `git-stash-audit.sh` now share one `clean_pr_map` helper that raises the cap,
detects truncation by equality against the requested limit (`gh pr list` has no
unlimited sentinel and rejects `--limit 0`), and reports `PRCount:`,
`PRDataTruncated:`, or `PRDataUnavailable:`. Both sites are fixed; fixing one
would have left half the bug.

Step 0 resolved a repository root with a bare `git rev-parse --show-toplevel` on
a path documented as reachable from anywhere, ending the run outside a
repository. The tiers that need a root now resolve it themselves, guarded.

The `allowed-tools` frontmatter claimed the six mutating scripts stay behind the
destructive guard. They do not: the guard matches command shapes and matches
none of them, nor `git branch -D`, nor `git push --delete`. The claim is
corrected rather than the guard extended; its best-effort posture is deliberate
and whether coverage should grow is a separate human-gated decision (#3852).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…uard

Registering the guard on the PowerShell tool left it with no
acknowledgement path there: the only documented ack, the Bash prefix
`CLEAN_GUARD_ACK=1 <command>`, is not something PowerShell can execute,
so a confirmed Remove-Item -Recurse, git reset --hard, or git stash drop
was blocked with no way through short of the kill switch. The guard now
accepts `$env:CLEAN_GUARD_ACK=1; <command>` on the PowerShell tool.

The ack is a deny-to-allow widening, so each spelling counts only on its
own tool and only as the leading statement with the literal value 1.
The test asserts the accepted spelling against a live pwsh when one is
on PATH, and a bypass corpus (comment, quoted string, after the command,
later pipeline segment, falsey or other value, wrong tool) stays blocked.

Section 4.3 of SKILL.md now tells the reader to act on the stash audit's
PR-map status line the way section 4.2 does for the branch audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…sh ref

The destructive guard's ack dispatch treated every tool_name other than
PowerShell as Bash, so a third shell tool added later would have inherited the
Bash prefix `CLEAN_GUARD_ACK=1 <cmd>` as an unblock nobody granted it. Nothing
was open today: a bare destructive command already exited 2 under every
tool_name, and pwsh cannot execute the Bash prefix at all. The dispatch is now
default-deny on tool_name, Bash gets the prefix spelling, PowerShell gets the
$env: spelling, anything else and a missing tool_name get no ack path, and the
block reason for those says so instead of naming a spelling the tool cannot
run. Blocking behavior is byte-identical for every tool.

The documented PowerShell stash drop used a bare `stash@{0}`, which pwsh reads
as splatting syntax: git reported "unknown switch `e'" and the stash count did
not change. Single-quoted it drops correctly. Fixed in SKILL.md and in the test
corpus; the Bash lane needs no quoting and is untouched.

Tests extended with 42 tool-gating cases (Foo, powershell, Powershell,
"PowerShell ", " Bash", bash, Bash2, "123", "null", empty string, JSON null,
JSON 123, and an absent key) asserting no ack path and unchanged blocking, plus
live pwsh execution of the quoted and unquoted stash selectors against a real
stash fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
House style bans em dashes in prose written here. Three helper-signature
comments added by the previous commit used them; reworded, no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…p-file create

Remove #3852 from the clean skill body (tracking stays in the changelog) and
treat failure to create or write the PR map file as PRDataUnavailable so an
invalid TMPDIR cannot report a complete map with no rows.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@cursor
cursor Bot force-pushed the claude/3850-repo-hygiene branch from aa1b6d8 to eb9b992 Compare September 7, 2026 10:00
cursoragent and others added 2 commits September 7, 2026 10:18
The lint lane rejects `unparseable` in the branch-audit tests and the
git-branch-cleanup output-contract line.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
The manifest description already names Bash and PowerShell; regenerate the
README options block so the plugin-options docs check matches.

Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@kyle-sexton
kyle-sexton merged commit 2a65691 into main Sep 7, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude/3850-repo-hygiene branch September 7, 2026 10:39
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…y deletion batch (#3900)

Closes #3853

## Summary

Makes recovery from a wrong branch deletion mechanical.
`git-branch-audit.sh` now reports every branch's tip commit as a
structured `Tip:` field and writes all tips to a durable capture file; a
new `git-branch-delete.sh` becomes the git tier's only sanctioned
deletion path and refuses to delete anything unless that capture exists,
covers every branch in the batch, and still matches each branch's live
tip. The value is in the ordering: the tip is recorded, pinned, and
ledgered before the destructive step, the destructive step itself is an
atomic compare-and-delete on the recorded tip, and the test restores
deleted branches from the capture alone after a `gc --prune=now`.

## Fix

**Where the capture lives and why it survives.**
`<git-common-dir>/repo-hygiene/branch-tips/<utc-stamp>-<pid>.tsv`, i.e.
the main checkout's `.git/`, even when the audit ran in a linked
worktree (`--capture-file PATH` overrides). `.git/` is untouched by
`tree` resets and by linked-worktree removal, and is findable by someone
who knows only the repository. The path convention is documented in
`context/git-branch-cleanup.md` (§4.2 and the new §4.8) so recovery
needs no memory of how the run was invoked.

**What is captured.** Per branch: name, tip SHA, tier, PR state,
upstream, ahead, behind, commits not on `origin/<default>`, and a UTC
timestamp; header lines name the repo, its common dir, the default
branch, and the restore command. Rows are written for every branch
regardless of verdict, since a verdict can be wrong in either direction.
Rows are recognised by shape on both sides (nine tab-separated columns,
a commit id in the second, the run's stamp in the last), never by a
leading `#`: `#` is a legal first character of a branch name, and a
comment heuristic made such a branch count one row short at the seal,
failing the whole audit with `TipCaptureError: short write` and leaving
no branch in that repository deletable.

**Enforcement, and what happens when capture fails (fail closed).**
- Audit side: the capture is written to a `.part` file, created
exclusively (`noclobber`) so a `<stamp>-<pid>` collision across PID
namespaces sharing a mount, or an interrupted run, is refused rather
than interleaved into one file (the foreign `.part` is left alone). It
is renamed into place only after a shape-based row count on disk equals
the rows produced. Any failure (unresolvable common dir, unwritable
directory, existing `.part`, short write, failed rename) prints
`TipCaptureError: <why>` and no `TipCapture:` path. A branch whose tip
cannot be resolved gets `Tip: unresolved` and no row.
- Delete side: `git-branch-delete.sh --capture PATH BRANCH...` runs a
batch-wide precondition before the first deletion: capture present,
readable, correct header, taken in this repository (compared by common
dir), every branch has a row, every captured tip equals the live tip, a
SAFE-by-ancestry tip is actually merged into `origin/<default>` (the
check `git branch -d` used to make, now made before anything is
written), and no branch is
current/default/protected-pattern/worktree-checked-out; REVIEW needs
`--force-review`, PROTECTED and WORKTREE are never deletable. Any
refusal prints `Refused: <branch> (<what is missing and how to produce
it>)`, `Summary: planned=0 refused=N deleted=0`, exit 3, and nothing is
deleted, including the branches that would have passed.

**Per-branch vs per-batch, and partial failure.** Preconditions are per
batch (all or nothing). In `--apply`, each branch is then processed in
order: re-verify the live tip, `git update-ref
refs/repo-hygiene/deleted/<branch> <tip>` (the pin: `git-prune.sh
--apply` runs `gc` right after deletion and `gc` prunes unreachable
objects past `gc.pruneExpire`, so a SHA in a file alone is not a
recoverable capture), append to the ledger (`<capture>.deleted.tsv`,
beside the capture's real file with symlinks resolved), and only then
delete with `git update-ref -d refs/heads/<branch> <tip>`. That delete
is a compare-and-delete inside git's ref lock: it refuses (`cannot lock
ref ... is at X but expected Y`) when the tip is no longer the captured
one. The per-branch re-check closes the window between the batch check
and the pin; the conditional delete closes the window between the pin
and the delete, which a plain `git branch -D` left open (a tip moved
there was deleted and its commit lost to the next prune). A failure in
any step before the delete prints `Aborted: <branch> (<why>)` and stops:
branches already deleted keep their pin and ledger row, the failing
branch and everything after it are untouched, `Summary:` counts
`deleted/failed/aborted/untouched`, exit 1. A delete refused because the
tip moved also aborts: the branch stays at its new tip, its pin stays
(harmless, and it records the tip the audit saw), and the ledger gains a
`# not deleted:` note. Safe delete for SAFE by ancestry; force delete
for SAFE with a merged PR (squash), LIKELY-SAFE, and forced REVIEW; the
delete mechanism is the same atomic form in every mode. Every `Deleted:`
line and the closing `Restore:` line carry `git branch <branch> <tip>`.

**Ledger path choice.** The ledger follows the capture's real location
rather than refusing a capture outside the sink, because
`--capture-file` to a writable location is the documented fallback when
the sink itself is unwritable; refusing it would remove the only
recovery path from a `TipCaptureError`. The pin, not the ledger, is what
protects the tip.

**Docs.** SKILL.md §4.2 states the precondition;
`context/git-branch-cleanup.md` §4.7 routes deletion through the script,
documents the atomic delete, the ledger and the pins, and adds §4.8
(restore steps); `context/action-router.md` names the script in the
`git` row. The delete script is deliberately not in `allowed-tools`
(mutating; the pairing test's allowlist is unchanged and passes). The
session guard does not match `git branch -D` (that is #3852), so "only
through the script" is enforced by the script's preconditions plus the
skill text, not by a hook.

**Interaction with sibling #3854 (not implemented here).** A
lossy-but-deletable verdict block would sit at the confirmation gate,
immediately before `git-branch-delete.sh --apply`; the delete script's
tier gate (`SAFE`/`LIKELY-SAFE` free, `REVIEW` behind `--force-review`)
is where a new tier would need to be admitted. The two changes touch
adjacent but distinct places.

**Version.** `0.10.35`. Main is `0.10.33`; open PR #3887
(`claude/3850-repo-hygiene`) takes `0.10.34`. Re-checked against current
main and every open head (61 refs) immediately before the latest push:
only this branch carries `0.10.35`. `git merge-tree` against #3887 shows
textual conflicts in four files: `CHANGELOG.md` (adjacent new entries),
`plugin.json` (version line), `git-branch-audit.sh` (header comment
block, and the setup block after `CURRENT_BRANCH` where #3887 adds its
`clean_pr_map` block and this PR adds the capture setup), and
`git-branch-cleanup.md` (the output-contract line). All are additive on
both sides. Merge against current main is clean.

## Verification

Ran in this container (Linux, git 2.43, ShellCheck 0.11.0, shfmt
3.12.0):

- `git-branch-delete.test.sh` (new, 106 cases) and
`git-branch-audit.test.sh` (extended, +25 cases): pass. The delete suite
demonstrates (a) a dry-run plans and deletes nothing and an apply
deletes four branches, one named `#42-hash`, with ledger rows and pins;
(b) no `--capture`, unreadable capture, bad header, foreign-repo
capture, missing row, moved tip, forged SAFE row on an unmerged branch,
mixed batch with one REVIEW branch, protected/current/worktree: each
exits 3 with every branch still present, including the deletable
siblings; backup-ref failure mid-batch aborts with the blocked branch
and the following one untouched; an unwritable ledger aborts before
anything is deleted; a symlinked capture puts the ledger beside the real
file; (c) after `gc --prune=now`, each deleted branch is recreated with
`git branch <name> <tip>` using only the name-to-tip lookup in the
capture file, and its content is intact; a control shows the same prune
destroys an unpinned tip; (d) both tip-move windows, opened
deterministically: a FIFO at the ledger path parks the script, with no
reader present, at the ledger append that sits after the pin and before
the delete, and the pin appearing is the signal to move the tip. Window
B moves the parked branch's own tip (between pin and delete): the delete
refuses, the branch stays at the moved tip, the pin records the captured
tip, the ledger carries the `# not deleted:` note, and the moved commit
survives `reflog expire` plus `gc --prune=now`. Window A parks on a
first branch and moves the second branch's tip (after the batch check,
before its re-check): the first branch is deleted, the second aborts at
the re-check with no pin and no ledger row.
- Mutation battery on a scratch copy (each mutation applied alone,
suites re-run): remove the apply-time re-check: 3 failures, all window A
(stale pin written, ledger row written, wrong abort step); revert the
delete to `git branch -D`: 7 failures, all window B, including the
branch gone and the moved commit pruned, which reproduces the loss the
review demonstrated; restore the `grep -c -v '^#'` seal and the
comment-skipping parser: every capture case in both suites fails with
`TipCaptureError`, which is the bricking the review reported. Earlier
battery on the same tree (drop tip-moved refusal 6, drop the pin 9, drop
missing-row refusal 1, skip refused branches 17, ignore ledger write
failure 1, audit stops printing `Tip:` 3, audit hides `TipCaptureError:`
2, audit writes no rows 9) unchanged. No surviving mutation.
- `scripts/affected-tests.sh --run` in four shards, foreground: shards
0/4, 2/4, 3/4 exit 3 (all shell suites passed; the NOT RUN entries are
other ecosystems). Shard 1/4 exit 1 solely from
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
(2/24 process-budget cases), reproduced identically on a clean `git
archive origin/main` export, so pre-existing. The audit suite ran in
shard 1, the delete suite in shard 2, `allowed-tools-pairing.test.sh` in
shard 1; all passed.
- `shellcheck -x` and `shfmt -d` clean on the four changed shell files.
`check-purged-em-dashes.sh --check` clean; no em dashes in added lines
(byte check on the diff).
- `check-changelog-parity.sh`: `--check`, `--check-order`, `--check-bump
origin/main`, `--check-preserved origin/main` all exit 0.
- Exec bit: `git-branch-delete.test.sh` was committed `100644` and
failed the CI `lint` job's exec-bit gate on `0e701107a`; it is `100755`
from `f6f87d91e` on (`git ls-tree` confirmed). CI on the current head
(`ae18b8433`): `lint`, `test-linux (0..3)`, `hook-utils`, `changes`,
`managed-files-guard`, GitGuardian and `ci-status` all success;
`test-windows` and the review lanes skipped (draft).

## Related

- Refs #3346 (parent, gap G1 first half)
- Refs #3854 (sibling: lossy-but-deletable verdict; not implemented
here, interaction noted above)
- Refs #3852 (guard coverage for `git branch -D`; not implemented here)
- #3887 (open, same plugin, takes `0.10.34`; expect the textual
conflicts listed above)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…ing them when no telemetry sink is set (#3913)

Closes #3862

## Summary

Guard decisions left the process only through `HOOK_TELEMETRY_SINK`,
which is inert unless an environment variable names an executable. On an
ordinary install every decision was discarded as it was made, so "why
was this denied", "has it denied this all along", and "did the guard run
at all" had no evidence to answer from.

New `plugins/disk-hygiene/lib/guard_decision_log.py` appends one JSON
object per line to
`<CLAUDE_PLUGIN_DATA>/guard-decisions/decisions.jsonl`, default on, no
configuration. `destructive_guard.py` records every branch that reaches
a verdict; `guard_launch_monitor.py` records the did-not-run state the
guard structurally cannot write about itself.

Version taken: **0.23.0**. Verified free against current `main`
(`6db96637d`, 0.21.9) and against the head of every open PR at the
moment of opening: #3880 claims 0.21.10, #3783 claims 0.22.0, and
#3851/#3887/#3900/#3779 leave the manifest at 0.21.9. 0.23.0 is strictly
greater than all of them, so it cannot collide under any merge order;
the 0.22.x gap is what `--check-order` explicitly reads as correctly
ordered.

## Fix

**Where the record lives, and why it survives.** Under the plugin's own
persistent data root, resolved by the guard's existing
`resolve_authorized_data_root()` (the `--authorized-data-root` /
`--plugin-root` / `CLAUDE_PLUGIN_DATA` ladder) beside the run records
already kept there. Because this plugin is the one that deletes things,
the filename was checked against the engine's own discovery hints in
`reference/baseline-policy.json`: `decisions.jsonl` and
`decisions.previous.jsonl` match none of the 14 bundled name globs
(`*.tmp`, `tmp-*`, `tmp_*`, `scratch*`, `*.lock`, `__pycache__`,
`*.partial`, `*.crdownload`, `*.tmp.*`, `.claude.json.tmp.*`,
`temp_git_*`, `.pulumi-write-test-*`, `.DS_Store`, `Thumbs.db`), so the
plugin's own hints never nominate its audit trail. Appending directly
rather than writing a temp file and renaming is deliberate for the same
reason: an atomic-write staging name would land on `*.tmp.*`, which is a
bundled hint.

**What is recorded.** `schema_version`, `timestamp` (UTC, milliseconds),
`hook`, `decision`, `rule`, `tool`, `mode`, `command`, `reason`.
`decision` is `allow` / `ask` / `deny` / `none` (ran, issued no
`permissionDecision`) / `not-run`. `rule` names the branch that fired,
so `kill-switch-disabled-apply` is distinguishable from
`not-exact-engine-command`: two different answers to "why". `command` is
the input that drove it and `reason` is the exact text the host was
given, so the record and the host cannot disagree. Both are clipped to
400 characters, which keeps it a record of the decision rather than a
copy of the payload and keeps every line short enough that concurrent
hook processes appending to the same file do not interleave.

**Bounded, enforced.** The live file rotates to
`decisions.previous.jsonl` at 1 MiB via `os.replace`, so the record
occupies at most about 2 MiB forever with no operator pruning. The bound
is checked from the offset the append already returns (`handle.tell()`
in append mode), so enforcing it costs no extra syscall.

**Cost.** Measured with `strace -f -e
trace=clone,clone3,fork,vfork,execve,openat,write` against a detached
worktree of `origin/main` at `6db96637d`, five invocations per arm plus
a warm-path detail run:

| Path | Before | After |
| --- | --- | --- |
| defer (a Bash command not naming the engine, the always-on branch) | 1
`execve`, 1 `clone3` | 1 `execve`, 1 `clone3`, 0 record syscalls |
| decision (deny), warm data root | 1 `execve`, 1 `clone3` | 1 `execve`,
1 `clone3`, 1 `openat` + 1 `write` |
| decision (deny), first write of an install | 1 `execve`, 1 `clone3` |
plus 1 failed `openat` and 1 `mkdir` |

The single `clone3` is `CLONE_THREAD`, the existing watchdog thread, not
a process. **The process and exec census is unchanged on every path.**
The plugin-level defer branch, which is what this always-on hook takes
for work unrelated to disk-hygiene, writes nothing at all and is
byte-for-byte the path it was. Wall clock over 40 invocations per arm,
alternated twice, moved inside run-to-run noise on this host (52 to 58
ms both before and after, the sign of the difference changing between
repetitions), which is why the syscall census rather than a duration is
the figure cited.

**Failure behavior: the verdict never changes.** Two boundaries, both
load-bearing. `guard_decision_log.record` returns a bool and catches
`BaseException` around the whole write. `_record_decision` in the guard
wraps its own call, because the data root and mode are resolved in the
argument list, outside `record`'s protection, and one of its call sites
is `main`'s own `except BaseException` handler, where a raise would
reach the interpreter's default handler: exit 1, which PreToolUse treats
as non-blocking, so the command the guard just denied would run. Every
record call is made after the verdict has been emitted, and its result
is discarded.

**What is deliberately not recorded.** The plugin-level defer (hot path,
and not a decision anyone reconstructs later). The watchdog expiry path:
that callback runs while the main thread is presumed wedged inside a
filesystem call and stays syscall-free for exactly that reason, so a
write there could hang on the same filesystem. Both are stated in the
README rather than left implicit.

**Adjacency, stayed out of.** #3861 (the guard's fail-open when no
interpreter resolves) is `needs-human`. This change touches the guard's
decision branches but not interpreter resolution, and adds no new
fail-open path; the `not-run` record makes the fail-open class more
visible after the fact without adjudicating it.

**Escape hatch.** `DISK_HYGIENE_GUARD_DECISION_LOG` set to `0` / `off` /
`false` / `no` turns the record off. Opt-out, not opt-in: any other
value, including an absent one, records.

## Verification

All runs local and in the foreground; draft CI is not cited as test
evidence.

- `bash scripts/affected-tests.sh --run --shard N/4`: shards 0, 1, 2
exit **3** (success, with `NOT RUN` non-shell ecosystems), 0 `FAIL`
lines each. Shard 3 exits 1 with exactly three `FAIL` lines, all from
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
(`process budget: the trace probe actually counted something`, `process
budget: a one-install report costs at most 26 process creations`).
**Reproduced unchanged on a clean detached worktree of `origin/main` at
`6db96637d`**: same suite, same 2 cases, exit 1. Pre-existing, not this
change. Every changed file maps to at least one suite; the three
docs/manifest files resolve through the recorded no-suite allowlist.
- Direct suites: `hygiene.test.sh` RC=0 (350 cases),
`guard_launch_monitor.test.sh` RC=0 (28 cases),
`run-python-hook.test.sh` RC=0, `test_guard_decision_log.py` +
`test_hook_telemetry.py` RC=0 (15 new cases).
- All four parity modes green: `--check`, `--check-order`, `--check-bump
origin/main`, `--check-preserved origin/main`.
- `scripts/run-ruff.sh check plugins/disk-hygiene`: all checks passed.
`format --check`: my four touched/new Python files are clean. Five files
remain unformatted in this plugin (`killswitch_config.py`, `hygiene.py`,
`guard_launch_monitor.py:137`, `test_hygiene.py:2373`,
`test_kill_switch_probe.py:96`); all five are identically unformatted on
`origin/main`, so none is introduced here.
- Gates run clean: `check-purged-em-dashes.sh`,
`check-drive-root-litter.sh`, `check-silent-skips.sh`,
`check-discriminating-test-skips.sh`, `check-fixture-git-isolation.sh`,
`check-hook-exec-form.sh`, `check-killswitch-hoist.sh`, all RC=0. New
test file committed `100755` (verified with `git ls-tree`), the new
library `100644` matching its sibling `hook_telemetry.py`.
- No shell files changed, so shellcheck and shfmt have nothing to say
about this diff. `lib/hook-utils.sh` untouched.

**Mutation proof (the new assertions discriminate).** Nine mutations
applied one group at a time, each reverted:

| Mutation | Caught by |
| --- | --- |
| rotation call disabled | 3 lib cases (`rotates_at_the_bound`,
`discards_only_the_generation_before_last`, `a_failing_rotation...`) |
| `_clip` returns text unchanged |
`long_command_and_reason_are_truncated` |
| `enabled()` hardcoded True | 5 lib subtests +
`the_record_can_be_turned_off_without_changing_a_verdict` |
| deny rule string collapsed onto the kill-switch rule |
`denied_engine_command_is_recorded_with_its_rule_and_input` |
| a record added on the defer path | `engine_gate_defer_records_nothing`
|
| `_record_decision`'s try/except removed |
`a_broken_decision_record_never_changes_a_verdict` (record raises),
`record_decision_swallows_a_failure_in_data_root_resolution`, and the
**pre-existing**
`every_call_graph_function_failure_denies_at_exit_2_never_1` for both
`resolve_mode` and `resolve_authorized_data_root` |
| `_record_not_run` call removed from the monitor | 3 monitor cases |

The write-failure proof is
`test_a_broken_decision_record_never_changes_a_verdict`, which drives
all five verdict shapes (`allow`, `ask`, `deny`-by-authority,
`deny`-by-kill-switch, and the no-output defer) three times:
unsabotaged, against a data root whose parent is a regular file (a real
filesystem `OSError` on both the append and the `mkdir` behind it), and
with `record` raising `RuntimeError`. All three runs produce the
identical verdict list, and the unwritable root is asserted to still not
exist afterwards.
`test_a_write_failure_leaves_the_deny_exit_status_untouched` pins the
same thing end to end through `main`.

**Hermeticity fix included.** `run_guard_engine_gate` previously passed
`SCRIPT_DIR / "data-root"` as the authorized data root. With records
being written, that would have littered the checkout on every test run,
so it now takes a per-test temp path, and `GuardTests.setUp` pops an
inherited `CLAUDE_PLUGIN_DATA` so a developer's real plugin data
directory is never written to by the suite.

## Related

- Closes #3862; parent #3347 finding F12.
- Sibling #3861 (guard fail-open when no interpreter resolves) is
`needs-human` and deliberately untouched; the `not-run` record is what
makes that class visible after the fact.
- Sibling finding on a false denial: `rule` plus `command` plus `reason`
is what answers it from the record instead of by reproduction.
- Hook budget convention: `docs/conventions/hook-budget/README.md`,
`.claude/rules/hook-budget.md`. Measured share stated in the plugin
README's trust-surface record.
- Version contention checked against open PRs #3783 (0.22.0) and #3880
(0.21.10).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

repo-hygiene:clean: close the three HIGH audit findings (PowerShell matcher, PR-list truncation, unguarded repo-root, overstated guard claim)

3 participants