From dce504fa8c175fffa87aa18e14444dedc9f3b068 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:56:40 +0000 Subject: [PATCH] Start a plugin's runtime through the shipped launcher, not a bare python3 Every plugin package ran its runtime with python3, which on Windows is often missing or the Store stub that exits 9009: the hook failed to start and Claude Code let the command run. Each package now ships launch.sh beside its runtime, and its hook runs it through git's own sh: git -c "alias.chock-sh=!sh" chock-sh "/scripts/launch.sh" "/scripts/.py" ... The alias carries only sh, so it reads the same under bash, PowerShell and cmd.exe, and the client expands its plugin-root token in the paths exactly as it did before. The launcher starts the first Python 3.11+ that actually runs, or refuses with exit 2. Plugin descriptions now say the hook needs git and a Python 3.11+, not python3. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- CHANGELOG.md | 11 ++++ docs/cli-reference.md | 2 +- src/chock/hooks/launch.py | 13 +++++ src/chock/plugin/catalog_page.py | 7 +-- src/chock/plugin/claude.py | 34 +++++++++--- src/chock/plugin/codex.py | 10 ++-- src/chock/plugin/copilot.py | 20 ++++--- src/chock/plugin/cursor.py | 10 ++-- src/chock/plugin/devin.py | 17 ++++-- src/chock/plugin/gate_package.py | 12 +++-- src/chock/plugin/posture.py | 15 +++--- tests/test_claude_plugin.py | 3 +- tests/test_copilot_plugin.py | 4 +- tests/test_cursor_codex_plugin.py | 10 ++-- tests/test_devin_plugin.py | 4 +- tests/test_plugin_gate.py | 5 +- tests/test_plugin_launcher.py | 86 +++++++++++++++++++++++++++++++ 17 files changed, 208 insertions(+), 55 deletions(-) create mode 100644 tests/test_plugin_launcher.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8af17..f41d2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Chock changelog +## Unreleased + +- **A plugin's hook starts on Windows.** Every plugin package (claude, cursor, codex, copilot, + devin) ran its runtime with a bare `python3`, which on Windows is often missing or the Store + stub that exits 9009; the hook failed to start and Claude Code let the command run. Each + package now ships `scripts/launch.sh` beside its runtime, and its hook runs it through git's + own sh (`git -c "alias.chock-sh=!sh" chock-sh "/scripts/launch.sh" ...`), under + bash, PowerShell or cmd.exe alike. The launcher starts the first of `python3`, `python` and + `py` that actually runs Python 3.11+, or refuses with exit 2. The plugin descriptions now say + it needs git and a Python 3.11+, not python3. + ## 0.12.0 — Hooks that run on every machine and refuse when they cannot judge - **Agent hooks run on every machine, not just the one that last ran `chock sync`.** Hook diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7eb0d4f..bad3433 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -208,7 +208,7 @@ the envelope differs. `claude` (`.claude-plugin/`, `PreToolUse` + `Stop`) is rea CLI, VS Code and Grok Build; `copilot` is the Agent Plugins 1.0 layout under `com.github.copilot/hooks/` (`Stop`); `cursor` (`.cursor-plugin/`) takes `beforeShellExecution` per guard and, per gate, `preToolUse` on the write plus `stop`; `codex` (`.codex-plugin/`, `PreToolUse` per guard, `Stop` per gate) reaches a hook engine no other package -can, both failing **open** without `python3`; `devin` (`.devin-plugin/plugin.json` + `hooks.json`, same two events) +can, both failing **open** without `git`; `devin` (`.devin-plugin/plugin.json` + `hooks.json`, same two events) is best-effort by the vendor's own design, fail-open, not enforced. They require `--out-dir` (or `--out`); in-place output is refused so a policy folder is never mistaken for a published plugin. `--policies-dir` packages a published directory; `--check` judges without writing. `--policy ID` (repeatable; manifest diff --git a/src/chock/hooks/launch.py b/src/chock/hooks/launch.py index 9de2504..c59afbd 100644 --- a/src/chock/hooks/launch.py +++ b/src/chock/hooks/launch.py @@ -28,6 +28,14 @@ #: No `$`, no backslash, no single quote: bash, PowerShell and cmd.exe read it identically. _PREFIX = f'git -c "alias.{ALIAS}=!{_MISSING}; sh {LAUNCHER_REL}" {ALIAS}' +#: A plugin has no repository root to resolve against, so its alias carries nothing but `sh`: git +#: supplies a POSIX sh under bash, PowerShell and cmd.exe alike, and the launcher's path is an +#: argument the client expands in its plugin-root token, as it did for `python3 ""`. +PLUGIN_ALIAS = "chock-sh" + +#: The launcher's name beside a plugin's packaged runtime. +PLUGIN_LAUNCHER = "launch.sh" + _TEMPLATE = package_data_dir("chock", "hooks", "data").joinpath("launch.sh").read_text(encoding="utf-8") @@ -37,6 +45,11 @@ def hook_command(runtime: str, *args: str) -> str: return " ".join([_PREFIX, runtime, *words]) +def plugin_interpreter(launcher: str) -> str: + """What stands where a plugin hook said `python3`: git's sh running the shipped launcher.""" + return f'git -c "alias.{PLUGIN_ALIAS}=!sh" {PLUGIN_ALIAS} {launcher}' + + def launcher_text() -> str: """The launcher script, with this chock's minimum Python filled in.""" return _TEMPLATE.replace("__MIN_PYTHON_TEXT__", ".".join(map(str, MIN_PYTHON))).replace( diff --git a/src/chock/plugin/catalog_page.py b/src/chock/plugin/catalog_page.py index d68be22..64e8810 100644 --- a/src/chock/plugin/catalog_page.py +++ b/src/chock/plugin/catalog_page.py @@ -113,7 +113,8 @@ def _explain(tree: str, guards: int, guard_events: list[str], gates: int, gate_e parts.append( f"A guard package ships a guard script and a stdlib-only adapter, hooked at " f"{_event_list(guard_events)}, and can deny a shell command before the client runs it. " - "It fails open when `python3` or a usable `bash` is unavailable. When the guard itself " + "It fails open when `git` or a usable `bash` is unavailable, and exits 2 when no Python " + "3.11+ runs. When the guard itself " f"crashes, the hook {_on_crash(tree)}." ) if gates: @@ -126,8 +127,8 @@ def _explain(tree: str, guards: int, guard_events: list[str], gates: int, gate_e ) parts.append( f"A gate package ships the policy's gate and a stdlib-only runner instead, hooked at " - f"{_event_list(gate_events)}, {reach}. It needs `python3`; without it a fail-open client " - "allows silently, and a gate that cannot reach a decision refuses rather than allowing " + f"{_event_list(gate_events)}, {reach}. It needs `git` and a Python 3.11+; with no working " + "Python it exits 2, without git a fail-open client allows silently, and a gate that cannot reach a decision refuses rather than allowing " "one it never judged." ) parts.append("An advisory package ships skill text; nothing stops a violation.") diff --git a/src/chock/plugin/claude.py b/src/chock/plugin/claude.py index 02378e9..bf938a9 100644 --- a/src/chock/plugin/claude.py +++ b/src/chock/plugin/claude.py @@ -11,6 +11,7 @@ from chock.compile.emitters.in_agent import GATE_FILE, _guard_script, tool_use_gate_spec from chock.compile.emitters.in_agent_hooks import hooks_map_file from chock.gate import runtime_bundle +from chock.hooks import launch from chock.plugin import gate_package, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, @@ -29,10 +30,11 @@ _MANIFEST_REL = packaging.layout("claude_code")["manifest"] POSTURE_ENFORCED = ( - "Session-enforced via a PreToolUse hook; needs python3 and a usable bash. Without them, " - "fail-open clients allow silently; fail-closed clients refuse matched commands. On Windows, " - "disable the python3 Store alias or install Python. If the guard itself crashes or times " - "out, the hook asks for confirmation rather than allowing silently." + "Session-enforced via a PreToolUse hook; needs git, a usable bash and a Python 3.11+ " + "(python3, python or py, whichever actually runs; the Windows Store stub is skipped). With no " + "working Python the hook refuses (exit 2); without git or bash, fail-open clients allow " + "silently and fail-closed clients refuse matched commands. If the guard itself crashes or " + "times out, the hook asks for confirmation rather than allowing silently." ) POSTURE_ENFORCED_GATE = gate_package.gate_posture("claude_code") POSTURE_ADVISORY = "Advisory skill only; enforcement needs chock installed in the repo." @@ -54,18 +56,34 @@ def _adapter_source(agent: str = "claude_code") -> str: return runtime_bundle.render(agent) +_LAUNCHER_REL = _SCRIPTS_TEMPLATE.format(name=launch.PLUGIN_LAUNCHER) + + +def _runtime_files(agent: str) -> dict[Path, str]: + """`agent`'s runtime, and the launcher that starts it with a Python that actually runs.""" + return { + Path(_SCRIPTS_TEMPLATE.format(name=f"{agent}.py")): _adapter_source(agent), + Path(_LAUNCHER_REL): launch.launcher_text(), + } + + +def _interpreter(agent: str) -> str: + """The launcher invocation for `agent`'s plugin, reached through its plugin-root token.""" + return launch.plugin_interpreter(f'"{packaging.executable_ref(agent, _LAUNCHER_REL)}"') + + def _hook_command(script: str) -> str: """One interpreter invocation, deliberately without a fallback chain.""" adapter = packaging.executable_ref("claude_code", _SCRIPTS_TEMPLATE.format(name="claude_code.py")) guard = packaging.executable_ref("claude_code", _SCRIPTS_TEMPLATE.format(name=script)) - return f'python3 "{adapter}" --guard "{guard}"' + return f'{_interpreter("claude_code")} "{adapter}" --guard "{guard}"' def _gate_command() -> str: """The same adapter, handed the packaged gate instead of a guard.""" adapter = packaging.executable_ref("claude_code", _SCRIPTS_TEMPLATE.format(name="claude_code.py")) gate = packaging.executable_ref("claude_code", _GATE_REL) - return f'python3 "{adapter}" --gate "{gate}"' + return f'{_interpreter("claude_code")} "{adapter}" --gate "{gate}"' def build_claude_manifest( @@ -126,13 +144,13 @@ def claude_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: P hooks_rel = Path(packaging.supports("claude_code", packaging.HOOKS)) if script: files[hooks_rel] = json.dumps(hooks_map_file("claude_code", _hook_command(script)), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="claude_code.py"))] = _adapter_source("claude_code") + files.update(_runtime_files("claude_code")) files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / _IMPLEMENTATIONS / script).read_text( encoding="utf-8" ) elif gate: files[hooks_rel] = json.dumps(gate_package.gate_hooks_file("claude_code", _gate_command()), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="claude_code.py"))] = _adapter_source("claude_code") + files.update(_runtime_files("claude_code")) files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/codex.py b/src/chock/plugin/codex.py index dcb4cdd..5f713ee 100644 --- a/src/chock/plugin/codex.py +++ b/src/chock/plugin/codex.py @@ -21,7 +21,7 @@ plugin_name, skill_assets, ) -from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source +from chock.plugin.claude import POSTURE_ADVISORY, _interpreter, _runtime_files from chock.plugin.listing import ICON_REL, LICENSE_REL, icon_svg, interface_block, license_text from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE @@ -58,7 +58,7 @@ def _hook_command(script: str) -> str: """One interpreter invocation against the plugin's own bundled copies.""" adapter = packaging.executable_ref("codex_cli", _SCRIPTS_TEMPLATE.format(name="codex_cli.py")) guard = packaging.executable_ref("codex_cli", _SCRIPTS_TEMPLATE.format(name=script)) - return f'python3 "{adapter}" --guard "{guard}"' + return f'{_interpreter("codex_cli")} "{adapter}" --guard "{guard}"' POSTURE_GATE_CODEX = gate_package.gate_posture( @@ -72,7 +72,7 @@ def _gate_command() -> str: """The same adapter, handed the packaged gate instead of a guard.""" adapter = packaging.executable_ref("codex_cli", _SCRIPTS_TEMPLATE.format(name="codex_cli.py")) gate = packaging.executable_ref("codex_cli", _SCRIPTS_TEMPLATE.format(name="gate.json")) - return f'python3 "{adapter}" --gate "{gate}"' + return f'{_interpreter("codex_cli")} "{adapter}" --gate "{gate}"' def build_codex_manifest( @@ -142,13 +142,13 @@ def codex_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Pa files[LICENSE_REL] = licence if script: files[Path(HOOKS_REL)] = json.dumps(hooks_map_file("codex_cli", _hook_command(script)), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="codex_cli.py"))] = _adapter_source("codex_cli") + files.update(_runtime_files("codex_cli")) files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) elif gate: files[Path(HOOKS_REL)] = json.dumps(gate_package.gate_hooks_file("codex_cli", _gate_command()), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="codex_cli.py"))] = _adapter_source("codex_cli") + files.update(_runtime_files("codex_cli")) files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/copilot.py b/src/chock/plugin/copilot.py index e4331e8..740b2e2 100644 --- a/src/chock/plugin/copilot.py +++ b/src/chock/plugin/copilot.py @@ -10,6 +10,7 @@ from chock.compile.emitters.in_agent import _guard_script, tool_use_gate_spec from chock.compile.emitters.in_agent_hooks import hooks_map_file +from chock.hooks import launch from chock.plugin import gate_package, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, @@ -22,7 +23,7 @@ plugin_name, skill_assets, ) -from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source +from chock.plugin.claude import POSTURE_ADVISORY, _runtime_files from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE _LAYOUT = packaging.layout("copilot") @@ -33,9 +34,10 @@ "Session-enforced by the PreToolUse hook under com.github.copilot/ in clients that " "read that namespace (documented for VS Code agent mode); a client that ignores it, " "as the Agent Plugins spec tells generic clients to, gets the advisory skill only. " - "The hook needs python3 and a usable bash. Without them, fail-open clients allow " - "silently; fail-closed clients refuse matched commands. On Windows, disable the " - "python3 Store alias or install Python. If the guard itself crashes or times out, the " + "The hook needs git, a usable bash and a Python 3.11+ (python3, python or py, whichever " + "actually runs). With no working Python it exits 2; without git or bash, fail-open clients " + "allow silently and fail-closed clients refuse matched commands. If the guard itself " + "crashes or times out, the " "hook asks for confirmation rather than allowing silently -- VS Code agent mode honours " "that ask and it overrides the client's own auto-approve." ) @@ -56,8 +58,9 @@ def _hook_command(script: str) -> str: assert PLUGIN_ROOT.startswith("${") and PLUGIN_ROOT.endswith("}"), PLUGIN_ROOT # noqa: S101 -- build-time constant, not request input root = f"{PLUGIN_ROOT[:-1]}:-}}" adapter = f'"$r/{_SCRIPTS_TEMPLATE.format(name="vscode_copilot.py")}"' + launcher = launch.plugin_interpreter(f'"$r/{_SCRIPTS_TEMPLATE.format(name=launch.PLUGIN_LAUNCHER)}"') guard = f'"$r/{_SCRIPTS_TEMPLATE.format(name=script)}"' - return f'r="{root}"; [ -n "$r" ] && [ -f {adapter} ] || exit 0; exec python3 {adapter} --guard {guard}' + return f'r="{root}"; [ -n "$r" ] && [ -f {adapter} ] || exit 0; exec {launcher} {adapter} --guard {guard}' POSTURE_GATE_COPILOT = gate_package.gate_posture( @@ -72,8 +75,9 @@ def _gate_command() -> str: assert PLUGIN_ROOT.startswith("${") and PLUGIN_ROOT.endswith("}"), PLUGIN_ROOT # noqa: S101 -- build-time constant, not request input root = f"{PLUGIN_ROOT[:-1]}:-}}" adapter = f'"$r/{_SCRIPTS_TEMPLATE.format(name="vscode_copilot.py")}"' + launcher = launch.plugin_interpreter(f'"$r/{_SCRIPTS_TEMPLATE.format(name=launch.PLUGIN_LAUNCHER)}"') gate = f'"$r/{_SCRIPTS_TEMPLATE.format(name="gate.json")}"' - return f'r="{root}"; [ -n "$r" ] && [ -f {adapter} ] || exit 0; exec python3 {adapter} --gate {gate}' + return f'r="{root}"; [ -n "$r" ] && [ -f {adapter} ] || exit 0; exec {launcher} {adapter} --gate {gate}' def build_copilot_manifest( @@ -126,7 +130,7 @@ def copilot_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: files[LICENSE_REL] = licence if script: files[Path(HOOKS_REL)] = json.dumps(hooks_map_file("vscode_copilot", _hook_command(script)), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="vscode_copilot.py"))] = _adapter_source("vscode_copilot") + files.update(_runtime_files("vscode_copilot")) files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) @@ -134,7 +138,7 @@ def copilot_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: files[Path(HOOKS_REL)] = ( json.dumps(gate_package.gate_hooks_file("vscode_copilot", _gate_command()), indent=2) + "\n" ) - files[Path(_SCRIPTS_TEMPLATE.format(name="vscode_copilot.py"))] = _adapter_source("vscode_copilot") + files.update(_runtime_files("vscode_copilot")) files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/cursor.py b/src/chock/plugin/cursor.py index 9df2cd0..b399c5c 100644 --- a/src/chock/plugin/cursor.py +++ b/src/chock/plugin/cursor.py @@ -23,7 +23,7 @@ plugin_name, skill_assets, ) -from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source +from chock.plugin.claude import POSTURE_ADVISORY, _interpreter, _runtime_files from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE _LAYOUT = packaging.layout("cursor") @@ -59,7 +59,7 @@ def _hook_command(script: str) -> str: """One interpreter invocation against the plugin's own bundled copies.""" adapter = packaging.executable_ref("cursor", _SCRIPTS_TEMPLATE.format(name="cursor.py")) guard = packaging.executable_ref("cursor", _SCRIPTS_TEMPLATE.format(name=script)) - return f'python3 "{adapter}" --guard "{guard}"' + return f'{_interpreter("cursor")} "{adapter}" --guard "{guard}"' POSTURE_GATE_CURSOR = gate_package.gate_posture( @@ -74,7 +74,7 @@ def _gate_command() -> str: """The same adapter, handed the packaged gate instead of a guard.""" adapter = packaging.executable_ref("cursor", _SCRIPTS_TEMPLATE.format(name="cursor.py")) gate = packaging.executable_ref("cursor", _SCRIPTS_TEMPLATE.format(name="gate.json")) - return f'python3 "{adapter}" --gate "{gate}"' + return f'{_interpreter("cursor")} "{adapter}" --gate "{gate}"' def build_cursor_manifest( @@ -140,13 +140,13 @@ def cursor_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: P files[LICENSE_REL] = licence if script: files[Path(HOOKS_REL)] = json.dumps(cursor_hooks_file(_hook_command(script)), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="cursor.py"))] = _adapter_source("cursor") + files.update(_runtime_files("cursor")) files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) elif gate: files[Path(HOOKS_REL)] = json.dumps(gate_package.gate_hooks_file("cursor", _gate_command()), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="cursor.py"))] = _adapter_source("cursor") + files.update(_runtime_files("cursor")) files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/devin.py b/src/chock/plugin/devin.py index c55468d..2e86c00 100644 --- a/src/chock/plugin/devin.py +++ b/src/chock/plugin/devin.py @@ -10,6 +10,7 @@ from chock.compile.emitters.in_agent import _guard_script, tool_use_gate_spec from chock.compile.emitters.in_agent_hooks import hooks_map_file +from chock.hooks import launch from chock.plugin import gate_package, posture, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, @@ -21,7 +22,7 @@ plugin_name, skill_assets, ) -from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source +from chock.plugin.claude import POSTURE_ADVISORY, _runtime_files from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE _LAYOUT = packaging.layout("devin") @@ -65,8 +66,11 @@ def _hook_command(script: str) -> str: """One interpreter invocation, via a shell expansion of `$DEVIN_PLUGIN_ROOT`.""" adapter = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name='devin.py')}" + launcher = launch.plugin_interpreter( + f'"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name=launch.PLUGIN_LAUNCHER)}"' + ) guard = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name=script)}" - return f'python3 "{adapter}" --guard "{guard}"' + return f'{launcher} "{adapter}" --guard "{guard}"' POSTURE_GATE_DEVIN = gate_package.gate_posture( @@ -79,8 +83,11 @@ def _hook_command(script: str) -> str: def _gate_command() -> str: """The same adapter, handed the packaged gate instead of a guard.""" adapter = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name='devin.py')}" + launcher = launch.plugin_interpreter( + f'"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name=launch.PLUGIN_LAUNCHER)}"' + ) gate = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name='gate.json')}" - return f'python3 "{adapter}" --gate "{gate}"' + return f'{launcher} "{adapter}" --gate "{gate}"' def build_devin_manifest( @@ -133,13 +140,13 @@ def devin_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Pa files[LICENSE_REL] = licence if script: files[Path(HOOKS_REL)] = json.dumps(hooks_map_file("devin", _hook_command(script)), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="devin.py"))] = _adapter_source("devin") + files.update(_runtime_files("devin")) files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) elif gate: files[Path(HOOKS_REL)] = json.dumps(gate_package.gate_hooks_file("devin", _gate_command()), indent=2) + "\n" - files[Path(_SCRIPTS_TEMPLATE.format(name="devin.py"))] = _adapter_source("devin") + files.update(_runtime_files("devin")) files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/gate_package.py b/src/chock/plugin/gate_package.py index 67ef976..7c654fa 100644 --- a/src/chock/plugin/gate_package.py +++ b/src/chock/plugin/gate_package.py @@ -25,18 +25,20 @@ IMPLEMENTATIONS = "implementations" _STOP_ONLY_POSTURE = ( - "Session-enforced at the turn's end by a Stop hook; needs python3. This client records no " + "Session-enforced at the turn's end by a Stop hook; needs git and a Python 3.11+. This client records no " "file-writing tool vocabulary, so the write itself is not judged: what the turn actually left " "on disk is re-read, and a construct a rule denies is refused then, however it was written. " - "Without python3, fail-open clients allow silently. A gate that cannot reach a decision " + "With no working Python the hook exits 2; without git, fail-open clients allow silently. " + "A gate that cannot reach a decision " "refuses rather than allowing one it never judged. Enforcement at every commit and in CI " "still needs chock installed in the repo." ) _WRITE_AND_STOP_POSTURE = ( - "Session-enforced via PreToolUse and Stop hooks; needs python3. PreToolUse judges the file " + "Session-enforced via PreToolUse and Stop hooks; needs git and a Python 3.11+. PreToolUse " + "judges the file " "a tool call would write; Stop re-reads what the turn actually left on disk, so a file " - "written through a shell heredoc is judged too. Without python3, fail-open clients allow " - "silently. A gate that cannot reach a decision refuses rather than allowing one it never " + "written through a shell heredoc is judged too. With no working Python the hook exits 2; " + "without git, fail-open clients allow silently. A gate that cannot reach a decision refuses rather than allowing one it never " "judged. Enforcement at every commit and in CI still needs chock installed in the repo." ) _STOP_ONLY_NOTE = ( diff --git a/src/chock/plugin/posture.py b/src/chock/plugin/posture.py index 17bb5fd..6c1ca26 100644 --- a/src/chock/plugin/posture.py +++ b/src/chock/plugin/posture.py @@ -26,8 +26,9 @@ def enforced_codex(*, ledger=None) -> str: "returned as hook JSON, not an exit code, which Codex's Windows shell wrapper " "mangles). Codex requires a one-time trust review per hook -- the plugin is ADVISORY " "until you approve its hook, and a plugin update voids that trust until re-approved. " - "The hook needs python3 on PATH; a failure of the HOOK (missing python3, a timeout, an " - "unexpected exit) fails OPEN. A failure of the GUARD it runs is a DENY here, because " + "The hook needs git and a Python 3.11+ (python3, python or py) on PATH; with no working " + "Python it exits 2 rather than allowing, and any other failure of the HOOK (missing git, a " + "timeout, an unexpected exit) fails OPEN. A failure of the GUARD it runs is a DENY here, because " "Codex rejects the confirmation prompt the other clients get. Repo-wide enforcement " "at commit time and in CI still needs `chock sync`." ) @@ -43,7 +44,7 @@ def enforced_devin(*, ledger=None) -> str: "documented for local Devin sessions (the CLI and Devin Desktop) only. The hook " "command expands $DEVIN_PLUGIN_ROOT, an environment variable the vendor documents " "hook commands receive; whether that expansion happens inside a hooks.json command " - "string is documented, not witnessed here. The hook needs python3 on PATH. Repo-wide " + "string is documented, not witnessed here. The hook needs git and a Python 3.11+ on PATH. Repo-wide " "git-hook and CI coverage still needs `chock sync`." ) @@ -52,10 +53,10 @@ def enforced_cursor(*, ledger=None) -> str: """Cursor's enforced posture: the interpreters it needs, and what it does without them.""" return ( "Session-enforced in Cursor by a beforeShellExecution hook: a matched command is " - f"denied before it runs ({witness_clause('cursor', ledger=ledger)}). The hook needs python3 " - "and a usable bash resolved from PATH; without them Cursor allows the command " - "silently, so this fails OPEN. On Windows, disable the python3 Store alias or install " - "Python. If the guard itself crashes or times out, the hook returns " + f"denied before it runs ({witness_clause('cursor', ledger=ledger)}). The hook needs git, " + "a Python 3.11+ (python3, python or py; the Windows Store stub is skipped) and a usable " + "bash resolved from PATH; without them Cursor allows the command silently, so this fails " + "OPEN. If the guard itself crashes or times out, the hook returns " '`permission: "ask"`, which beforeShellExecution honours. Repo-wide enforcement at ' "commit time and in CI still needs `chock sync`." ) diff --git a/tests/test_claude_plugin.py b/tests/test_claude_plugin.py index 1f056e9..ed60234 100644 --- a/tests/test_claude_plugin.py +++ b/tests/test_claude_plugin.py @@ -74,7 +74,8 @@ def test_guard_policy_ships_hooks_adapter_and_guard(policy, tmp_path: Path) -> N assert "${CLAUDE_PLUGIN_ROOT}/scripts/claude_code.py" in command assert "${CLAUDE_PLUGIN_ROOT}/scripts/block-destructive-commands.sh" in command assert command == ( - 'python3 "${CLAUDE_PLUGIN_ROOT}/scripts/claude_code.py" ' + 'git -c "alias.chock-sh=!sh" chock-sh "${CLAUDE_PLUGIN_ROOT}/scripts/launch.sh" ' + '"${CLAUDE_PLUGIN_ROOT}/scripts/claude_code.py" ' '--guard "${CLAUDE_PLUGIN_ROOT}/scripts/block-destructive-commands.sh"' ) diff --git a/tests/test_copilot_plugin.py b/tests/test_copilot_plugin.py index 9fcb1fe..1ce9234 100644 --- a/tests/test_copilot_plugin.py +++ b/tests/test_copilot_plugin.py @@ -83,7 +83,7 @@ def test_hook_lives_in_the_copilot_namespace(policy, tmp_path: Path) -> None: command = entry["hooks"][0]["command"] assert command == ( 'r="${PLUGIN_ROOT:-}"; [ -n "$r" ] && [ -f "$r/scripts/vscode_copilot.py" ] || exit 0; ' - 'exec python3 "$r/scripts/vscode_copilot.py" ' + 'exec git -c "alias.chock-sh=!sh" chock-sh "$r/scripts/launch.sh" "$r/scripts/vscode_copilot.py" ' '--guard "$r/scripts/block-destructive-commands.sh"' ) @@ -123,7 +123,7 @@ def test_descriptions_state_the_fail_posture(policy, tmp_path: Path) -> None: assert POSTURE_ADVISORY in bare_manifest["description"] assert "com.github.copilot" in POSTURE_ENFORCED_COPILOT, "the scope must be named" assert "ignores it" in POSTURE_ENFORCED_COPILOT, "the namespace-ignoring outcome must be named" - assert "python3" in POSTURE_ENFORCED_COPILOT and "bash" in POSTURE_ENFORCED_COPILOT + assert "Python 3.11+" in POSTURE_ENFORCED_COPILOT and "bash" in POSTURE_ENFORCED_COPILOT def test_extension_claims_match_the_package_contents(policy, tmp_path: Path) -> None: diff --git a/tests/test_cursor_codex_plugin.py b/tests/test_cursor_codex_plugin.py index 8a0056a..da324cb 100644 --- a/tests/test_cursor_codex_plugin.py +++ b/tests/test_cursor_codex_plugin.py @@ -62,6 +62,7 @@ def test_cursor_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: Path("skills/block-destructive-commands/SKILL.md"), Path("hooks/hooks.json"), Path("scripts/cursor.py"), + Path("scripts/launch.sh"), Path("scripts/block-destructive-commands.sh"), } @@ -72,7 +73,8 @@ def test_cursor_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: entry = entries[0] assert set(entry) == {"command", "timeout"} assert entry["command"] == ( - 'python3 "${CURSOR_PLUGIN_ROOT}/scripts/cursor.py" ' + 'git -c "alias.chock-sh=!sh" chock-sh "${CURSOR_PLUGIN_ROOT}/scripts/launch.sh" ' + '"${CURSOR_PLUGIN_ROOT}/scripts/cursor.py" ' '--guard "${CURSOR_PLUGIN_ROOT}/scripts/block-destructive-commands.sh"' ) @@ -110,6 +112,7 @@ def test_codex_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: Path("skills/block-destructive-commands/SKILL.md"), Path("hooks/hooks.json"), Path("scripts/codex_cli.py"), + Path("scripts/launch.sh"), Path("scripts/block-destructive-commands.sh"), Path("assets/icon.svg"), } @@ -122,7 +125,8 @@ def test_codex_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: assert inner["type"] == "command" assert "async" not in inner, "Codex only honours a blocking decision from a sync hook" assert inner["command"] == ( - 'python3 "${PLUGIN_ROOT}/scripts/codex_cli.py" --guard "${PLUGIN_ROOT}/scripts/block-destructive-commands.sh"' + 'git -c "alias.chock-sh=!sh" chock-sh "${PLUGIN_ROOT}/scripts/launch.sh" ' + '"${PLUGIN_ROOT}/scripts/codex_cli.py" --guard "${PLUGIN_ROOT}/scripts/block-destructive-commands.sh"' ) @@ -225,7 +229,7 @@ def test_losing_a_guard_removes_the_hook(policy, tmp_path: Path, build, tree, ho def test_each_vendor_claims_only_what_was_witnessed(policy, tmp_path: Path) -> None: """Cursor was witnessed blocking; Codex was witnessed NOT blocking. The packages say so.""" assert "Session-enforced in Cursor" in POSTURE_ENFORCED_CURSOR - assert "OPEN" in POSTURE_ENFORCED_CURSOR and "python3" in POSTURE_ENFORCED_CURSOR + assert "OPEN" in POSTURE_ENFORCED_CURSOR and "Python 3.11+" in POSTURE_ENFORCED_CURSOR assert "Session-enforced in Codex" in POSTURE_ENFORCED_CODEX, "witnessed 2026-08-24" assert "trust review" in POSTURE_ENFORCED_CODEX, "hooks are inert until a human approves" diff --git a/tests/test_devin_plugin.py b/tests/test_devin_plugin.py index 94859fb..e951c18 100644 --- a/tests/test_devin_plugin.py +++ b/tests/test_devin_plugin.py @@ -66,6 +66,7 @@ def test_devin_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: Path("skills/block-destructive-commands/SKILL.md"), Path("hooks.json"), Path("scripts/devin.py"), + Path("scripts/launch.sh"), Path("scripts/block-destructive-commands.sh"), } @@ -76,7 +77,8 @@ def test_devin_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: inner = entry["hooks"][0] assert inner["type"] == "command" assert inner["command"] == ( - 'python3 "$DEVIN_PLUGIN_ROOT/scripts/devin.py" --guard "$DEVIN_PLUGIN_ROOT/scripts/block-destructive-commands.sh"' + 'git -c "alias.chock-sh=!sh" chock-sh "$DEVIN_PLUGIN_ROOT/scripts/launch.sh" ' + '"$DEVIN_PLUGIN_ROOT/scripts/devin.py" --guard "$DEVIN_PLUGIN_ROOT/scripts/block-destructive-commands.sh"' ) diff --git a/tests/test_plugin_gate.py b/tests/test_plugin_gate.py index de4363e..054fe75 100644 --- a/tests/test_plugin_gate.py +++ b/tests/test_plugin_gate.py @@ -101,7 +101,10 @@ def test_a_tool_use_gate_ships_hooks_runner_gate_and_its_program(policy, tmp_pat out = _build(policy(manifest), manifest, tmp_path) hooks = json.loads((out / "hooks" / "hooks.json").read_text(encoding="utf-8"))["hooks"] - command = 'python3 "${CLAUDE_PLUGIN_ROOT}/scripts/claude_code.py" --gate "${CLAUDE_PLUGIN_ROOT}/scripts/gate.json"' + command = ( + 'git -c "alias.chock-sh=!sh" chock-sh "${CLAUDE_PLUGIN_ROOT}/scripts/launch.sh" ' + '"${CLAUDE_PLUGIN_ROOT}/scripts/claude_code.py" --gate "${CLAUDE_PLUGIN_ROOT}/scripts/gate.json"' + ) assert hooks["PreToolUse"][0]["matcher"] == "Write|Edit|MultiEdit|NotebookEdit" assert hooks["PreToolUse"][0]["hooks"][0]["command"] == command assert "matcher" not in hooks["Stop"][0] diff --git a/tests/test_plugin_launcher.py b/tests/test_plugin_launcher.py new file mode 100644 index 0000000..aee68ee --- /dev/null +++ b/tests/test_plugin_launcher.py @@ -0,0 +1,86 @@ +"""A plugin's hook starts its runtime through the shipped launcher, not a bare `python3`. + +On Windows `python3` is often missing or the Store stub (it exists and exits 9009): the hook +then failed to start, and Claude Code let the command run. git's own sh runs the launcher, +which picks the first Python 3.11+ that actually runs, or refuses with exit 2. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +from pathlib import Path + +import pytest +import yaml +from conftest import baseline_policy, run_hook_command + +from chock.hooks.launch import launcher_text +from chock.plugin.claude import build_claude_plugin + +posix_only = pytest.mark.skipif(sys.platform == "win32", reason="symlinked PATH shims are a POSIX construct") + +POLICY_ID = "block-destructive-commands" +RM_ROOT = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "rm -rf /"}} + + +def _plugin(tmp_path: Path) -> tuple[Path, str]: + """A built Claude Code plugin and its PreToolUse command, the root token expanded as the client does.""" + pack = baseline_policy(POLICY_ID) + manifest = yaml.safe_load((pack / "manifest.yaml").read_text(encoding="utf-8")) + out = tmp_path / "plugin" + build_claude_plugin(pack, manifest, tmp_path, out) + hooks = json.loads((out / "hooks" / "hooks.json").read_text(encoding="utf-8")) + command = hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + return out, command.replace("${CLAUDE_PLUGIN_ROOT}", out.as_posix()) + + +def _bin(tmp_path: Path, *tools: str, **links: str) -> Path: + """A PATH directory holding `tools` from this machine plus `links` (name -> target).""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + for tool in tools: + found = shutil.which(tool) + assert found, tool + (bin_dir / tool).symlink_to(found) + for name, target in links.items(): + (bin_dir / name).symlink_to(target) + return bin_dir + + +def _stub(bin_dir: Path, name: str) -> None: + """A `name` on PATH that exists but does not run, like Windows' python3 Store alias.""" + stub = bin_dir / name + stub.write_text("#!/bin/sh\nexit 9009\n", encoding="utf-8") + stub.chmod(0o755) + + +def test_the_plugin_ships_the_launcher_beside_its_runtime(tmp_path: Path) -> None: + out, command = _plugin(tmp_path) + assert (out / "scripts" / "launch.sh").read_text(encoding="utf-8") == launcher_text() + assert not command.startswith("python3"), "a bare python3 is what failed open on Windows" + + +@posix_only +def test_a_python3_that_does_not_run_is_skipped_and_the_guard_still_denies(tmp_path: Path) -> None: + _out, command = _plugin(tmp_path) + shims = _bin(tmp_path, python=sys.executable) + _stub(shims, "python3") + env = {**os.environ, "PATH": f"{shims}{os.pathsep}{os.environ['PATH']}"} + outside = tmp_path / "not-a-repo" + outside.mkdir() + proc = run_hook_command(command, outside, json.dumps(RM_ROOT), env=env) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +@posix_only +def test_no_working_python_refuses_rather_than_failing_open(tmp_path: Path) -> None: + _out, command = _plugin(tmp_path) + bin_dir = _bin(tmp_path, "git", "sh", "bash") + _stub(bin_dir, "python3") + proc = run_hook_command(command, tmp_path, json.dumps(RM_ROOT), env={"PATH": str(bin_dir)}) + assert proc.returncode == 2, "exit 2 blocks; the Store stub's 9009 was a non-blocking error" + assert "no working Python" in proc.stderr