Summary
The disk-hygiene clean skill's destructive_guard.py reads the authorized data root from the
CLAUDE_PLUGIN_DATA environment variable (os.environ.get("CLAUDE_PLUGIN_DATA") in
_is_authorized_data_root / _display_data_root). The guard is declared as a skill-frontmatter
hook in skills/clean/SKILL.md. That environment variable is not present in the guard's
process environment, so --data-root never validates and the guard fails closed on every engine
invocation. The entire scan/preview/apply engine lane is unusable through its sanctioned Bash
lane — on all platforms, not just Windows.
Environment
- Claude Code
2.1.215
- Windows 11 Pro (26200); guard interpreter
cpython-3.14.6 (uv-managed), Python ≥ 3.11 OK
disk-hygiene@melodic-software 0.4.0 (installed from main 57c4f4b)
Observed
Every guarded engine call — including the exact bundled scan shape with the hook's own absolute
interpreter — is denied with:
The hook process did not receive CLAUDE_PLUGIN_DATA, so --data-root cannot be validated and engine
calls fail closed.
${CLAUDE_PLUGIN_ROOT} does resolve in the same frontmatter hook args (the guard script path
resolves and the guard executes), so the hook fires; only the environment injection of
CLAUDE_PLUGIN_DATA is missing.
Root cause
Per the plugins reference (https://code.claude.com/docs/en/plugins-reference), the placeholders
${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PLUGIN_DATA} / ${CLAUDE_PROJECT_DIR} resolve via inline
substitution in hook commands ("anywhere the placeholder appears"), which is a separate mechanism
from environment-variable injection into the hook subprocess. Empirically, on 2.1.215 a hook
declared in skill frontmatter gets inline substitution but does not get CLAUDE_PLUGIN_DATA
injected into its process environment. The guard depends on the env-var path exclusively, so it can
never obtain the authoritative data root.
The 0.3.0 --data-root work (and its CHANGELOG note "the runtime exports it to hook processes")
addressed the shell-tool subprocess gap but did not fix the guard side: the guard still reads the
authority from os.environ, which is empty for this hook. destructive_guard.py was not modified by
that change.
Recommended fix (preserves skill-scoping)
Pass the data root to the guard as an explicit substituted argument rather than relying on env
injection, since inline substitution is proven to work in this exact frontmatter context:
skills/clean/SKILL.md frontmatter hook — add the placeholder as an arg:
hooks:
PreToolUse:
- matcher: "Bash|PowerShell"
hooks:
- type: command
command: "python"
args:
- "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/destructive_guard.py"
- "--authorized-data-root"
- "${CLAUDE_PLUGIN_DATA}"
destructive_guard.py — read the authorized root from argv (falling back to
CLAUDE_PLUGIN_DATA env when the arg is absent, for backward compatibility), and validate the
model-supplied --data-root against it exactly as today. The security property is unchanged: the
authority still comes from a runtime-substituted value the model cannot forge.
Verify during implementation: confirm ${CLAUDE_PLUGIN_DATA} substitutes in skill-frontmatter
hook args the same way ${CLAUDE_PLUGIN_ROOT} already does (add it, run a scan, confirm the guard
receives the resolved absolute path).
Alternative (rejected)
Move the guard to a plugin-level hooks/hooks.json so it is unambiguously a "plugin hook" that
receives CLAUDE_PLUGIN_DATA. Downsides: the guard would then run on every Bash/PowerShell call
for the whole session (loses the intended skill-scoping), and it still depends on env injection rather
than the proven substitution path.
Impact
- Linux: full engine (incl.
apply) is unreachable through the guarded lane — the plugin's core
value is inaccessible.
- Windows/macOS:
apply is already execution-platform-unsupported, but the deterministic scan
(snapshot + tiered classification) is also blocked, forcing an entirely manual audit.
Repro
- On Windows + Claude Code 2.1.215, invoke
/disk-hygiene:clean <dir>.
- Run the exact bundled
scan shape via Bash with the hook's absolute interpreter and a correct
--data-root.
- Observe the fail-closed denial citing missing
CLAUDE_PLUGIN_DATA.
Summary
The
disk-hygieneclean skill'sdestructive_guard.pyreads the authorized data root from theCLAUDE_PLUGIN_DATAenvironment variable (os.environ.get("CLAUDE_PLUGIN_DATA")in_is_authorized_data_root/_display_data_root). The guard is declared as a skill-frontmatterhook in
skills/clean/SKILL.md. That environment variable is not present in the guard'sprocess environment, so
--data-rootnever validates and the guard fails closed on every engineinvocation. The entire
scan/preview/applyengine lane is unusable through its sanctioned Bashlane — on all platforms, not just Windows.
Environment
2.1.215cpython-3.14.6(uv-managed), Python ≥ 3.11 OKdisk-hygiene@melodic-software0.4.0(installed from main57c4f4b)Observed
Every guarded engine call — including the exact bundled
scanshape with the hook's own absoluteinterpreter — is denied with:
${CLAUDE_PLUGIN_ROOT}does resolve in the same frontmatter hook args (the guard script pathresolves and the guard executes), so the hook fires; only the environment injection of
CLAUDE_PLUGIN_DATAis missing.Root cause
Per the plugins reference (https://code.claude.com/docs/en/plugins-reference), the placeholders
${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA}/${CLAUDE_PROJECT_DIR}resolve via inlinesubstitution in hook commands ("anywhere the placeholder appears"), which is a separate mechanism
from environment-variable injection into the hook subprocess. Empirically, on 2.1.215 a hook
declared in skill frontmatter gets inline substitution but does not get
CLAUDE_PLUGIN_DATAinjected into its process environment. The guard depends on the env-var path exclusively, so it can
never obtain the authoritative data root.
The 0.3.0
--data-rootwork (and its CHANGELOG note "the runtime exports it to hook processes")addressed the shell-tool subprocess gap but did not fix the guard side: the guard still reads the
authority from
os.environ, which is empty for this hook.destructive_guard.pywas not modified bythat change.
Recommended fix (preserves skill-scoping)
Pass the data root to the guard as an explicit substituted argument rather than relying on env
injection, since inline substitution is proven to work in this exact frontmatter context:
skills/clean/SKILL.mdfrontmatter hook — add the placeholder as an arg:destructive_guard.py— read the authorized root fromargv(falling back toCLAUDE_PLUGIN_DATAenv when the arg is absent, for backward compatibility), and validate themodel-supplied
--data-rootagainst it exactly as today. The security property is unchanged: theauthority still comes from a runtime-substituted value the model cannot forge.
Verify during implementation: confirm
${CLAUDE_PLUGIN_DATA}substitutes in skill-frontmatterhook args the same way
${CLAUDE_PLUGIN_ROOT}already does (add it, run ascan, confirm the guardreceives the resolved absolute path).
Alternative (rejected)
Move the guard to a plugin-level
hooks/hooks.jsonso it is unambiguously a "plugin hook" thatreceives
CLAUDE_PLUGIN_DATA. Downsides: the guard would then run on every Bash/PowerShell callfor the whole session (loses the intended skill-scoping), and it still depends on env injection rather
than the proven substitution path.
Impact
apply) is unreachable through the guarded lane — the plugin's corevalue is inaccessible.
applyis alreadyexecution-platform-unsupported, but the deterministicscan(snapshot + tiered classification) is also blocked, forcing an entirely manual audit.
Repro
/disk-hygiene:clean <dir>.scanshape via Bash with the hook's absolute interpreter and a correct--data-root.CLAUDE_PLUGIN_DATA.