Skip to content

fix(disk-hygiene): setup check reports the kill switch deterministically, never from an unexpanded body token#1012

Merged
kyle-sexton merged 4 commits into
mainfrom
fix/1007-setup-check-kill-switch-report
Jul 22, 2026
Merged

fix(disk-hygiene): setup check reports the kill switch deterministically, never from an unexpanded body token#1012
kyle-sexton merged 4 commits into
mainfrom
fix/1007-setup-check-kill-switch-report

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Fixes the HIGH finding from handoff-inbox item 20260722-004536-disk-hygiene-setup-audit: /disk-hygiene:setup check reported the safety-critical disk_hygiene_enabled kill switch by emitting ${user_config.disk_hygiene_enabled} in the skill body with the rule "unexpanded or empty means default true" — so a configured false (audit-only mode) whose token failed to expand was misreported as enabled.

  • New probe skills/setup/scripts/kill_switch_probe.py (stdlib-only, report-only, exit 0 always): reads pluginConfigs[<plugin-id>].options.disk_hygiene_enabled from user settings.json (CLAUDE_CONFIG_DIR-aware), emits one JSON line: effective, source (configured/default/indeterminate), degraded, matched entries. Unparsable settings, invalid types, and conflicting entries degrade honestly — an assumed default is always labeled an assumption.
  • setup check step 4 reports the probe result with provenance; the body token is at most a cross-check whose contradiction is reported, never silently resolved.
  • clean skill resolves an unexpanded token via the same probe instead of assuming "unset = enabled"; the guard's Bash allowlist permits exactly the argument-free bundled probe invocation (arguments, bare python, other paths stay denied — fail-closed posture unchanged).
  • Enforcement is untouched: the guard's runtime-substituted --disk-hygiene-enabled hook argument (0.4.4) remains the authority.
  • Version 0.4.6 → 0.5.0 + CHANGELOG.

Fresh-docs verification (fetched 2026-07-22): https://code.claude.com/docs/en/plugins-reference states non-sensitive ${user_config.*} values "can also be substituted in skill and agent content", while https://code.claude.com/docs/en/skills omits ${user_config.*} from its substitution table and the producer's live run observed the token unexpanded — docs/field conflict, so body expansion cannot be load-bearing for a safety report.

Testing

  • Red-first: 13 new probe tests (test_kill_switch_probe.py, wrapper kill_switch_probe.test.sh auto-discovered by scripts/run-plugin-tests.sh), 4 new guard tests (probe shape allowed incl. audit-only mode; arguments / bare python denied).
  • Full suite: test_hygiene.py 85 tests OK (4 platform skips); check-changed-skills.sh PASS; check-changelog-parity.sh --check-bump PASS; shellcheck clean.

Related

🤖 Generated with Claude Code

@kyle-sexton
kyle-sexton enabled auto-merge (squash) July 22, 2026 15:59

@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: 1c57a36911

ℹ️ 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/disk-hygiene/skills/setup/scripts/kill_switch_probe.py
@kyle-sexton
kyle-sexton force-pushed the fix/1007-setup-check-kill-switch-report branch from 1c57a36 to 4fa7bd4 Compare July 22, 2026 16:07
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 52s —— View job


PR Review: fix(disk-hygiene): setup check reports kill switch deterministically

Todo list:

  • Gather context and understand the request
  • Read changed files: kill_switch_probe.py, test_kill_switch_probe.py, kill_switch_probe.test.sh
  • Read changed files: destructive_guard.py, test_hygiene.py, SKILL.md (setup + clean)
  • Read plugin manifest and CHANGELOG
  • Analyze for correctness, security, and alignment with guidelines
  • Post review findings

Summary

This PR correctly diagnoses and fixes a real safety-reporting flaw: ${user_config.disk_hygiene_enabled} in the skill body was not reliably expanded, so a configured false could be silently misreported as true. The probe-first approach is architecturally sound — the body token becomes a cross-check rather than the authority. The guard allowlist for the probe is appropriately narrow (argument-free, absolute-interpreter-only, exact path). Overall, this is a careful, well-tested fix. The findings below are mostly notes and one low-risk style issue; none block merge.


Findings

1. zip(interpreted, entries) in the detail string is unexpectedly clever

kill_switch_probe.py line 131

{k: e['value'] for k, e in zip(interpreted, entries)}

zip(dict, list) iterates over dict keys, so k is a key string and e is the corresponding entry dict — this produces {key: raw_value} which is the right intent. It's correct because interpreted preserves insertion order from entries (Python 3.7+). However it reads as though k might be an interpreted value. The equivalent and clearer form is:

{entry["key"]: entry["value"] for entry in entries}

Fix this →


2. assert effective is not None is correct but fragile against its own contract

kill_switch_probe.py line 149

