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
2 changes: 1 addition & 1 deletion plugins/disk-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "disk-hygiene",
"version": "0.41.0",
"version": "0.41.1",
"description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.",
"author": {
"name": "Melodic Software",
Expand Down
6 changes: 6 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to the `disk-hygiene` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.41.1] - 2026-09-30

### Changed

- **The engine-gate denial names what failed** ([#5519](https://github.com/melodic-software/claude-code-plugins/issues/5519)). A denied Bash command now says which token or stage the exact-engine classifier refused, states the flag-order rule (required flags first, in declared order, then optional flags in any order), and states which mention forms are gated and which read-only forms work, adding that a relative path or bare name which resolves to the installed engine from the current directory is still gated. A quoted word the gate reads as an engine call, such as a `gh --search` query naming an interpreter and the engine, is named as the gated word. The message is the only change: the guard matches and denies exactly as before.

## [0.41.0] - 2026-09-30

### Added
Expand Down
105 changes: 105 additions & 0 deletions plugins/disk-hygiene/lib/engine_grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,108 @@ def value_ok(flag: Flag, value: str) -> bool:
for flag in spec.optional
if flag.requires is not None and flag.name in seen
) and all(len(seen.intersection(group)) == 1 for group in spec.one_of)


_TOKEN_CAP = 80


def clip_token(value: str) -> str:
"""A user-supplied token quoted for a message, capped so a paste stays short."""
clipped = value if len(value) <= _TOKEN_CAP else value[: _TOKEN_CAP - 3] + "..."
return repr(clipped)


def required_order(spec: Subcommand) -> str:
"""The required head of ``spec`` spelled in the one order the guard admits."""
return ", ".join(flag.name for flag in spec.required)


def _order_rule(spec: Subcommand) -> str:
if not spec.required:
return "every flag is optional and may come in any order"
return (
f"required flags first, in order: {required_order(spec)}; "
"then optional flags in any order"
)


def explain_mismatch(
name: str,
words: list[str],
external_checks: dict[str, object] | None = None,
) -> str | None:
"""Name the first word ``match_invocation`` refuses and the rule it broke.

Runs on the deny path only and walks the same grammar in the same order, so
it returns ``None`` exactly when ``match_invocation`` returns ``True``.
"""
spec = subcommand(name)
if spec is None:
return f"{clip_token(name)} is not an engine subcommand."
checks = external_checks or {}

def value_problem(flag: Flag, value: str) -> str | None:
subject = f"{flag.name} value {clip_token(value)}"
if not is_argument(value):
return f"{subject} must be a literal, not empty or starting with '-'."
if flag.choices is not None and value not in flag.choices:
return f"{subject} must be one of: {', '.join(sorted(flag.choices))}."
if flag.pattern is not None and flag.pattern.fullmatch(value) is None:
return f"{subject} must match {flag.pattern.pattern}."
if flag.external_check is not None:
check = checks.get(flag.external_check)
if not (callable(check) and check(value)):
return f"{subject} is not the {flag.external_check}."
return None

def read_value(flag: Flag, index: int) -> tuple[int, str | None]:
if not flag.takes_value:
return index, None
if index >= len(words):
return index, f"{flag.name} needs a value."
return index + 1, value_problem(flag, words[index])

index = 0
for flag in spec.required:
if index >= len(words):
return f"required flag {flag.name} is missing; {_order_rule(spec)}."
if words[index] != flag.name:
return (
f"{clip_token(words[index])} is where required flag {flag.name} "
f"belongs; {_order_rule(spec)}."
)
index, problem = read_value(flag, index + 1)
if problem:
return problem

seen: set[str] = set()
while index < len(words):
word = words[index]
flag = spec.flag(word)
if flag is None:
return f"{clip_token(word)} is not a {name} flag."
if flag.required:
return (
f"required flag {flag.name} is already given in the required head "
"and cannot repeat."
)
if flag.name in seen and not flag.repeatable:
return f"{flag.name} is not repeatable but is given twice."
seen.add(flag.name)
index, problem = read_value(flag, index + 1)
if problem:
return problem

for flag in spec.optional:
if (
flag.requires is not None
and flag.name in seen
and flag.requires not in seen
):
return f"{flag.name} requires {flag.requires}."
for group in spec.one_of:
given = sorted(seen.intersection(group))
if len(given) != 1:
count = "none was" if not given else f"{len(given)} were"
return f"give exactly one of {', '.join(group)}; {count} given."
return None
193 changes: 176 additions & 17 deletions plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,29 @@ def _carries_marker(word: str) -> bool:
return name == _ENGINE_MARKER


def _is_interpreter(word: str) -> bool:
base = Path(word.casefold()).name
return base.startswith("python") or base in {"py", "py.exe"}


def _reads_as_engine_payload(word: str) -> bool:
"""Whether a word that is not the engine path still reads as an engine call.

A quoted compound payload (sh -c / pwsh -Command) whose first token is the
engine or an interpreter, or any word holding both the engine filename and
"python". The gate and its denial reason share this test, so the reason
names the word the gate acted on.
"""
folded = word.casefold()
if _carries_marker(word) or _ENGINE_MARKER not in folded:
return False
if " " in word:
first_token = folded.split()[0]
if _carries_marker(first_token) or _is_interpreter(first_token):
return True
return "python" in folded


# PowerShell also closes a quote opened with ' by any of these and a " by the
# double forms; a scanner that knows only the ASCII pair would see a different
# string boundary than PowerShell does.
Expand Down Expand Up @@ -875,10 +898,6 @@ def _engine_gate_relevant(command: str, tool_name: str = "Bash") -> bool:
the rest of the session once that belt has registered).
"""

def _is_interpreter(word: str) -> bool:
base = Path(word.casefold()).name
return base.startswith("python") or base in {"py", "py.exe"}

bundled = _engine_script_path()

def _samefile(word: str) -> bool:
Expand Down Expand Up @@ -1022,7 +1041,6 @@ def _provably_other_file(token: str, word: str) -> bool:
if Path(word.casefold()).name in _WRAPPERS
]
for index, word in enumerate(words):
folded = word.casefold()
if _carries_marker(word):
if _samefile(word) or _within_plugin_cache_family(word):
# The word is one of THIS PLUGIN'S engines — the bundled one,
Expand Down Expand Up @@ -1056,13 +1074,7 @@ def _provably_other_file(token: str, word: str) -> bool:
# (`git diff -- hygiene.py`) still defer.
return True
continue
if _ENGINE_MARKER in folded and " " in word:
first_token = folded.split()[0]
if _carries_marker(first_token) or _is_interpreter(first_token):
# A quoted compound payload (sh -c / pwsh -Command) whose first
# token is the engine or an interpreter is an invocation.
return True
if _ENGINE_MARKER in folded and "python" in folded:
if _reads_as_engine_payload(word):
return True
return False

Expand Down Expand Up @@ -2148,7 +2160,145 @@ def _bash_allowlist_disclosure(authority: str | None) -> str:
)


def _bash_denial_guidance(authority: str | None, mode: str | None = None) -> str:
_OPERATOR_LABELS = {
char: label
for chars, label in (
("|", "a pipe"),
("<>", "a redirect"),
(";", "a ';'"),
("&", "an '&'"),
("$`(){}", "a substitution or expansion"),
("*?[]~", "a glob or tilde"),
("\r\n\t", "a newline or tab"),
("!#", "a '!' or '#'"),
)
for char in chars
}


def _unparsable_reason(command: str) -> str:
"""Name the first thing in ``command`` that ``_literal_shell_words`` rejects."""
for char in command:
if char in _OPERATOR_LABELS:
culprit = f"{_OPERATOR_LABELS[char]} ({char!r})"
break
else:
culprit = (
"a backslash"
if "\\" in command
else "a quote that does not wrap a whole word, or a non-space whitespace character"
)
return f"The command contains {culprit}, so it is not one plain literal invocation."


def _resolves_to_engine(word: str) -> bool:
key = _script_path_key(word)
return key is not None and key == _script_path_key(str(_engine_script_path()))


def _engine_mismatch_reason(command: str, authority: str | None) -> str:
"""One sentence naming what ``classify_exact_engine_command`` refuses.

