Skip to content

fix(disk-hygiene): detect WindowsApps python3 alias stub fail-open in setup check#1124

Draft
kyle-sexton wants to merge 2 commits into
mainfrom
fix/1110-disk-hygiene-python3-alias-stub-failopen
Draft

fix(disk-hygiene): detect WindowsApps python3 alias stub fail-open in setup check#1124
kyle-sexton wants to merge 2 commits into
mainfrom
fix/1110-disk-hygiene-python3-alias-stub-failopen

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Closes a fail-open vector in the disk-hygiene destructive-action guard on Windows. The clean skill registers its PreToolUse guard as the literal hook command python3 (skills/clean/SKILL.md). On stock Windows, python3 resolves to %LOCALAPPDATA%\Microsoft\WindowsApps\python3.exe — a zero-length App Execution Alias reparse stub that opens the Microsoft Store (or exits) instead of running an interpreter. The guard process never starts, so it emits neither of the two signals that let a PreToolUse hook block a tool call — exit code 2 or a deny decision — and Claude Code lets the destructive Bash/PowerShell command proceed ungated. This is the same fail-open shape as the 0.6.3 fix (a failed hook launch treated as non-blocking), reached through a new vector: the guard's launch name resolving to the Store stub rather than a real interpreter.

This is a DEFER-FORBIDDEN fail-open gap in a safety guard — detection, tests, and docs are all part of the fix, not follow-ups.