The logic is sound: by line 148, None in values has already been excluded and len(values) == 1, so values.pop() is always a non-None bool. The assert is redundant. The risk is that if assert is ever triggered (shouldn't happen), the probe raises AssertionError, exits non-zero, and prints a traceback — violating both the "exit always 0" and "single-line JSON on stdout" guarantees. A small guard would be safer:

effective = values.pop()
if effective is None:
    return _report(True, "indeterminate", True, "Internal probe error; assuming default.", settings_path, entries)

Low risk given the logical guarantee, but worth noting for a safety-critical path.


3. Codex P2 finding — managed settings scope limitation (design decision, not a bug)

kill_switch_probe.py lines 111–121

The Codex reviewer flagged that when no user-settings entry exists, the probe reports source: "default", degraded: false — which overstates confidence if managed settings or --settings carries disk_hygiene_enabled: false.

This limitation is intentional and explicitly documented: the probe docstring says "managed settings and a --settings flag can also carry pluginConfigs and are not visible here; the detail sentence states the path actually probed so the reader can judge the claim." SKILL.md step 4 further says to "report the discrepancy" if the body token contradicts the probe. The guard's enforcement channel (runtime-substituted --disk-hygiene-enabled hook arg) is unaffected.

One thing worth verifying: the detail sentence for the "no entries" case says "the toggle is not configured there" (emphasis on the specific path probed), which correctly scopes the claim. This is sufficient for the reporting use-case. No change needed, but worth an explicit call-out in the review conversation since the Codex P2 is visible and may prompt follow-up.


4. parents[2] cross-skill path navigation — correct, but worth a comment

destructive_guard.py lines 294–298

Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"

parents[2] from skills/clean/scripts/ is skills/ — so this resolves to skills/setup/scripts/kill_switch_probe.py. Correct. Worth a one-line comment (# skills/clean/scripts/ → skills/) since this traversal is non-obvious and a reviewer's first read might miscalculate it.


5. Consistent-duplicate entries are accepted as configured — intentional but verify semantics

kill_switch_probe.py lines 179–192 in tests

Two entries disk-hygiene@a and disk-hygiene@b both set to falsesource: "configured", degraded: false. This is the right call: the values agree, so no ambiguity. Confirmed by test_consistent_duplicate_entries_stay_configured.


What's well done

  • Probe scope discipline: the probe never tries to read managed settings or --settings layers it can't reliably access; instead it documents exactly what it probed and lets the model reconcile
  • Fail-safe posture throughout: every degraded branch defaults to effective: true (enabled), so a reporting failure never silently disables the guard
  • Guard allowlist is tight: is_exact_kill_switch_probe rejects any arguments, bare python, wrong path — consistent with the existing fail-closed Bash lane design
  • Test coverage: missing file, configured false, configured true, bare key, unrelated key, no toggle in matched entry, string booleans, unparseable JSON, invalid type, conflicting entries, consistent duplicates, CLAUDE_CONFIG_DIR honor, single-line JSON output — 13 cases cover the full decision tree
  • 4 guard tests cover both allow-paths (enabled and audit-only) and both deny-paths (with arguments and bare python)
  • CHANGELOG and version bump: appropriate minor-version bump with clear attribution of the fix; CHANGELOG accurately describes what changed and why

Verdict

Ready to merge. The one actionable style fix (item 1) is optional but would make the code easier to follow. Everything else is correct.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

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


Security Review — PR #1012

  • Gather context and PR diff
  • Read destructive_guard.py helper functions
  • Analyze injection surfaces, path checks, and logic flows
  • Post findings

Scope: kill_switch_probe.py, destructive_guard.py (new is_exact_kill_switch_probe), both SKILL.md edits, test files. No CI/Actions files changed.


No CRITICAL or IMPORTANT findings.

The overall design is sound: the probe is stdlib-only and read-only (exit 0 always), the guard uses _literal_shell_words to reject any shell meta-characters before inspecting tokens, and the path comparison relies on resolve(strict=True) + normcase + samefile rather than string matching.


SUGGESTION — None == None path-key comparison allows any nonexistent script through the probe gate (low practical risk)

Confidence: High that the condition can arise; low that it is exploitable.

destructive_guard.py L295–298

expected_script = str(
    Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
)
return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key returns None when Path(value).resolve(strict=True) raises OSError (file absent, symlink loop, permission-denied traversal). If the probe file is missing from the installed plugin (partial install, accidental deletion), _script_path_key(expected_script) returns None. An attacker-supplied tokens[1] that also fails strict=True resolution likewise returns None, so None == None evaluates True and the guard emits allow.

In practice the consequence is limited: the allowed Bash command is <python> <nonexistent-or-unresolvable-path>, which fails at execution time — Python cannot open a file it cannot stat. A path that fails resolve(strict=True) on a symlink loop also fails open() in CPython, so the actual command cannot do meaningful work. Additionally, _is_current_python(tokens[0]) still requires the exact hook interpreter as the first token.

The identical pattern (_script_path_key(a) != _script_path_key(b)) exists in the pre-existing classify_exact_engine_command (which guards apply) and is not introduced by this PR; the new probe-gate code is consistent with that baseline.

Hardening suggestion: guard the comparison against None explicitly:

candidate_key = _script_path_key(tokens[1])
expected_key = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

This makes is_exact_kill_switch_probe fail-closed when either path is unresolvable — and is a stronger posture than the existing classify_exact_engine_command code, even though the consequence there (an apply-gate bypass in a corrupted install) is more severe. Fix this →


Notes on other surfaces examined

  • Command injection (kill_switch_probe.py): No subprocess calls. The script does only json.loads(settings_path.read_text(...)) and json.dumps(...). No injection surface.
  • Path traversal via CLAUDE_CONFIG_DIR: Trusted framework variable; joined only with the literal settings.json. Not a concern.
  • --settings-file argument on the probe: Accessible only when calling the script directly (testing/CLI), not through the guarded LLM tool-use path. The guard explicitly rejects any token count other than 2, blocking --settings-file injection from the LLM.
  • Exception details in detail field: The probe includes exc text and settings_path in degraded-read error messages. These are diagnostic details reported to the operator who already owns the settings file. Not a concern.
  • Probe allowed when enabled=False: Intentional and correct — querying the kill switch must work even in audit-only mode. Read-only probe has no destructive capability.
  • Codex P2 finding (managed-settings scope): Confirmed as a correctness/documentation concern, not a security issue. The PR explicitly discloses the limitation in setup/SKILL.md step 4 ("the probe sees user settings only; managed settings or a --settings flag can carry a value the probe cannot see") and instructs the skill to report any discrepancy. No silent mismatch.

@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: 4fa7bd40f9

ℹ️ 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/disk-hygiene/skills/setup/scripts/kill_switch_probe.py
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

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


PR Review: fix(disk-hygiene): setup check reports kill switch deterministically

Todo list:

  • Gather context and understand the PR
  • Read changed files: kill_switch_probe.py, test_kill_switch_probe.py, kill_switch_probe.test.sh
  • Read changed files: destructive_guard.py, test_hygiene.py, SKILL.md files
  • Read plugin.json and CHANGELOG.md
  • Check if prior review findings were addressed in the latest commit
  • Synthesize and post review

Context

This PR has two commits:

  • 4fa7bd4 — main fix (kill-switch probe + guard changes, 520 additions)
  • d53a11e — mark shebanged probe scripts executable in the git index (mode change only, 0 insertions/deletions)

The latest push only changed file modes; no code changed since the prior two Claude reviews. None of the findings from those reviews have been addressed yet.


Unaddressed Finding That Needs a Fix

AttributeError on non-dict JSON root violates the exit-0 contract

kill_switch_probe.py line 86–99

try:
    settings = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
    return _report(...)       # catches unparsable JSON

plugin_configs = settings.get("pluginConfigs")   # ← AttributeError if settings is a list, str, int, or None

When settings.json is valid JSON whose root is not an object (e.g. [], "string", 42, null), json.loads succeeds, bypasses the except clause, and settings.get(...) raises AttributeError on a non-dict. The probe exits nonzero with a traceback on stderr and no JSON on stdout, violating both the "exit code is always 0" and "single-line JSON on stdout is the whole contract" guarantees. Callers get no degraded report; the clean skill has no signal to fall back on.

Codex flagged this at the commit 4fa7bd40f9 review (inline comment on line 99); it was not addressed before the latest push.

Fix — add a type guard immediately after parsing:

try:
    raw = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
    return _report(True, "indeterminate", True,
        f"Could not read the configured toggle from {settings_path} ({exc}); "
        "assuming the default (enabled). This is an assumption, not the configured value.",
        settings_path, [])

if not isinstance(raw, dict):
    return _report(True, "indeterminate", True,
        f"{settings_path} root is not a JSON object ({type(raw).__name__}); "
        "assuming the default (enabled). This is an assumption, not the configured value.",
        settings_path, [])

settings = raw
plugin_configs = settings.get("pluginConfigs")

A test case for this path (write_settings([])) is missing from test_kill_switch_probe.py — should be added alongside the fix.

Fix this →


Findings Carried From Prior Reviews (Still Open)

These were identified in the prior two Claude review comments on this PR and remain unaddressed:

1. assert effective is not None can violate the "exit 0 always" contract

kill_switch_probe.py line 149

The assert is logically guaranteed to never fire (all None values are filtered above, and len(values) == 1 is the only path here), but if it ever does — or if the code evolves and this invariant breaks — it raises AssertionError, exits nonzero with a traceback, and emits no JSON. Python's -O flag also silently disables asserts, which is the opposite of safe degradation. A defensive return is more appropriate on a safety-critical reporting path:

effective = values.pop()
if effective is None:
    return _report(True, "indeterminate", True,
        "Internal probe error; assuming default.", settings_path, entries)

2. zip(interpreted, entries) is confusing

kill_switch_probe.py line 131

zip(dict, list) iterates over dict keys, so k is a key string and e is an entries dict — correct because interpreted preserves insertion order from entries, but reads as though k might be an interpreted value. Clearer:

{entry["key"]: entry["value"] for entry in entries}

3. None == None in is_exact_kill_switch_probe weakens fail-closed posture

destructive_guard.py line 298

If the probe file is absent from the plugin (partial install), _script_path_key(expected_script) returns None; an attacker-controlled path that also fails resolve(strict=True) likewise returns None, so None == None emits allow. Practical risk is low (Python cannot run a nonexistent script), but explicit guarding is consistent with the guard's fail-closed philosophy:

candidate_key = _script_path_key(tokens[1])
expected_key = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

4. parents[2] cross-skill traversal deserves a comment

destructive_guard.py line 296

parents[2] from skills/clean/scripts/ resolves to skills/. A one-line comment (# skills/clean/scripts/ → skills/) would make this immediately verifiable.


Additional Note: Test Wrapper Python Resolution Order

kill_switch_probe.test.sh line 7

The script tries bare python before python3. On systems where python resolves to Python 2.x and python3 resolves to 3.11+, the version check on line 16 will SKIP — even though a suitable interpreter exists. The plugin's own design rationale calls out that bare python is rejected by the guard for exactly this reason (legacy 2.x on stock macOS and many Linux distros). Preferring python3 first would be more consistent and avoids false SKIPs. Minor — correctness is preserved by the version check.


What's Well Done

The core design is sound and the fix addresses a real safety reporting flaw:

  • Probe scope discipline: explicitly documents that managed settings and --settings layers are out of scope, and reports which path was probed so the caller can judge the claim
  • Fail-safe posture throughout: every degraded branch defaults to effective: true; a reporting failure never silently disables the guard
  • Guard allowlist is tight: is_exact_kill_switch_probe rejects any arguments, bare python, wrong path — consistent with the existing fail-closed Bash lane
  • 13 probe tests cover the full decision tree: missing file, configured false/true, bare key, unrelated key, no toggle in matched entry, string booleans, unparsable JSON, invalid type, conflicting entries, consistent duplicates, CLAUDE_CONFIG_DIR honored, single-line JSON output
  • 4 guard tests for both allow-paths (enabled and audit-only) and both deny-paths (with arguments and bare python)
  • CHANGELOG and version bump: 0.4.6 → 0.5.0 appropriate for the new probe; attribution clear

Verdict

One fix is needed before merge: the AttributeError on a non-dict JSON root (finding above). The remaining items are suggestions or style — none block merge independently, but they come up against the "exit 0 always" contract that makes this probe load-bearing for the clean skill's fallback path.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

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


Security Review — PR #1012

Todo list:

  • Gather context and understand the request
  • Fetch PR diff and identify changed files
  • Read kill_switch_probe.py — injection surfaces, path traversal, output guarantees
  • Read destructive_guard.py — allowlist logic, path key comparison, None == None path
  • Read SKILL.md files — prompt injection, template injection in skill bodies
  • Verify test files for security-relevant gaps
  • Synthesize and post findings

No CRITICAL or IMPORTANT findings.

The design is sound: the probe is stdlib-only and strictly read-only (no subprocess, no writes); the guard uses _literal_shell_words to reject any shell meta-characters before token inspection; and _is_current_python requires the exact absolute hook interpreter path as the first token. The kill-switch's enforcement is correctly separated from this reporting path and remains in the guard's runtime-substituted --disk-hygiene-enabled hook argument, which the probe cannot influence.


SUGGESTION — None == None allows any two-token command where both paths are unresolvable (partial install / broken symlinks)

Severity: SUGGESTION · Confidence: High (the condition is reachable; practical exploit is nil — execution fails safely)

destructive_guard.py L295–298

expected_script = str(
    Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
)
return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key returns None on any OSError from resolve(strict=True) — missing file, broken symlink, or permission-denied traversal. If the probe file is absent from the installed plugin (partial install, accidental deletion, or a plugin directory that was moved), _script_path_key(expected_script) returns None. Any tokens[1] that also fails strict resolution (broken symlink, nonexistent path, permission-denied) produces the same None, making None == None → True and emitting allow.

In practice the impact is limited: Python cannot run a file it cannot stat, so the allowed command fails immediately at execution with no side effects. _is_current_python still enforces the first token. The identical pattern (!= form) exists in the pre-existing classify_exact_engine_command for the apply gate, so this is consistent with the baseline — but the apply gate consequence of that same bug is more severe (clean writes, not just a read-only probe).

Hardening suggestion — fail-closed when either path is unresolvable:

candidate_key = _script_path_key(tokens[1])
expected_key  = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

Fix this →


SUGGESTION — AttributeError on non-dict settings root breaks the probe's output contract

Severity: SUGGESTION · Confidence: High (the crash path is real; security impact is indirect — enforcement guard is unaffected)

kill_switch_probe.py L99

settings = json.loads(settings_path.read_text(encoding="utf-8"))
# except block ends here — only catches OSError, UnicodeDecodeError, JSONDecodeError

plugin_configs = settings.get("pluginConfigs")  # AttributeError if root is [], "str", 42, …

When settings.json is valid JSON but its root is not an object (e.g. [], "string", or 42), json.loads succeeds and the except block is never entered. The subsequent .get() call raises AttributeError, which propagates uncaught through main() and out of raise SystemExit(main(...)) as an unhandled exception — printing a traceback to stderr, emitting no JSON to stdout, and exiting non-zero.

This violates two stated invariants: "exit code is always 0" and "the single-line JSON on stdout is the whole contract." The docstring for both SKILL.md edits instructs the model to parse effective from probe output; when output is absent, behavior falls back to the model's own judgment, which is exactly the ambiguity this PR was written to eliminate. The enforcement guard (--disk-hygiene-enabled hook arg) is unaffected, so the runtime kill-switch posture is unchanged — this is a reporting-layer correctness issue with a residual safety implication.

The Codex review flagged the same code location (P2, line 99 of the second review pass). This confirms it.

Fix: extend the try/except to wrap the .get() call, or add an isinstance guard before it:

if not isinstance(settings, dict):
    return _report(
        True,
        "indeterminate",
        True,
        f"{settings_path} is valid JSON but not an object ({type(settings).__name__}); "
        "assuming the default (enabled). This is an assumption, not the configured value.",
        settings_path,
        [],
    )
plugin_configs = settings.get("pluginConfigs")

Fix this →


Other surfaces examined — no findings

  • Command injection via probe: No subprocess calls. Reads settings_path.read_text()json.loads()json.dumps() to stdout only. No injection surface exists.
  • Path traversal via CLAUDE_CONFIG_DIR: Joined only with the literal "settings.json". The probe is read-only; no write path exists regardless of where CLAUDE_CONFIG_DIR points.
  • --settings-file injection from the LLM: The guard rejects any token count other than 2 (len(tokens) != 2), so --settings-file cannot be appended to the guarded command shape from an LLM tool call. The flag is only reachable by calling the script directly (test harness, CLI).
  • Prompt injection via detail field: Values from matched entries appear in the detail string via json.dumps(...), which JSON-encodes them. The LLM receives structured JSON, not free text. Non-boolean values trigger the indeterminate degraded path before they appear in detail. Risk is negligible.
  • assert effective is not None (line 149): Logically unreachable given prior None in values check, but if reached, AssertionError would also violate the exit-0 / single-line-JSON contract. Low-risk since the logical guarantee is solid, but both contracts are fragile to unexpected code paths — the isinstance guard fix above reduces the category of surprising exits.
  • CI/Actions files: No workflow files changed in this PR.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

🔒 merge-drive lane claiming this PR for on-branch fix work this cycle (amendment-round: 16, worker tier). Will fix clear branch-owned CI/bot findings, defer non-DEFER-FORBIDDEN items with reply+resolve, push, then re-check the merge gate.

kyle-sexton and others added 2 commits July 22, 2026 12:18
…om an unexpanded body token

setup check step 4 emitted `${user_config.disk_hygiene_enabled}` in the
skill body with the rule "unexpanded or empty means default true", so a
configured false (audit-only mode) whose token failed to expand was
misreported as enabled — a false-negative on the one safety-critical
setting the check exists to verify. The clean skill's audit-only
instruction carried the same "unset = enabled" assumption.

Add a report-only, stdlib-only probe
(skills/setup/scripts/kill_switch_probe.py) that reads
pluginConfigs[<plugin-id>].options.disk_hygiene_enabled from the user
settings.json (CLAUDE_CONFIG_DIR-aware) and emits one JSON line with the
effective boolean, its provenance (configured/default/indeterminate), a
degraded flag, and the matched entries. Unparsable settings, invalid
value types, and conflicting entries degrade honestly — assumed default
true is always labeled an assumption, never the configured value.

setup check now reports the probe result with provenance and treats the
body token as at most a cross-check whose contradiction is reported.
clean resolves an unexpanded token through the same probe; the guard's
Bash allowlist permits exactly the argument-free bundled probe shape
(any argument, bare python, or another path stays denied). Enforcement
remains the guard's runtime-substituted --disk-hygiene-enabled hook
argument (0.4.4).

Docs verified this session: plugins-reference documents skill-content
substitution for non-sensitive user_config values, but a live run
observed the token unexpanded, so body expansion cannot be load-bearing
for a safety report.

Closes #1007

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… index

CI's shebang-executable gate requires 100755 for files with a shebang.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 24s —— View job


PR Review: fix(disk-hygiene): setup check reports kill switch deterministically

Todo list:

  • Gather context and understand the request
  • Read kill_switch_probe.py for correctness and contract violations
  • Read destructive_guard.py changes for security posture
  • Read test files and SKILL.md changes
  • Verify whether prior review findings were addressed in latest commits
  • Post findings

Context

The latest push (7cc6d57) is a mode-change only commit — it marks shebanged probe scripts executable in the git index. No code changed. The findings from prior reviews remain unaddressed.


One Finding Still Needs a Fix Before Merge

AttributeError on non-dict JSON root violates the exit-0 contract

kill_switch_probe.py line 99

try:
    settings = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
    return _report(...)           # only catches parse-level errors

plugin_configs = settings.get("pluginConfigs")   # ← AttributeError if root is [], "str", 42, null

When settings.json is syntactically valid JSON but its root is not an object (e.g. [], 42, "string", null), json.loads succeeds and bypasses the except block. The .get() call on line 99 then raises AttributeError, which propagates through main() uncaught, prints a traceback to stderr, emits no JSON to stdout, and exits non-zero — violating both stated invariants: "exit code is always 0" and "the single-line JSON on stdout is the whole contract."

The clean skill falls back to the probe precisely when its body token is unexpanded; if the probe produces no output, the fallback has no signal, reproducing the ambiguity this PR was built to eliminate.

Fix — add an isinstance guard immediately after parsing:

if not isinstance(settings, dict):
    return _report(
        True,
        "indeterminate",
        True,
        f"{settings_path} is valid JSON but not an object "
        f"({type(settings).__name__}); assuming the default (enabled). "
        "This is an assumption, not the configured value.",
        settings_path,
        [],
    )
plugin_configs = settings.get("pluginConfigs")

A test for this path is also missing — write_settings([]) should assert source == "indeterminate" and degraded == True.

Fix this →


Open Suggestions (Carried From Prior Reviews)

These are non-blocking but remain open.

1. assert effective is not None can violate the exit-0 contract

kill_switch_probe.py line 149

The assert is logically guaranteed not to fire (all None values are filtered out at line 125, and len(values) == 1 is the only path here). But assert exits nonzero on failure — which is the opposite of safe degradation on a probe that promises exit 0 always. Python's -O flag also silently skips asserts. A defensive return is more appropriate:

effective = values.pop()
if effective is None:
    return _report(True, "indeterminate", True,
        "Internal probe error; assuming default.", settings_path, entries)

2. zip(interpreted, entries) produces correct output but reads confusingly

kill_switch_probe.py line 131

{k: e['value'] for k, e in zip(interpreted, entries)}

zip(dict, list) iterates over dict keys, so k is a string key and e is an entry dict — this works because interpreted preserves insertion order from entries (Python 3.7+), but it reads as though k might be an interpreted value. The intent is clearer as:

{entry["key"]: entry["value"] for entry in entries}

3. None == None in is_exact_kill_switch_probe weakens fail-closed posture

destructive_guard.py line 307

return _script_path_key(tokens[1]) == _script_path_key(expected_script)

If the probe file is absent from the installed plugin (partial install, accidental deletion), _script_path_key(expected_script) returns None. Any tokens[1] that also fails resolve(strict=True) returns None, so None == None → True emits allow. Practical impact is nil — Python cannot execute a nonexistent file — but explicit guarding is consistent with the guard's fail-closed philosophy:

candidate_key = _script_path_key(tokens[1])
expected_key  = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

4. parents[2] cross-skill traversal deserves a comment

destructive_guard.py line 305

parents[2] from skills/clean/scripts/ resolves to skills/. A one-line comment (# skills/clean/scripts/ → skills/) would make this immediately verifiable without counting directory levels.


What's Well Done

The overall design is sound and addresses a real safety-reporting flaw:

  • Probe scope discipline: explicitly documents that managed settings and --settings layers are out of scope and states which path was probed so the caller can judge the claim
  • Fail-safe throughout: every degraded branch returns effective: true; a reporting failure never silently disables the guard
  • Guard allowlist is tight: is_exact_kill_switch_probe rejects arguments, bare python, wrong path — consistent with the existing fail-closed Bash lane
  • Test coverage: 13 probe tests cover the full decision tree; 4 guard tests cover both allow-paths and both deny-paths
  • Version bump: 0.4.6 → 0.5.0 appropriate; CHANGELOG clearly attributes the fix

Verdict

One fix is needed before merge: the AttributeError on a non-dict JSON root (finding above). The remaining items are suggestions — none block independently, but they bear on the "exit 0 always" contract that makes the probe load-bearing.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

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


Security Review — PR #1012

Todo list:

  • Gather context and understand the request
  • Fetch PR diff and identify changed files
  • Read kill_switch_probe.py — injection surfaces, path traversal, output guarantees
  • Read destructive_guard.py — allowlist logic, path key comparison, None == None path
  • Read SKILL.md files — prompt injection surfaces in skill bodies
  • Verify test files for security-relevant gaps
  • Synthesize and post findings

No CRITICAL findings.

The design posture is sound: the probe is stdlib-only and strictly read-only (no subprocess, no writes), the guard uses _literal_shell_words to reject any shell meta-characters before token inspection, and _is_current_python requires the exact absolute hook interpreter path as the first token — the strongest guarantee against command substitution from an LLM-controlled string. The enforcement kill switch (--disk-hygiene-enabled hook arg, resolved in resolve_disk_hygiene_enabled) is completely separate from the probe and unaffected by any of the issues below.


SUGGESTION — None == None in is_exact_kill_switch_probe allows any two-token command where both paths are unresolvable

Confidence: High that the condition is reachable; low that it is exploitable.

destructive_guard.py L304–307

expected_script = str(
    Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
)
return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key (L97–103) returns None on any OSError from resolve(strict=True). If the probe file is absent from the installed plugin (partial install, accidental deletion, or corrupted cache), _script_path_key(expected_script) returns None. Any tokens[1] that also fails strict resolution (a broken symlink, a nonexistent path, a permission-denied traversal) likewise returns None, making None == None → True and emitting allow.

Practical impact is negligible: _is_current_python(tokens[0]) still requires the exact absolute hook interpreter path, and Python cannot execute a file whose path fails resolve(strict=True) — the command itself fails safely at execution time. However, the guard's stated posture is fail-closed, and this is an explicit open-allow path when the plugin is corrupted.

The identical pattern exists in the pre-existing classify_exact_engine_command (the apply gate), but the consequence there — permitting an apply-shaped command when the engine script file is absent — is more severe; hardening is_exact_kill_switch_probe first would at minimum be consistent.

Fix:

candidate_key = _script_path_key(tokens[1])
expected_key  = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

Fix this →


SUGGESTION — AttributeError on non-dict settings.json root violates the probe's output contract

Confidence: High (the crash path is real and easily triggered; security impact is indirect).

kill_switch_probe.py L86–99

try:
    settings = json.loads(settings_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
    return _report(...)   # catches unparsable JSON only

plugin_configs = settings.get("pluginConfigs")   # AttributeError if root is [], "str", 42, null

When settings.json is valid JSON but its root is not an object (e.g. [], "string", 42, null), json.loads succeeds and the except clause is never entered. settings.get(...) then raises AttributeError, which propagates uncaught through probe() and main(), printing a traceback to stderr, emitting no JSON to stdout, and exiting non-zero.

This violates both stated contracts: "exit code is always 0" and "the single-line JSON on stdout is the whole contract." The clean skill is instructed to honor the probe's effective value and fall back to "proceed as enabled" only on degraded: true — but when the probe crashes it emits nothing, so the skill receives no signal at all and falls back to the model's own judgment, which is the exact ambiguity this PR was written to eliminate. The enforcement guard (--disk-hygiene-enabled hook arg) is unaffected; this is a reporting-layer reliability issue.

Fix:

if not isinstance(settings, dict):
    return _report(
        True,
        "indeterminate",
        True,
        f"{settings_path} is valid JSON but not an object "
        f"({type(settings).__name__}); assuming the default (enabled). "
        "This is an assumption, not the configured value.",
        settings_path,
        [],
    )
plugin_configs = settings.get("pluginConfigs")

A test case (write_settings([]) → assert source == "indeterminate" and degraded == True) should accompany the fix.

Fix this →


Other surfaces examined — no findings

  • Command injection in probe: No subprocess calls. The script performs settings_path.read_text()json.loads() → analysis → json.dumps() to stdout only. No injection surface.
  • Path traversal via CLAUDE_CONFIG_DIR: Joined only with the literal "settings.json". The probe is read-only; no write path exists regardless of where CLAUDE_CONFIG_DIR points.
  • --settings-file injection from the LLM: The guard rejects any token count other than 2 (len(tokens) != 2), so --settings-file cannot be appended to the guarded command shape from an LLM tool-use path. The flag is only reachable by calling the script directly (test harness, CLI).
  • Prompt injection via detail field: Values from matched entries appear in the detail string via json.dumps(...), which JSON-encodes them. Non-boolean values trigger the indeterminate degraded path before appearing in detail. Negligible risk.
  • Probe allowed when enabled=False (audit-only): Intentional and correct — the probe is read-only and querying the kill switch must work in audit-only mode. Explicitly covered by test_guard_allows_kill_switch_probe_in_audit_only_mode.
  • assert effective is not None (L149): Logically unreachable given prior None in values filter. The assert violation risk is acknowledged in prior reviews; it is not a security issue given the logical guarantee, though it shares the same exit-0 contract fragility as the non-dict root issue above.
  • SKILL.md ${CLAUDE_PLUGIN_ROOT} template: Framework variable set by the harness, not user-controllable; joined with a literal probe path. No injection surface.
  • CI/Actions files: No workflow files changed in this PR.

… its user-settings-only scope

A settings.json whose root is valid JSON but not an object (e.g. []) hit
.get() and crashed the probe with AttributeError, breaking the promised
single-line-JSON contract; it now degrades to indeterminate like any
other unreadable settings document. Default-source reports also state in
the report itself that managed settings or a --settings flag could carry
a value the probe cannot see, instead of leaving that caveat to the
skill prose alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 57s —— View job


PR Review: fix(disk-hygiene): setup check reports kill switch deterministically

Todo list:

  • Gather context and understand the request
  • Check what changed in the latest commit (e0cab40)
  • Read kill_switch_probe.py — verify findings from prior reviews are addressed
  • Read destructive_guard.py — verify None == None and parents[2] findings
  • Check test files for new test coverage
  • Synthesize findings and post review

Context

Latest commit e0cab40 ("probe survives non-object settings roots and names its user-settings-only scope") is the first code change since the last two review cycles. Prior reviews (7cc6d57) all identified the same blocking finding: an AttributeError crash on a non-dict settings.json root. This commit addresses it.


Blocking Finding — Now Fixed

AttributeError on non-dict JSON rootRESOLVED

The fix chosen wraps the isinstance check inside the existing try block, raising ValueError for a non-object root, then catching ValueError alongside the other I/O exceptions:

try:
    settings = json.loads(settings_path.read_text(encoding="utf-8"))
    if not isinstance(settings, dict):
        raise ValueError("settings root is not a JSON object")
except (OSError, UnicodeDecodeError, ValueError) as exc:
    return _report(True, "indeterminate", True, ...)

Two notes on the approach:

  1. json.JSONDecodeError removed from the except clause — This is safe: json.JSONDecodeError is a subclass of ValueError, so the broader catch still covers it. No regression.

  2. detail message — The error message for the non-dict case reads "Could not read the configured toggle from {path} (settings root is not a JSON object); assuming...". Technically "could not read" is imprecise (the file was read fine, but the root was wrong type). Not a contract violation, but slightly misleading. The suggested approach used type(settings).__name__ in the message to be more precise. Minor; not a blocker.

A dedicated test (test_non_object_settings_root_degrades_without_crashing) with write_settings([]) is added and correctly asserts source == "indeterminate" and degraded == True. ✅


Scope Documentation — Also Addressed

Both the "no settings file" and "no entry" default-report paths now append to their detail:

"Managed settings or a --settings flag could still carry a value this probe cannot see."

A corresponding test (test_default_reports_name_their_user_settings_only_scope) asserts "cannot see" appears in detail for both the missing-file and no-entry cases. This directly addresses the Codex P2 finding and the Codex reviewer's "Mark non-user configuration as indeterminate" comment (which asked for honest scope labeling — the PR chose honest labeling over changing source to "indeterminate", which is reasonable: absence from user settings genuinely is "default" for this probe; the caveat is now explicit). ✅


Open Suggestions (Carried From Prior Reviews — Non-Blocking)

These were raised in prior review cycles and remain unaddressed. None block merge independently.

1. assert effective is not None can violate the "exit 0 always" contract

kill_switch_probe.py L153

The assert is logically guaranteed not to fire (all None values are filtered at L129, and len(values) == 1 is the only path here). But assert exits nonzero on failure and is silently skipped under python -O — the opposite of safe degradation for a probe that promises "exit code is always 0." A defensive return would be more robust:

effective = values.pop()
if effective is None:
    return _report(True, "indeterminate", True,
        "Internal probe error; assuming default.", settings_path, entries)

Fix this →

2. zip(interpreted, entries) is confusing

kill_switch_probe.py L135

{k: e['value'] for k, e in zip(interpreted, entries)}

zip(dict, list) iterates over dict keys, so k is a key string and e is an entry dict — correct (insertion-order preserved in Python 3.7+) but reads as though k might be an interpreted bool value. Clearer equivalent:

{entry["key"]: entry["value"] for entry in entries}

Fix this →

3. None == None in is_exact_kill_switch_probe weakens fail-closed posture

destructive_guard.py L307

return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key returns None on any OSError from resolve(strict=True). If the probe file is absent (partial install), both sides return None and the comparison emits allow. Practical impact is nil — Python cannot run an absent file — but explicit guarding is consistent with the guard's fail-closed philosophy:

candidate_key = _script_path_key(tokens[1])
expected_key  = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

Fix this →

4. parents[2] cross-skill traversal deserves a comment

destructive_guard.py L305

parents[2] from skills/clean/scripts/ resolves to skills/. A one-line comment (# skills/clean/scripts/ → skills/) would make this immediately verifiable without counting directory levels.


Verdict

Ready to merge. The blocking finding — AttributeError crash on a non-dict settings.json root — is fixed and has a test. The scope-limitation documentation is present in both detail messages and the probe docstring. The four remaining items above are non-blocking suggestions; all were raised in prior review rounds and the author may choose to defer them.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

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


Security Review — PR #1012

Todo list:

  • Gather context and understand the request
  • Fetch PR diff and identify changed files
  • Read kill_switch_probe.py at HEAD SHA — verify non-dict root fix, check injection surfaces
  • Read destructive_guard.py — verify None == None posture, check path key comparison
  • Read SKILL.md files — prompt injection surfaces in skill bodies
  • Review test files for security-relevant gaps
  • Synthesize and post findings

No CRITICAL or IMPORTANT findings.

The latest commit (e0cab40) resolves the previously-required fix: a non-object JSON root in settings.json now raises ValueError inside the existing try/except (OSError, UnicodeDecodeError, ValueError) block and degrades to indeterminate, preserving both the exit-0 and single-line-JSON contracts. A regression test (test_non_object_settings_root_degrades_without_crashing) covers the path. The enforcement kill-switch channel (runtime-substituted --disk-hygiene-enabled hook argument, resolved in resolve_disk_hygiene_enabled) is independent of the probe and unaffected by any of the items below.


SUGGESTION — None == None in is_exact_kill_switch_probe is an open-allow path when the probe file is missing

Confidence: High the condition is reachable; low that it is exploitable (nil practical impact).

destructive_guard.py L304–307

expected_script = str(
    Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
)
return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key returns None on any OSError from resolve(strict=True). If the probe file is absent from the installed plugin (partial install, deleted cache), _script_path_key(expected_script) returns None. An LLM-supplied tokens[1] that is also unresolvable — a broken symlink, a nonexistent path — also returns None, so None == None → True and the guard emits allow.

In practice, the impact is nil: _is_current_python still requires the exact absolute hook interpreter path, and Python cannot execute a file whose path fails resolve(strict=True). The allowed command (<hook-python> <unresolvable-path>) exits immediately with a file-not-found error and no side effects.

The identical != form of this pattern appears in the pre-existing classify_exact_engine_command at L224 (the apply gate), where the consequence of a corrupt install is more severe. Hardening is_exact_kill_switch_probe first would at minimum be consistent with the guard's stated fail-closed philosophy:

candidate_key = _script_path_key(tokens[1])
expected_key  = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

Fix this →


SUGGESTION — assert effective is not None can violate the exit-0 / single-line-JSON contract if asserts are disabled

Confidence: High the condition is reachable via -O; very low that it is exploitable.

kill_switch_probe.py L153

The assert is logically unreachable: by the time execution reaches line 152, None in values has already been filtered (line 129) and len(values) > 1 has already been filtered (line 141), leaving a set of exactly one non-None bool. The assert never fires under normal Python execution.

However, Python's -O flag compiles away all assert statements silently. Under -O, if the guarantee ever breaks (e.g., a future code change alters the filter logic and reintroduces a None path), values.pop() returns None, the _report() call at line 154 receives effective=None, and json.dumps({"effective": None, ...}) succeeds — but emits {"effective": null, ...}. The caller's if result["effective"] check would then treat null as falsy, silently misreporting the kill switch as disabled. This is the failure mode that matters on a safety-critical reporting path.

A defensive return is a stronger contract than an assert:

effective = values.pop()
if effective is None:
    return _report(
        True, "indeterminate", True,
        "Internal probe error; assuming default.", settings_path, entries
    )

Fix this →


Other surfaces examined — no findings

  • Non-dict settings.json root (prior blocker): Fixed in e0cab40. The if not isinstance(settings, dict): raise ValueError(...) guard at L88–89 feeds into the except (OSError, UnicodeDecodeError, ValueError) block and degrades cleanly.
  • Command injection in probe: No subprocess calls. The script performs settings_path.read_text()json.loads() → analysis → json.dumps() to stdout only.
  • Path traversal via CLAUDE_CONFIG_DIR: Joined only with the literal "settings.json". Probe is read-only; no write path exists regardless of where CLAUDE_CONFIG_DIR points.
  • --settings-file injection from LLM tool use: The guard rejects any token count other than 2 (len(tokens) != 2), so --settings-file cannot be appended to the guarded shape from the LLM tool-use path.
  • Prompt injection via detail field: Entry values from settings.json appear in detail via json.dumps(), which JSON-encodes them. Non-boolean values trigger the indeterminate path before appearing in detail. Risk negligible; operator controls their own settings file.
  • _matches_plugin prefix logic: key.startswith(f"{_PLUGIN_NAME}@") correctly excludes disk-hygiene-extras@foo (hyphen, not @) while accepting disk-hygiene@marketplace. Covered by test_unrelated_plugin_entry_does_not_match.
  • json.dumps(probe(...)) failure: entries are built from json.loads() output, which only produces JSON-serializable types. json.dumps() in main() cannot fail on this data.
  • SKILL.md template injection: ${CLAUDE_PLUGIN_ROOT} is a harness-controlled framework variable joined with a literal probe path. Not user-controllable from the LLM tool-use path.
  • CI/Actions files: No workflow files changed in this PR.

Confirmed fixed in e0cab40 (was the blocker in prior reviews)

  • AttributeError on non-dict settings.json root — fixed with isinstance guard + ValueError raise inside the existing try block. Test test_non_object_settings_root_degrades_without_crashing covers it.
  • Default-source reports omitting managed-settings scope — default and missing-file reports now carry the "Managed settings or a --settings flag could still carry a value this probe cannot see" caveat. Test test_default_reports_name_their_user_settings_only_scope asserts the caveat is present.

@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: e0cab40f53

ℹ️ 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/disk-hygiene/skills/setup/scripts/kill_switch_probe.py Outdated
…spected

Path.is_file() swallows OSError and returns False for existing
non-files, so an inaccessible settings.json (unsearchable ancestor) or a
directory at the settings path was reported as "no settings file,
default, not degraded" — a certainty the probe did not have. The probe
now stats the path itself: only FileNotFoundError keeps the clean
default report; any other OSError or a non-regular file degrades to
indeterminate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

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


PR Review: fix(disk-hygiene): setup check reports kill switch deterministically

Todo list:

  • Gather context and understand the PR
  • Read kill_switch_probe.py at HEAD (3eb21d7)
  • Read destructive_guard.py changes
  • Read test files and SKILL.md changes
  • Verify status of all prior review findings
  • Post final review

Summary

This PR correctly diagnoses and resolves a real safety-reporting flaw: the ${user_config.disk_hygiene_enabled} body token was not reliably expanded in skill content, so a configured false (audit-only mode) could be silently misreported as true. The probe-first architecture is sound — the body token becomes a cross-check rather than the authority, and enforcement (the guard's runtime-substituted --disk-hygiene-enabled hook arg) is untouched throughout.

The PR has gone through five review rounds. Each round addressed the blocking finding of that cycle. At HEAD (3eb21d7), all blocking findings across all rounds are resolved.


Latest Commit (3eb21d7) — Verified Fixed

Probe degrades when the settings path cannot be inspected

kill_switch_probe.py lines 76–108

The new stat()-first structure is clean and semantically correct:

  • FileNotFoundErrorsource: "default", degraded: false — no settings file is a clean absence, not an error
  • Other OSError (e.g. PermissionError on an ancestor directory) → source: "indeterminate", degraded: true — can't inspect, assume enabled
  • Not a regular file (directory at the path) → source: "indeterminate", degraded: true — exists but unreadable

Two regression tests added: test_directory_at_settings_path_degrades and test_uninspectable_settings_path_degrades. Both assert the correct degraded: true / source: "indeterminate" outcome. ✅

The uninspectable test uses mock.patch.object(probe.Path, "stat", side_effect=PermissionError("denied")) — this correctly intercepts the settings_path.stat() call because settings_path is a Path instance and the mock patches the class method in the probe's module namespace. ✅


Prior Findings — All Resolved

Finding Resolved in
AttributeError on non-dict JSON root e0cab40
Managed-settings scope not named in default-source details e0cab40
Non-file / uninspectable settings path not degraded 3eb21d7

Remaining Suggestions (Non-Blocking — All Carried From Prior Reviews)

These have been raised in prior review cycles without being addressed. None block merge; the prior four review rounds all reached "ready to merge" without them.

1. assert effective is not None can violate the exit-0 / single-line-JSON contract

kill_switch_probe.py line 176

The assert is logically unreachable (all None values are filtered at line 152, and len(values) > 1 is filtered at line 164, leaving a set of exactly one non-None bool). But assert exits non-zero on failure and is silently compiled away under python -O. If the guarantee ever breaks and effective is None, json.dumps({"effective": None, ...}) emits {"effective": null, ...} — falsy — which would misreport the kill switch as disabled, the wrong direction for a fail-safe probe. A defensive return is a stronger contract:

effective = values.pop()
if effective is None:
    return _report(True, "indeterminate", True,
        "Internal probe error; assuming default.", settings_path, entries)

Fix this →

2. zip(interpreted, entries) reads confusingly

kill_switch_probe.py line 158

{k: e['value'] for k, e in zip(interpreted, entries)}

zip(dict, list) iterates over dict keys — so k is a string key and e is an entry dict. Correct because interpreted preserves insertion order from entries (Python 3.7+), but reads as though k might be an interpreted bool. Clearer:

{entry["key"]: entry["value"] for entry in entries}

Fix this →

3. None == None in is_exact_kill_switch_probe weakens fail-closed posture

destructive_guard.py line 307

_script_path_key returns None on any OSError from resolve(strict=True). If the probe file is absent (partial install), both sides return None and None == None → True emits allow. Practical impact is nil — Python can't execute an absent file and _is_current_python still constrains the first token — but explicit guarding is more consistent with the guard's fail-closed philosophy:

candidate_key = _script_path_key(tokens[1])
expected_key  = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

Fix this →

4. parents[2] cross-skill traversal deserves a comment

destructive_guard.py line 305

parents[2] from skills/clean/scripts/ resolves to skills/. A one-line comment (# skills/clean/scripts/ → skills/) makes this immediately verifiable without counting directory levels.


What's Well Done

  • Probe scope discipline: never guesses at managed-settings or --settings layers; instead documents the path actually probed and carries the "cannot see" caveat in every default-source detail message — so the limitation travels with the JSON
  • Fail-safe posture throughout: every degraded branch returns effective: true; a reporting failure never silently disables the guard
  • Layered stat check (new in 3eb21d7): FileNotFoundError is correctly separated from other OSError — absence is a clean default, inaccessibility is a degraded assumption
  • Guard allowlist is tight: is_exact_kill_switch_probe rejects arguments, bare python, wrong path — consistent with the existing fail-closed Bash lane
  • 15 probe tests: missing file, configured false/true, bare key, unrelated key, no toggle in matched entry, string booleans, directory at path, uninspectable path, non-object root, default-scope labeling, unparsable JSON, invalid type, conflicting entries, consistent duplicates, CLAUDE_CONFIG_DIR honored, single-line JSON output
  • 4 guard tests: both allow-paths (enabled and audit-only) and both deny-paths (with arguments and bare python)
  • CHANGELOG and version bump: 0.5.0 → 0.6.0 appropriate for the new probe; [0.6.0] section accurately attributes both the new feature and the fix

Verdict

Ready to merge. All blocking findings from all five review rounds are resolved. Items 1–4 above are non-blocking suggestions that have been open since the first review round; the author may defer them.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 36s —— View job


Security Review — PR #1012

Todo list:

  • Gather context and understand the request
  • Fetch PR diff and identify changed files at HEAD
  • Read kill_switch_probe.py at HEAD — new path-stat logic, injection surfaces, output guarantees
  • Read destructive_guard.pyNone == None path key comparison, is_exact_kill_switch_probe
  • Read test files for security-relevant gaps
  • Synthesize and post findings

No CRITICAL or IMPORTANT findings.

The latest commit (3eb21d7) adds stat()-based path inspection before attempting to read settings.json. The design is sound: only FileNotFoundError produces a clean "default, not degraded" report; any other OSError (permission-denied ancestor, unusual filesystem error) degrades to indeterminate. An existing non-regular path (directory, device, named pipe) is also caught by the stat.S_ISREG() check before read_text() is attempted. This strictly improves the safety posture relative to the previous is_file() check and does not introduce new injection surfaces.

The enforcement kill-switch channel — runtime-substituted --disk-hygiene-enabled hook argument, resolved by resolve_disk_hygiene_enabled() in destructive_guard.py — is completely separate from this probe and unaffected by any of the issues below.


SUGGESTION — TOCTOU between stat() and read_text() in the probe

Severity: SUGGESTION · Confidence: High the race is reachable; very low that it is exploitable.

kill_switch_probe.py L76–110

try:
    settings_stat = settings_path.stat()    # ← check
except FileNotFoundError:
    ...
except OSError:
    ...
if not stat.S_ISREG(settings_stat.st_mode):
    ...
try:
    settings = json.loads(settings_path.read_text(encoding="utf-8"))  # ← use

Between stat() (check) and read_text() (use), a race exists. Concretely: if an attacker replaces settings.json with a symlink between the two calls, read_text() follows the symlink and reads the symlink target. On most platforms read_text() cannot differentiate a regular file from a symlink to one.

The practical impact is nil for three independent reasons:

  1. Probe is report-only. The enforcement kill switch is the runtime-substituted --disk-hygiene-enabled hook argument in destructive_guard.py, not the probe report. A misreport never disables or bypasses the enforcement channel.
  2. Read failure degrades safely. If read_text() raises (e.g., symlink to a device that blocks), the except (OSError, UnicodeDecodeError, ValueError) block degrades to effective: true, degraded: true — the safe-default posture.
  3. CLAUDE_CONFIG_DIR/~/.claude/settings.json is the user's own file. An attacker who can replace it with an arbitrary symlink in that directory already has full write access to the user's Claude configuration — far beyond what the probe controls.

Worth documenting in the probe docstring for completeness, but no code change needed given the reporting-only role and independent enforcement channel.


SUGGESTION — None == None in is_exact_kill_switch_probe is an open-allow path in a corrupted install

Severity: SUGGESTION · Confidence: High the condition is reachable; nil practical exploitability.

destructive_guard.py L304–307

expected_script = str(
    Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
)
return _script_path_key(tokens[1]) == _script_path_key(expected_script)

_script_path_key (L97–103) returns None on any OSError from resolve(strict=True). If the probe file is absent from the installed plugin (partial install, deleted cache, corrupted plugin directory), _script_path_key(expected_script) returns None. An LLM-supplied tokens[1] that also fails strict resolution — a nonexistent path, a broken symlink, a permission-denied traversal — likewise returns None, so None == None → True emits allow.

Impact is nil: _is_current_python(tokens[0]) still requires the exact absolute hook interpreter, and Python cannot execute a file whose path fails resolve(strict=True). The allowed command (<hook-python> <unresolvable-path>) fails immediately with a file-not-found error and no side effects.

The identical pattern (!=) exists in the pre-existing classify_exact_engine_command at L224, where the consequence (an apply-gate bypass in a corrupted install) is more severe. Explicit guarding in is_exact_kill_switch_probe would be more consistent with the guard's stated fail-closed philosophy:

candidate_key = _script_path_key(tokens[1])
expected_key  = _script_path_key(expected_script)
return candidate_key is not None and candidate_key == expected_key

Fix this →


Other surfaces examined — no findings

  • Non-regular file at settings path (3eb21d7 addition): stat.S_ISREG() correctly rejects directories, named pipes, and device files before read_text() is attempted. The check uses the result of a prior stat() call, not a string test on the path, so it cannot be confused by filenames containing "reg" or similar.
  • FileNotFoundError vs OSError split (3eb21d7 addition): The separation is correct — FileNotFoundError is a subclass of OSError; placing it first in a separate except clause catches it before the broader OSError handler, giving it distinct degradation semantics. No exception class ambiguity.
  • Command injection in probe: No subprocess calls. The script performs settings_path.stat()read_text()json.loads() → analysis → json.dumps() to stdout only. No injection surface.
  • Path traversal via CLAUDE_CONFIG_DIR: Joined only with the literal "settings.json". The probe is read-only; no write path exists regardless of where CLAUDE_CONFIG_DIR points.
  • --settings-file injection from LLM tool use: The guard rejects any token count other than 2 (len(tokens) != 2), so --settings-file cannot be appended to the guarded command shape from the LLM tool-use path.
  • sorted(plugin_configs) key iteration: Pure iteration over parsed JSON keys with prefix filtering. No injection surface; no eval or dynamic dispatch.
  • detail field content with entry values: Entry values from settings.json appear in detail via json.dumps(), which JSON-encodes them. Non-boolean values trigger the indeterminate path before appearing in detail. Negligible injection risk; operator controls their own settings file.
  • Probe allowed when enabled=False (audit-only): Intentional and correct — the probe is read-only and querying the kill switch must work in audit-only mode. Covered by test_guard_allows_kill_switch_probe_in_audit_only_mode.
  • assert effective is not None (L176): Logically unreachable — None in values exits early at L152, leaving only non-None bools in values. Under -O the assert is skipped, but effective is still a guaranteed bool; json.dumps({"effective": None}) cannot be reached.
  • CI/Actions files: No workflow files changed in this PR.

@kyle-sexton
kyle-sexton merged commit 6b66c69 into main Jul 22, 2026
25 checks passed
@kyle-sexton
kyle-sexton deleted the fix/1007-setup-check-kill-switch-report branch July 22, 2026 16:35
kyle-sexton added a commit that referenced this pull request Jul 22, 2026
Reconcile disk-hygiene conflicts from concurrent main advances (#1010, #1012,
#1028, #1031) that landed while this branch was open.

- plugin.json / CHANGELOG: restack this branch's fix onto main's version line,
  0.4.8 -> 0.6.3 (next patch above main's landed 0.6.2); preserve every landed
  CHANGELOG entry (0.6.2 / 0.6.1 / 0.6.0 / 0.5.0) with this branch's entry on top.
- clean/SKILL.md: compose #983's corrected kill-switch framing (a skill-scoped
  hook receives neither ${user_config.*} nor CLAUDE_PLUGIN_OPTION_*, so the guard
  cannot enforce audit-only) with #1012's deterministic kill_switch_probe read,
  dropping main's stale "guard enforces via --disk-hygiene-enabled" claim that
  #983's hook-arg change (only --plugin-root) invalidated.
- clean/reference/safety-model.md: keep #983's --data-root derivation-from-
  plugin-root rewrite and append #1010's --confirmed-large-scan grammar note.

destructive_guard.py and test_hygiene.py auto-merged (data-root derivation +
--confirmed-large-scan + kill_switch_probe allowlist + MIN_PYTHON floor); full
suite 98 passed, 4 platform-skipped.
kyle-sexton added a commit that referenced this pull request Jul 22, 2026
Freshen branch with latest main (disk-hygiene updates #1010, #1012, #1028,
#1031). No overlap with toolchain plugin changes; no conflicts.
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.

fix(disk-hygiene): setup check must not report the kill switch from an unexpanded body token

1 participant