Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 "<plugin root>/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
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/chock/hooks/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<path>"`.
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")


Expand All @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions src/chock/plugin/catalog_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.")
Expand Down
34 changes: 26 additions & 8 deletions src/chock/plugin/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions src/chock/plugin/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
20 changes: 12 additions & 8 deletions src/chock/plugin/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand All @@ -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."
)
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -126,15 +130,15 @@ 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"
)
elif gate:
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

Expand Down
10 changes: 5 additions & 5 deletions src/chock/plugin/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
17 changes: 12 additions & 5 deletions src/chock/plugin/devin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading