Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
bump: patch
---

### Fixed

- The clean skill's destructive guard now gates only while a mutating clean run is open, tracked by a per-session run marker that `scan` and `scan-batch` never write, so it no longer blocks unrelated commands for the rest of the session.
- On the Bash tool the guard matches each command's parsed words instead of the raw text, so destructive text inside a quoted argument (an issue comment body, a commit message) no longer blocks the command.
- The run marker's idle timeout counts from the last run of one of the clean skill's mutating scripts (dry-run included) or acknowledged or destructive command, so ordinary shell calls, including ones that only name a script, no longer keep a marker alive after a run that skipped its close step, while a long dry-run or confirmation phase still reaches its apply gated.
9 changes: 6 additions & 3 deletions plugins/repo-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,18 @@ ghq list -p | bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/git-branch-audit.s
- **Any git-tracked file is off-limits** to selective deletion; a tracked file
deleted by reparse-point (junction/symlink) traversal during a `tree` clean is
auto-restored from the index.
- **Session-scoped destructive guard.** While the skill is active, a PreToolUse
hook blocks bare `rm -rf`, `git clean -f*`, `git reset --hard`,
- **Session-scoped destructive guard.** While a mutating clean run is open (a
per-session run marker under the plugin data directory, written by the
mutating tiers and never by `scan` or `scan-batch`, cleared when the run ends,
with an idle timeout as a fallback), a PreToolUse hook blocks bare `rm -rf`, `git clean -f*`, `git reset --hard`,
`git checkout --`, recursive `Remove-Item`, bare `git branch -D`/`-d`/`--delete`,
`git push --delete` and `git push origin :ref` (also `+:ref`), the clean scripts when the
command contains `--apply`, and `git worktree remove` with a force flag. A
dry-run (including a dry-run push) is not blocked. The confirmed command runs
only through the skill's own gate, and the ack-prefixed spelling is the
documented way to run a bare `git branch -D` during a clean session. The guard
is a best-effort net over command text, not a security boundary. The skill deletes local
is a best-effort net over command words, not a security boundary; on the Bash tool destructive
text inside one quoted argument does not match. The skill deletes local
branches with `git-branch-delete.sh` after the confirmation gate, which uses
`git update-ref -d` and so does not go through the branch patterns. Kill switch: the `clean_destructive_guard_enabled`
userConfig option set to `false` (`/plugin configure repo-hygiene@<marketplace>`, or
Expand Down
14 changes: 10 additions & 4 deletions plugins/repo-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ allowed-tools:
# matches these scripts when the command text contains `--apply`, plus
# `git worktree remove` with a force flag. A dry-run of the same scripts, or
# of a push, does not match. It does not match obfuscated spellings (eval,
# `bash -c`, aliases). The guard is a best-effort net over command text, not
# aliases) or a `bash -c` behind any wrapper (sudo, timeout, env, nohup, ssh,
# docker exec). The guard is a best-effort net over command words, not
# a security boundary.
# git-branch-audit.sh writes one TipCapture file under the git common dir
# (.git/repo-hygiene/branch-tips/) per run, and its landed-proof squash step
Expand All @@ -40,7 +41,9 @@ hooks:
# Exec form. `command` is `node`. exec-bash.mjs finds Git Bash and
# never System32\bash.exe, then runs destructive-guard.sh. `args`
# names only ${CLAUDE_PLUGIN_ROOT}, by design; whether a
# skill-frontmatter hook receives ${CLAUDE_PLUGIN_DATA} is untested.
# skill-frontmatter hook receives ${CLAUDE_PLUGIN_DATA} is untested. The
# guard reads it from its environment to find the run marker and stays
# armed when it is empty.
# Pointer: https://code.claude.com/docs/en/hooks#exec-form-and-shell-form
# and https://code.claude.com/docs/en/hooks#reference-scripts-by-path.
# As of: 2026-10-11.
Expand Down Expand Up @@ -137,7 +140,7 @@ Protected-path enforcement gates `scan`, `caches`, `build`, `git`, AND `tree` (`

`tree` requires explicit confirmation and is never auto-invoked. Any file tracked by git is reset via `git reset --hard`, not selective deletion, and any tracked file deleted by reparse-point traversal (junction/symlink into a tracked dir) is auto-restored.

**Session-scoped destructive guard (frontmatter hook).** While this skill is active, a PreToolUse hook (`scripts/destructive-guard.sh`) inspects Bash **and** PowerShell tool calls and blocks destructive command shapes (`rm -rf`, `git clean -f*`, `git reset --hard`, `git checkout --`, `git stash drop`/`clear`, recursive `Remove-Item`, bare `git branch -D`/`-d`/`--delete`, `git push --delete`, `git push origin :ref` (also `+:ref`)). It also blocks this skill's mutating scripts (`clean-caches.sh`, `clean-build.sh`, `git-prune.sh`, `git-tree-reset.sh`, `git-tree-reset-batch.sh`, `remove-path.sh`, `clean-batch.sh`) when the command contains `--apply`, and `git worktree remove` with a force flag. A dry-run of those scripts, or of a push, is not blocked. It is a best-effort net over command text, not a security boundary: obfuscated spellings (`eval`, `bash -c`, aliases) get past it. After the [confirmation gate](#confirmation-gate) passes, re-issue the confirmed command with the acknowledgement prefix for the tool you are using: `CLEAN_GUARD_ACK=1 <command>` on the Bash tool, `$env:CLEAN_GUARD_ACK=1; <command>` on the PowerShell tool. Each spelling is a real assignment only in its own shell, so the guard accepts it only there, and only as the first statement of the command (not in a comment, a string, or after the destructive command). The gating is per tool and default-deny: any other tool name, including a missing one, gets no acknowledgement path at all, so the block stands and no prefix lifts it. Never add the prefix without the user's explicit confirmation in this session. Kill switch: the `clean_destructive_guard_enabled` userConfig option set to `false` (`/plugin configure repo-hygiene@<marketplace>`). The prefix satisfies only this guard; Claude Code's permission rules and auto-mode classifier still apply ([the host permission layer above the ack](reference/invocation-forms.md#the-host-permission-layer-above-the-ack)).
**Session-scoped destructive guard (frontmatter hook).** A per-session run marker scopes the guard to the run, whatever the host does with the hook registration (lifetime: [hooks in skills and agents](https://code.claude.com/docs/en/hooks#hooks-in-skills-and-agents), as of 2026-10-20, recheck when the page changes how skill hooks end): [run marker](context/run-marker.md) is opened when a mutating tier starts (never for `scan` or `scan-batch`) and cleared when the run reaches its final outcome, and with no live marker the guard allows every command. While a run is open, a PreToolUse hook (`scripts/destructive-guard.sh`) inspects Bash **and** PowerShell tool calls and blocks destructive command shapes (`rm -rf`, `git clean -f*`, `git reset --hard`, `git checkout --`, `git stash drop`/`clear`, recursive `Remove-Item`, bare `git branch -D`/`-d`/`--delete`, `git push --delete`, `git push origin :ref` (also `+:ref`)). It also blocks this skill's mutating scripts (`clean-caches.sh`, `clean-build.sh`, `git-prune.sh`, `git-tree-reset.sh`, `git-tree-reset-batch.sh`, `remove-path.sh`, `clean-batch.sh`) when the command contains `--apply`, and `git worktree remove` with a force flag. A dry-run of those scripts, or of a push, is not blocked. On the Bash tool it matches the parsed words of each command, so destructive text inside one quoted argument (an issue comment body, a commit message) does not match; the PowerShell tool keeps raw-text matching. It is a best-effort net over command words, not a security boundary: obfuscated spellings (`eval`, aliases) and a `bash -c` behind any wrapper (`sudo`, `timeout`, `env`, `nohup`, `ssh`, `docker exec`) get past it. After the [confirmation gate](#confirmation-gate) passes, re-issue the confirmed command with the acknowledgement prefix for the tool you are using: `CLEAN_GUARD_ACK=1 <command>` on the Bash tool, `$env:CLEAN_GUARD_ACK=1; <command>` on the PowerShell tool. Each spelling is a real assignment only in its own shell, so the guard accepts it only there, and only as the first statement of the command (not in a comment, a string, or after the destructive command). The gating is per tool and default-deny: any other tool name, including a missing one, gets no acknowledgement path at all, so the block stands and no prefix lifts it. Never add the prefix without the user's explicit confirmation in this session. Kill switch: the `clean_destructive_guard_enabled` userConfig option set to `false` (`/plugin configure repo-hygiene@<marketplace>`). The prefix satisfies only this guard; Claude Code's permission rules and auto-mode classifier still apply ([the host permission layer above the ack](reference/invocation-forms.md#the-host-permission-layer-above-the-ack)).

**Guard coverage for branch deletion.** The guard matches bare `git branch -D`/`-d`/`--delete`, `git push --delete`, `git push -d` and `git push origin :ref` (also `+:ref`); a push with `--dry-run`/`-n` is allowed. The ack prefix lifts these blocks like the others and is the documented way to run a bare `git branch -D` during a clean session; a spelling the patterns do not parse (a global option with a separate value such as `--work-tree <path>`, an alias) is not blocked. This skill deletes local branches with `git-branch-delete.sh` after the confirmation gate; the script deletes with `git update-ref -d`, so the confirmed path does not go through these patterns. No script or instruction this skill runs (excluding `*.test.sh` fixtures) runs `git push --delete`.

Expand All @@ -163,6 +166,8 @@ Per-tier targets: [reference/cleanup-config.md](reference/cleanup-config.md). Sc

Run `resolve-clean-action.sh`. If `Action: menu`, show table + ask. Otherwise dispatch to the matching § below. **Never `--apply` on first invocation.**

For any resolved action other than `scan` and `scan-batch`, open the run marker before the first dry-run or `--apply` and clear it when the run reaches its final outcome (done, declined or aborted): [context/run-marker.md](context/run-marker.md). This session's id is `${CLAUDE_SESSION_ID}`; the plugin data directory is `${CLAUDE_PLUGIN_DATA}`.

Resolve no repo root here. This step is reachable from anywhere, including outside a repository, and a bare `git rev-parse --show-toplevel` on that path exits non-zero and takes the menu down with it. The tiers that need a root resolve it themselves, guarded, in §§ 4 to 6 below; the bundled scripts each resolve their own and print `Error: not a git repository` rather than failing.

### 1. Scan (`scan`)
Expand Down Expand Up @@ -248,7 +253,8 @@ Repo sources: `--repo` (repeatable; a shell glob expands to these), `--repos-fro
## Spoke paths

The `context/` files write this skill's directory as `<skill-dir>`, which is `${CLAUDE_SKILL_DIR}`,
and the plugin data directory as `<plugin-data>`, which is `${CLAUDE_PLUGIN_DATA}`. Put those paths
the plugin data directory as `<plugin-data>`, which is `${CLAUDE_PLUGIN_DATA}`, and the session id as
`<session-id>`, which is `${CLAUDE_SESSION_ID}`. Put those values
in place of the placeholders before running a command. Those files arrive through the Read tool as
plain bytes, so a `${…}` token in them would reach the Bash tool unsubstituted.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ Skill data (`.claude/skills/*/data/`) is preserved unconditionally. No flag remo

## Hook interaction

The session-scoped destructive guard (`scripts/destructive-guard.sh`) blocks bare `git clean -f` / `git reset --hard` while this skill is active, and blocks `git-tree-reset.sh --apply` until the command starts with the acknowledgement prefix (`CLEAN_GUARD_ACK=1 bash git-tree-reset.sh … --apply` on Bash, `$env:CLEAN_GUARD_ACK=1; …` on PowerShell), added only after the confirmation gate. A dry-run is not blocked. Run the reset through the wrapper, not inline git commands: it runs those as subprocesses.
The session-scoped destructive guard (`scripts/destructive-guard.sh`) blocks bare `git clean -f` / `git reset --hard` while a clean run is open (the run marker), and blocks `git-tree-reset.sh --apply` until the command starts with the acknowledgement prefix (`CLEAN_GUARD_ACK=1 bash git-tree-reset.sh … --apply` on Bash, `$env:CLEAN_GUARD_ACK=1; …` on PowerShell), added only after the confirmation gate. A dry-run is not blocked. Run the reset through the wrapper, not inline git commands: it runs those as subprocesses.
38 changes: 38 additions & 0 deletions plugins/repo-hygiene/skills/clean/context/run-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Run marker

The destructive guard gates only while a mutating clean run is open in the session. The marker is a
file, `<plugin-data>/clean-run/<session-id>.open`, holding the epoch second of its last write.

## Open

Right after the action resolves, for every action except `scan` and `scan-batch`, before the first
dry-run or `--apply`:

```bash
bash <skill-dir>/scripts/clean-run-marker.sh open --tier <resolved-action> --session <session-id> --data-dir <plugin-data>
```

`scan` and `scan-batch` exit 0 with `Marker: not armed` and write nothing. Any other failure (exit 2:
the data directory or session id did not resolve) means the guard cannot be scoped to this run: stop
and tell the user rather than continuing without it.

## Clear

When the run reaches its final outcome (applied, declined at the confirmation gate, or aborted):

```bash
bash <skill-dir>/scripts/clean-run-marker.sh close --session <session-id> --data-dir <plugin-data>
```

It succeeds when the marker is already gone. Until it runs, the guard keeps gating every Bash and
PowerShell call in the session.

## Idle timeout

The guard refreshes the marker on each call that runs one of the skill's mutating scripts (a
dry-run included, and even when the marker is idle past the hour, so a long confirmation gate
still reaches its apply gated) and on each acknowledged or destructive call. Ordinary shell calls
leave it alone, including one that only names a script (staging or grepping it). A marker idle for an hour is deleted by the next ordinary call, which then allows
the command. This covers a run that ended without the clear step.
The guard stays armed, ignoring the marker, when `CLAUDE_PLUGIN_DATA` is empty in the hook process or
the hook input carries no usable `session_id`.
95 changes: 95 additions & 0 deletions plugins/repo-hygiene/skills/clean/scripts/clean-run-marker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Opens, closes or reports the per-session run-open marker that scopes the clean
# skill's destructive guard (destructive-guard.sh) to a mutating clean run.
#
# Usage:
# clean-run-marker.sh open --tier <tier> --session <id> --data-dir <dir>
# clean-run-marker.sh close --session <id> --data-dir <dir>
# clean-run-marker.sh --help
#
# open writes the marker for every tier except the read-only scan and
# scan-batch, which never arm the guard (it prints "not armed" and writes
# nothing). close removes it and succeeds when it is already gone. The marker
# lives under <dir>/clean-run/<id>.open; the guard reads the same path from
# CLAUDE_PLUGIN_DATA and the hook input's session_id.
#
# Exit: 0 success; 2 usage error or an unusable --session / --data-dir.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/run-marker.sh
source "$SCRIPT_DIR/lib/run-marker.sh"

usage() {
sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}

ACTION="${1:-}"
case "$ACTION" in
open | close) shift ;;
--help | -h)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac

TIER="" SESSION="" DATA_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--tier)
TIER="${2:-}"
shift 2 || shift
;;
--session)
SESSION="${2:-}"
shift 2 || shift
;;
--data-dir)
DATA_DIR="${2:-}"
shift 2 || shift
;;
--help | -h)
usage
exit 0
;;
*)
echo "Error: unknown argument: $1" >&2
exit 2
;;
esac
done

if [[ "$ACTION" == open ]]; then
if [[ -z "$TIER" ]]; then
echo "Error: open needs --tier" >&2
exit 2
fi
case "$TIER" in
scan | scan-batch)
echo "Marker: not armed (read-only tier: $TIER)"
exit 0
;;
*) ;;
esac
fi

MARKER=""
if ! run_marker_path_to MARKER "$DATA_DIR" "$SESSION"; then
echo "Error: --data-dir and --session must both resolve (session ids use letters, digits, _ and -); the destructive guard cannot be scoped to this run." >&2
exit 2
fi

if [[ "$ACTION" == open ]]; then
if ! run_marker_write "$MARKER"; then
echo "Error: could not write the run marker: $MARKER" >&2
exit 2
fi
echo "Marker: armed ($MARKER)"
else
rm -f "$MARKER"
echo "Marker: cleared"
fi
Loading
Loading