Fix

  • New inspect-only probe skills/setup/scripts/python3_alias_probe.py: classifies what the name python3 resolves to (via shutil.which, --path override for tests) without executing it (running the stub pops the Store / hangs). Verdicts: store-alias-stub (zero-length file under a WindowsApps path component), ok (real interpreter — including the Store's genuine Python under a versioned WindowsApps\PythonSoftwareFoundation...\ subdir, which has non-zero size), not-found, indeterminate (identity unreadable). On Windows it also reports the IO_REPARSE_TAG_APPEXECLINK reparse attribute as corroborating evidence, but the cross-platform verdict uses the portable zero-length + WindowsApps-component signal.
  • setup check (SKILL.md step 1) now runs the probe after locating the working interpreter and fails closed on every verdict except ok: store-alias-stub and indeterminate both FAIL with remediation (disable the python3 App execution alias, or install real Python ahead of WindowsApps on PATH); not-found folds into the floor's absent-interpreter FAIL. A bare command -v python3 success is explicitly called out as insufficient — it matches the stub too.
  • README requirements section documents the vector and why it fails open.
  • Version bump 0.6.50.6.6 in plugin.json + matching CHANGELOG.md entry. (Merged origin/main in after PR fix(disk-hygiene): require immediate re-enumeration before container-wide manual deletions #1118 landed the fix(disk-hygiene): manual-handoff lane — require immediate re-enumeration before container-wide deletions (F2) #1108 fix as 0.6.5; re-based the bump to 0.6.6 and reordered the CHANGELOG entry above 0.6.5. Merge, not rebase, per branch policy.)

Verification

  • New unit tests test_python3_alias_probe.py (10 cases, run via python3_alias_probe.test.sh) — all pass. They fabricate a zero-length .../WindowsApps/python3.exe in a tempdir so detection is exercised cross-platform without a real Windows box: stub detected; case-insensitive WindowsApps match; real interpreter under a versioned WindowsApps subdir → ok (no false positive); zero-length outside WindowsApps → ok; not-found; the indeterminate stat-failure path (fail-closed); and an assertion that the probe never invokes subprocess on the candidate.
  • Full plugin suites green post-merge: setup probe 10/10, kill_switch_probe unaffected, clean engine test_hygiene.py 112 passed (4 skipped).
  • markdownlint-cli2, shellcheck, and typos clean on all changed files; new scripts tracked 100755 (exec-bit lane parity with the existing kill_switch_probe.*).
  • Empirical signal confirmation (this Windows 11 box): inspected %LOCALAPPDATA%\Microsoft\WindowsApps — all 35 App-Execution-Alias .exe stubs are Length 0 with the ReparsePoint attribute set, confirming the zero-length detection signal against ground truth (not asserted from memory).
  • Hook-failure-semantics claim verified live against current docs (fetched this session): code.claude.com/docs/en/hooks — "Exit 2PreToolUse blocks the tool call"; "Any other exit code is a non-blocking error … Execution continues"; PreToolUse can alternatively block via exit-0 JSON permissionDecision: deny. A guard that never launches emits neither exit 2 nor a deny, so the non-blocking path is taken — the doc-grounded basis for the fail-open, stated as that logical consequence rather than as a documented launch-failure rule.

Related

  • Source: handoff-inbox item 20260723-021058-disk-hygiene-0-6-4-consumer-audit, finding F3.
  • Root cause note (out of scope here): the terminal root cause is the guard hook using the bare name python3 (skills/clean/SKILL.md), which changing the hook's launch command would address — that is guard-registration territory (operator-gated, fix(disk-hygiene): implement guard registration decision — kill-switch delivery + data-root authority (F1/F5) #1107), so this PR is the assigned mitigation (detect + document + fail closed in setup check), not a change to the guard's registration.
  • Deferred low-risk items surfaced by independent review, none blocking: python3_alias_probe.test.sh's command -v python3 fallback could itself resolve to a Store stub on a stub-only machine (kept at parity with the sibling kill_switch_probe.test.sh wrapper rather than diverging); a symlink whose own path is outside WindowsApps but whose target is inside would evade the path-component check (AppExecLink aliases are reparse points, not symlinks, so not the real-world shape).

Closes #1110

kyle-sexton and others added 2 commits July 23, 2026 05:19
… setup check

The clean skill's destructive-action guard hook launches the literal command
`python3`. On stock Windows that name resolves to a zero-length
`WindowsApps\python3.exe` App Execution Alias stub that opens the Microsoft
Store instead of running an interpreter, so the guard process never starts. A
PreToolUse hook blocks a tool call only by emitting exit code 2 or a `deny`
decision; a guard that never runs emits neither, so the destructive
Bash/PowerShell command proceeds ungated — a silent fail-open in a safety guard.

setup check now runs a bundled inspect-only probe (python3_alias_probe.py) that
classifies the python3 resolution — zero length under a WindowsApps path
component is the stub — without executing it. The skill fails closed on every
verdict except `ok`: `store-alias-stub` and `indeterminate` (identity
unreadable) both FAIL, `not-found` folds into the floor's absent-interpreter
FAIL. Adds cross-platform unit tests (including the stat-failure indeterminate
path), a README requirements gotcha, and bumps the plugin to 0.6.5 with a
CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the disk-hygiene version/CHANGELOG collision with #1108 (PR #1118),
which merged to main as 0.6.5. Re-bump this branch's fix to 0.6.6 and reorder
the #1110 CHANGELOG entry above the merged 0.6.5 entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kyle-sexton kyle-sexton added area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. automated Opened by automation. labels Jul 23, 2026
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@github-actions

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

🔒 babysit-prs lane claiming this PR for on-branch fix work this cycle (amendment-round: 16, safe tier). Will fix clear branch-owned findings and push; will not resolve threads or merge (safe tier).

kyle-sexton added a commit that referenced this pull request Jul 23, 2026
…boundary, doc polish (F7/F9/F10) (#1148)

## Summary

dh **0.8.1** — the consumer-audit declarative/doc batch (findings F7,
F9, F10). Docs-only; no engine or guard code changes.

- **F7 — `--execute` contract for the manual lane (decision recorded,
not silently resolved).** The flag now explicitly gates every deletion
lane: the gated engine lane where supported, the manual handoff
elsewhere. This is named in the doc as a *deliberate semantic
unification* — the flag previously read as "offer the gated ENGINE lane"
(which can never apply on Windows/macOS), leaving the manual lane's gate
ambiguous; the audit observed consumer sessions reading it both ways,
including one that proceeded to manual deletion without `--execute`. The
issue's adversarial-review comment asked that the semantic shift be
named rather than passed off as clarification — done at the argument
definition and in the manual-handoff precondition (lane symmetry, per
the issue's recommendation).
- **F9 — hint-prose trust boundary.** Safety-model trust boundaries now
name standing-policy `additional_hints[].reason` prose as untrusted
claims requiring independent evidence: additive-only design means hints
can't authorize anything, but the prose reached triage reasoning
unlabeled.
- **F10(a).** Setup SKILL.md + README corrected: `preview` *reports
`execution-platform-unsupported` as a per-candidate blocker*, not a
top-level status — verified against `preview()`'s payload shape
(blockers live in `candidates[].blockers`; top-level status is
`blocked`/`ready-for-explicit-approval`).
- **F10(b).** README states once that the Recycle-Bin / Trash naming is
a model-layer distinction only; the engine treats Windows and macOS
identically (execution unsupported).
- **F10(c) — DECLINED with evidence.** The hub-restructure suggestion
rested on an MD013 line-length complaint; the repo's
`.markdownlint-cli2.jsonc` sets `"MD013": false` ("No hard line-length
limit"), so the complaint came from an out-of-repo lint run, and the
skill-quality gate passes the hub at its current length.

**Scheduling note:** #1113 was queued behind draft PR #1124 (file
overlap on setup SKILL.md / README / CHANGELOG). That draft has been
stale for ~4h with CI green; the actual hunk overlap is disjoint
(different sections), so this batch proceeds — whichever merges second
rebases trivially (CHANGELOG entry ordering + version bump).

## Test plan

- [x] `check-changelog-parity.sh --check-bump origin/main` → pass (0.8.1
+ entry)
- [x] `check-changed-skills.sh` → 2 skills checked, 0 failed
- [x] Docs-only: no engine tests affected (suite untouched)
- [ ] CI green

## Related

Closes #1113. Source: handoff-inbox item
`20260723-021058-disk-hygiene-0-6-4-consumer-audit`, findings F7/F9/F10.
F7 decision context: the adversarial-review comment on #1113.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

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


PR Review

  • Gather context (PR body, changed files list)
  • Read changed files and analyze
  • Review probe script correctness and security
  • Review test coverage
  • Review SKILL.md changes and documentation
  • Review plugin.json / CHANGELOG / README updates
  • Post final review findings

Summary

This is a correct, well-structured fix for a real fail-open gap. The detection logic is sound, the fail-closed semantics are consistently applied, and the test suite is thorough. Three minor findings below — none blocking.


python3_alias_probe.py — Core Logic

Correct and safe. The two-signal approach (WindowsApps path component AND zero-length) is the right cross-platform proxy; it avoids executing the stub, doesn't require Win32 APIs, and the reparse attribute is correctly treated as corroborating evidence rather than the verdict. The indeterminate path on stat failure fails closed as required.

One edge case worth noting: when --path is given to a path that doesn't exist, path.stat() raises OSErrorindeterminate verdict instead of not-found. The not-found branch is only reached when path is None (i.e. shutil.which returned nothing). This is not a runtime issue since --path is only used in tests with controlled paths, but it creates a slight semantic gap:

# python3_alias_probe.py:75 — path is None only when shutil.which returned nothing
def classify(path: Path | None) -> dict[str, object]:
    if path is None:      # only the "not on PATH" case
        return _report("not-found", ...)

A non-existent explicitly-provided path takes the stat-failure branch and returns indeterminate. For the test at line 112, the path is never created and stat is patched — the test is correct, but the behavior if someone ran --path /nonexistent unpatched would silently be indeterminate rather than not-found. Not a bug in practice. Fix this →

Security: No subprocess calls, no network, no file writes — purely shutil.which + path.stat(). Clean.


test_python3_alias_probe.py — Test Coverage

Comprehensive. 10 cases covering the expected failure modes, the false-positive guards, and the no-subprocess invariant. A few observations:

  • The case-insensitive WindowsApps match (WINDOWSAPPS) at line 66 is correct and important; confirmed by the lower() in _under_windowsapps.
  • The test_real_interpreter_under_windowsapps_is_ok at line 71 correctly guards against false-positives for the Microsoft Store's genuine Python (non-zero size, versioned subdirectory).
  • test_zero_length_outside_windowsapps_is_ok at line 83 correctly returns ok — a zero-length file outside WindowsApps would fail the Python version check in step 1 anyway, so the probe doesn't need to duplicate that gate.
  • test_probe_never_executes_the_candidate at line 126 mocks subprocess.run and subprocess.Popen but not os.system/os.popen/os.execv. Since the probe doesn't import os at all, this is theoretically fine, but the coverage claim is narrower than the comment implies. Not a real gap, but if the probe source ever changed to import os, this test wouldn't catch accidental execution via those paths.

python3_alias_probe.test.sh — Shell Wrapper

The wrapper at line 16–23 tries python first, then python3. As noted in the PR, command -v python3 can itself resolve to the Store stub on a stub-only Windows machine, causing the entire test suite to silently fail to run. This is at parity with kill_switch_probe.test.sh, and on such a machine the tests can't run regardless (you need a real Python to run them), so it's not a correctness issue — but the error message SKIP: Python ${FLOOR}+ not found at line 22 would be the output, which is accurate enough.


SKILL.md — Probe Integration

The new check text at lines 42–56 is clear and correctly wired:

  • Uses <python> (the resolved step-1 interpreter) to invoke the probe — avoids the exact problem being fixed (running python3 directly).
  • The verdict routing is correct: only ok passes; store-alias-stub and indeterminate both FAIL with the probe's detail as remediation; not-found folds into the floor's missing-interpreter FAIL.
  • The note "A bare command -v python3 success is not evidence on its own — it matches the stub too" at line 56 is an important guard against a naive model implementation.

One nuance: the instruction doesn't specify what the model should do if the probe script itself fails to execute (e.g., parse error, missing script). Since setup check is a model-driven skill, the model would presumably surface the error, but an explicit instruction to treat a failed probe run as FAIL (same as indeterminate) would close that final edge. Very minor.


plugin.json, CHANGELOG.md, README.md


Verdict

Approve. The fail-open vector is real, the fix is correctly scoped as detect + document + fail-closed in setup check, and the test suite fabricates the Windows-specific condition cross-platform so it's exercisable in CI. The three findings above (non-existent --path semantic gap, subprocess mock narrowness, probe-crash handling) are all non-blocking and can be addressed as follow-up if desired.

@kyle-sexton kyle-sexton added the needs-human Human-in-the-loop required; autonomous sessions must not resolve items carrying this. label Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. automated Opened by automation. needs-human Human-in-the-loop required; autonomous sessions must not resolve items carrying this.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(disk-hygiene): setup check detects Windows Store python3 alias stub fail-open (F3)

1 participant