Deny path only; the caller has already denied and this decides nothing. It
walks the classifier's stages in order, except that when the command is not
the hook's Python, an engine operand or a word that reads as an engine
payload is named first: that word is what the gate acts on, whatever the
command's length.
"""
tokens = _literal_shell_words(command)
if tokens is None:
return _unparsable_reason(command)
python_ok = _is_current_python(tokens[0])
operand = (
None
if python_ok
else next((word for word in tokens[1:] if _resolves_to_engine(word)), None)
)
payload = (
None
if python_ok or operand is not None
else next((word for word in tokens if _reads_as_engine_payload(word)), None)
)
if payload is not None:
return (
f"{engine_grammar.clip_token(payload)} holds the engine filename with "
"an interpreter or as its first word, so the gate reads it as an "
"engine call."
)
if operand is not None:
named = (
"is the engine path"
if os.path.isabs(operand)
else "resolves to the engine from the current directory"
)
return (
f"{engine_grammar.clip_token(operand)} {named}, and only a call "
f'through "{_display_python()}" may name it; '
f"{engine_grammar.clip_token(tokens[0])} is not that interpreter."
)
if len(tokens) < 3:
return (
f"The command has {len(tokens)} word(s); an engine call is "
"<hook python> <engine script> <subcommand> <flags>."
)
if not python_ok:
return (
f"{engine_grammar.clip_token(tokens[0])} is not this hook's Python; "
f'the interpreter must be "{_display_python()}".'
)
if not _resolves_to_engine(tokens[1]):
return (
f"{engine_grammar.clip_token(tokens[1])} is not the bundled engine "
f'"{_display_path(_engine_script_path())}".'
)
subcommand = tokens[2]
if subcommand not in _ALLOWED_ENGINE_SUBCOMMANDS:
return (
f"{engine_grammar.clip_token(subcommand)} is not an engine "
f"subcommand; use one of {', '.join(_ALLOWED_ENGINE_SUBCOMMANDS)}."
)
words = tokens[3:]
if engine_grammar.DATA_ROOT_FLAG not in words:
return (
f"{engine_grammar.DATA_ROOT_FLAG} is missing; every engine call "
f"passes {engine_grammar.DATA_ROOT_FLAG} with the authorized root."
)
external_checks = {
engine_grammar.AUTHORIZED_DATA_ROOT: (
lambda value: _is_authorized_data_root(value, authority)
),
}
return (
engine_grammar.explain_mismatch(subcommand, words, external_checks)
or "The arguments do not match the engine grammar."
)


def _engine_flag_order_rule() -> str:
heads = "; ".join(
f"{spec.name}: {engine_grammar.required_order(spec)}"
for spec in engine_grammar.SUBCOMMANDS
if spec.required
)
return (
"Flag order: required flags come first, in declared order "
f"({heads}), then optional flags in any order."
)


_ENGINE_GATE_SCOPE = (
"Any command that contains the engine filename together with a pipe, "
"redirect, ;, substitution, or an absolute engine-path operand is gated. "
"The read-only forms that work name the engine by a relative path or bare "
"name in a plain git show, git grep, grep or rg with no pipe, redirect or ;. "
"A relative path or bare name that resolves to the installed engine from "
"the current directory is still gated."
)
Comment thread
kyle-sexton marked this conversation as resolved.


def _bash_denial_guidance(
authority: str | None, mode: str | None = None, command: str | None = None
) -> str:
"""Explain a Bash deny in the words of the surface that issued it.

