← Về thư mục
📄 / / academic-research-skills / docs / design / 2026-06-17-454-windows-python-hook-portability-design.md

#454 — Windows Python hook portability + graceful no-Python degradation

Status: design (awaiting user review) Issue: #454 (ncwuguo) — ars_write_scope_guard.py crashes with exit 49, empty stderr, on Windows alongside RTK Branch: fix/454-windows-python-hook-portability Date: 2026-06-17

1. Problem

The PreToolUse write-scope guard hook is registered as:

{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/ars_write_scope_guard.py\"" }

On the reporter's Windows machine every Bash/Write/Edit tool call makes this hook fail (exitCode 49, empty stderr, log spam). The guard's own Python only ever return 0s, so the failure is at the interpreter-launch layer, before Python runs: python3 on Windows is commonly a 0-byte Microsoft Store App Execution Alias stub, not real Python.

Empirically established (first-party, Windows 11 VM)

Ground truth (official Claude Code hook docs, verified this session)

2. The deciding fact: ARS core does not require Python

Verified by repo inspection + independent codex/gemini fact-check: - Core skill use (research / write / review) requires NO Python. README prerequisites list only Claude Code + API key; there is a requirements-dev.txt but no requirements.txt. SKILL.md/agent files are prompt/markdown that Claude reads. - The guard hook is the sole auto-running Python at user runtime, and is an optional security hardening layer added in v3.10 (#134), not a core feature. - Nuance (codex catch — must stay honest): ARS is not 100% prompt-only. These opt-in / advanced features DO execute real Python when the user invokes them: - revision mode: scripts/ars_anchorize_draft.py, scripts/ars_apply_revision_patch.py - pipeline submission verifier: scripts/verify_submission_package.py - slash commands: /ars-cache-invalidate, /ars-mark-read, /ars-unmark-read These are user-triggered (not auto-running hooks), so they fail visibly and the user can choose to install Python. They are out of scope for this fix (tracked as follow-up, §6).

Consequence → Plan A (graceful degradation)

Forcing a Python install on a user whose ARS usage never needed Python (Plan B: fail-closed / exit 2) would turn an optional hardening layer into a global prerequisite, contradicting the setup docs and ARS's established "don't assume the environment has X; degrade if it's missing" principle (cf. #413 symlink→materialized copies for Windows). Both codex and gemini independently endorsed Plan A.

3. Design

3.1 A launcher that finds real Python (the fix for the bug body)

Add hooks/run_guard.sh (POSIX sh, Bash 3.2 compatible, same style/shape as the existing scripts/announce-ars-loaded.sh). Responsibilities:

  1. Resolve the plugin root from the launcher's OWN path, not ${CLAUDE_PLUGIN_ROOT} (codex P1). CC substitutes ${CLAUDE_PLUGIN_ROOT} into the hook command text before the shell, but that does NOT guarantee the variable is exported into the launcher's environment. So the launcher computes the guard path relative to itself: SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"; GUARD="$SELF_DIR/../scripts/ars_write_scope_guard.py". This must work when CLAUDE_PLUGIN_ROOT is unset AND when the plugin path contains spaces (both are test cases, §3.5).
  2. Detect a REAL Python interpreter by trying candidates in order. Each candidate is a command + fixed args, NOT a single quoted string (codex P1): py -3 (command py, arg -3), then python3, then python. Implement as parallel positional sets, e.g. iterate over "py -3" "python3" "python" and split into cmd/rest via set -- $candidate so py is invoked with -3 as a real argument — never as an executable literally named py -3.
  3. For each candidate, VERIFY it actually executes by running a marker probe — <cmd> <args> -c "import sys; print('ARS_PY_OK')" — and requiring both exit 0 AND the exact marker ARS_PY_OK on stdout. A 0-byte Store stub fails to execute / prints nothing → skipped. Probe stdout/stderr suppressed except the marker check. Each probe wrapped in timeout when available (see §3.3 for the no-timeout watchdog).
  4. First verified interpreter runs the guard as a supervised subprocess, NOT a bare exec (codex/gemini P1 — see §3.2.1): capture the guard's stdout and exit code; decide what to emit based on whether the guard produced a clean result. Stdin is forwarded to the guard.

3.2 No-Python posture (Plan A)

If NO candidate verifies (no real Python on the machine): the launcher emits a valid pass-through hook JSON {"hookSpecificOutput":{"hookEventName":"PreToolUse"}} and exits 0, and is SILENT on stderr (see "stderr discipline" below).

Rationale (grounded in §1 ground truth + §2 fact): - A non-zero (non-2) exit blocks nothing anyway (GT) — "fail closed via nonzero" is an illusion that only spams logs. - Exit 2 (true block) would hard-lock a user out of all inspected writes/Bash for an environment gap — wrong for an optional hardening layer on a Python-free core (§2). - So pass-through + exit 0: no spam, no block.

stderr discipline (resolves codex P2 — the earlier "stderr advisory + no spam" was self-contradictory): PreToolUse is a hot path firing on every inspected tool call. Any stderr the launcher writes on the no-Python / guard-broke paths would, if CC surfaces or logs it, repeat on EVERY call — i.e. exactly the log spam #454 is about. Therefore the launcher writes nothing to stderr on these degraded paths; it stays silent and exits 0. The user-facing surface for "guard not active" is the docs note (§3.4), NOT a per-call stderr line. (A future once-per-session SessionStart advisory could surface it without hot-path spam — noted as optional follow-up §6, not built here.)

3.2.1 Found-Python-but-guard-broke posture (decision: do NOT block)

Distinct from "no Python" (an environment gap, not ARS's fault): here a real Python WAS found but the guard subprocess does not return a clean result — it crashes (non-zero exit), the guard script is missing, or it emits invalid/empty stdout instead of the expected hook JSON. This is an ARS-side defect (a broken guard), so:

3.3 Bash 3.2 / cross-platform constraints

3.4 hooks.json + docs

3.5 Tests (close the codex-flagged blind spot)

Existing tests invoke the guard via [sys.executable, guard_path] — they never exercise interpreter resolution. Add launch-layer tests for run_guard.sh (and register the new test file in scripts/_ci_pytest_manifest.toml, see §3.6):

Terminology fix (gemini/codex): the guard ALWAYS exits 0 and signals a denial via "permissionDecision":"deny" in its stdout JSON (render_hook_output), never via a non-zero exit. All tests below assert on the forwarded JSON payload, not on exit codes-as-decision.

3.6 Seams to update (cross-file, dual-track flagged — all verified against the actual files)

4. What this does NOT change

5. Rejected alternatives

6. Follow-up (separate issues, not this PR)