``engine-gate`` (the plugin-level always-on hook) gates this engine
Expand All @@ -2158,16 +2308,25 @@ def _bash_denial_guidance(authority: str | None, mode: str | None = None) -> str
that skill is invoked, and names how it clears. Both bodies disclose the
same classifier allow-list so the denial cannot teach a grammar the
classifier does not implement. Unrecognized ``mode`` values fall back to
``belt``, matching ``resolve_mode``.
``belt``, matching ``resolve_mode``. ``command`` is read only by the
``engine-gate`` body, to name what failed in the denied command.
"""
resolved = resolve_mode() if mode is None else mode
grammar = _bash_allowlist_disclosure(authority)
if resolved == _MODE_ENGINE_GATE:
reason = (
f"{_engine_mismatch_reason(command, authority)} "
if command is not None
else ""
)
return (
"Disk-hygiene engine gate: this specific engine invocation is "
"gated. The rest of the Bash lane is unaffected, and "
"gated. " + reason + "The rest of the Bash lane is unaffected, and "
"/disk-hygiene:clean need not have been invoked for this to fire. "
"Allowed shapes for this invocation are "
+ _engine_flag_order_rule()
+ " "
+ _ENGINE_GATE_SCOPE
+ " Allowed shapes for this invocation are "
+ grammar
+ " Supporting inspection of this invocation may use that small "
"Bash allowlist or non-Bash read-only tools; any other shape of "
Expand Down Expand Up @@ -2849,7 +3008,7 @@ def _decide(command: str, tool_name: str, start: float) -> int:
start,
"deny",
"not-exact-engine-command",
_bash_denial_guidance(authority),
_bash_denial_guidance(authority, command=command),
)


Expand Down
Loading
Loading