From 6656cad51fd517d897fe29d19f8287d434b0f9c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:40:02 -0400 Subject: [PATCH 01/25] Judge a pre-tool write by its repository-relative path Claude Code and Cursor name the file a Write/Edit touches absolutely, while applies_to.paths globs are repository-relative, so every scoped content gate (pin-github-actions: `.github/workflows/*`) passed `actions/checkout@v4` unjudged at tool use. The write handler now folds each path onto the gate's repository root -- lexically, then through resolve() for a symlinked root -- with Windows drive, separator and case semantics; a path outside the root is left as given and so stays out of scope. Signed-off-by: Claude Co-Authored-By: Claude Opus 5.5 --- .chock/bin/claude_code.py | 39 ++++++++++++- .chock/bin/codex_cli.py | 39 ++++++++++++- .chock/bin/cursor.py | 39 ++++++++++++- .chock/bin/devin.py | 39 ++++++++++++- .chock/bin/gemini_cli.py | 39 ++++++++++++- .chock/bin/grok.py | 39 ++++++++++++- .chock/bin/tabnine.py | 39 ++++++++++++- .chock/bin/vscode_copilot.py | 39 ++++++++++++- .chock/bin/windsurf.py | 39 ++++++++++++- src/chock/gate/data/imports.py.tmpl | 1 + src/chock/gate/runtime_bundle.py | 2 + src/chock/gate/write_gate.py | 42 +++++++++++++- tests/fixtures/runtime_goldens/antigravity.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/claude_code.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/codex_cli.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/cursor.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/devin.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/gemini_cli.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/grok.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/tabnine.py | 39 ++++++++++++- .../runtime_goldens/vscode_copilot.py | 39 ++++++++++++- tests/fixtures/runtime_goldens/windsurf.py | 39 ++++++++++++- tests/test_write_gate_handler.py | 57 +++++++++++++++++++ 23 files changed, 802 insertions(+), 41 deletions(-) diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index e2f3b2c..1f2c65e 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -19,6 +19,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1052,6 +1053,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1106,6 +1111,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1199,12 +1232,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 99a4da8..8718909 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1038,6 +1039,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1092,6 +1097,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1185,12 +1218,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 5a09dbc..c8d4f06 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -966,6 +967,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1020,6 +1025,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1113,12 +1146,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index ce97857..610d699 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1020,6 +1021,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,6 +1079,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1167,12 +1200,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index b602ae3..aad3f68 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1020,6 +1021,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,6 +1079,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1167,12 +1200,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index ac46700..90ed41b 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1003,6 +1004,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,6 +1062,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1150,12 +1183,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index fd3bfce..38c3a8d 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1003,6 +1004,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,6 +1062,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1150,12 +1183,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 1473773..d7eb4de 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -827,6 +828,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -881,6 +886,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -974,12 +1007,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 387c633..82ab193 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -917,6 +918,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -971,6 +976,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1064,12 +1097,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/src/chock/gate/data/imports.py.tmpl b/src/chock/gate/data/imports.py.tmpl index 703fe92..7f1a24e 100644 --- a/src/chock/gate/data/imports.py.tmpl +++ b/src/chock/gate/data/imports.py.tmpl @@ -4,3 +4,4 @@ import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath diff --git a/src/chock/gate/runtime_bundle.py b/src/chock/gate/runtime_bundle.py index aead26c..4d4c930 100644 --- a/src/chock/gate/runtime_bundle.py +++ b/src/chock/gate/runtime_bundle.py @@ -31,6 +31,8 @@ "datetime": "_chock_datetime", "timezone": "_chock_timezone", "Path": "_chock_Path", + "PurePosixPath": "_chock_PurePosixPath", + "PureWindowsPath": "_chock_PureWindowsPath", } diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index 9fceeee..9484dc4 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -12,9 +12,10 @@ from __future__ import annotations import json +import os import subprocess import sys -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from .edit_image import added_from_event, edited_text from .patch_image import patch_added, patched_files @@ -33,6 +34,9 @@ _PACKAGED_RUNNER = "gate.py" _GIT = "git" +_PARENT = ".." +#: A Windows root is spelled with a drive (`C:`), which a POSIX root never is. +_DRIVE_COLON = ":" #: git status codes: a deletion leaves no content to judge, a rename is followed by its old path. _DELETED = "D" _RENAMED = "R" @@ -89,6 +93,36 @@ def writes_from_event(event, root=None): return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = os.name == "nt" or str(root)[1:2] == _DRIVE_COLON + flavour = PureWindowsPath if windows else PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (os.name == "nt"): + return text + try: + return Path(root, text).resolve().relative_to(Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -208,12 +242,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, "event", "")) if gate is None or name is None or not gate.exists(): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f"Blocked by chock policy: {gate.parent.parent.name}") if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index ee962a1..943e98a 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -986,6 +987,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1040,6 +1045,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1133,12 +1166,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index e2f3b2c..1f2c65e 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -19,6 +19,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1052,6 +1053,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1106,6 +1111,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1199,12 +1232,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 99a4da8..8718909 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1038,6 +1039,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1092,6 +1097,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1185,12 +1218,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 5a09dbc..c8d4f06 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -966,6 +967,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1020,6 +1025,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1113,12 +1146,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index ce97857..610d699 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1020,6 +1021,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,6 +1079,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1167,12 +1200,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index b602ae3..aad3f68 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1020,6 +1021,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,6 +1079,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1167,12 +1200,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index ac46700..90ed41b 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1003,6 +1004,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,6 +1062,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1150,12 +1183,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index fd3bfce..38c3a8d 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -1003,6 +1004,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,6 +1062,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1150,12 +1183,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 1473773..d7eb4de 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -827,6 +828,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -881,6 +886,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -974,12 +1007,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 387c633..82ab193 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -18,6 +18,7 @@ import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -917,6 +918,10 @@ def patch_added(event): _GIT = 'git' +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -971,6 +976,34 @@ def writes_from_event(event, root=None): return {} return {str(path): content} +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1064,12 +1097,14 @@ def evaluate_gate(argv, event): name = _EVENT_ARG.get(getattr(event, 'event', '')) if gate is None or name is None or (not gate.exists()): return None - writes = writes_for(event, gate) + root = repo_root_for(event, gate) + writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = {repo_relative(path, root): text for path, text in added.items()} added = {path: text for path, text in added.items() if path in writes} - outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate), added) + outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/test_write_gate_handler.py b/tests/test_write_gate_handler.py index 1bc3bae..76372e8 100644 --- a/tests/test_write_gate_handler.py +++ b/tests/test_write_gate_handler.py @@ -146,3 +146,60 @@ def test_a_missing_runner_refuses_too(tmp_path: Path) -> None: (tmp_path / ".chock" / "bin" / "gate.py").unlink() verdict = write_gate.evaluate_gate(["--gate", str(gate)], _event(path="app.py", content=SECRET)) assert verdict is not None and verdict[0] == write_gate.VERDICT_DENY + + +# --- a client names the file absolutely; a scope glob is repository-relative ---------------------- + +UNPINNED = " - uses: actions/checkout@v4\n" +PIN_SPEC = { + "kind": "content_regex", + "on": ["commit", "tool_use"], + "action": "block", + "message": "pin it", + "paths": [".github/workflows/*"], + "params": {"scan": "added_lines", "content_pattern": r"uses:\s*[\w./-]+@(?![0-9a-fA-F]{40})[\w./-]+"}, +} + + +def _installed_pin(tmp_path: Path) -> Path: + gate = _installed(tmp_path) + gate.write_text(json.dumps(PIN_SPEC), encoding="utf-8") + return gate + + +def test_a_scoped_gate_judges_a_write_named_by_its_absolute_path(tmp_path: Path) -> None: + """Claude Code and Cursor send tool_input.file_path absolute; the scope must still match it.""" + gate = _installed_pin(tmp_path) + absolute = tmp_path / ".github" / "workflows" / "ci.yml" + verdict = write_gate.evaluate_gate(["--gate", str(gate)], _event(path=str(absolute), content=UNPINNED)) + assert verdict is not None and verdict[0] == write_gate.VERDICT_DENY + + +def test_an_absolute_path_reached_through_a_symlink_is_still_inside(tmp_path: Path) -> None: + real = tmp_path / "real" + real.mkdir() + gate = _installed_pin(real) + (tmp_path / "link").symlink_to(real, target_is_directory=True) + via_link = tmp_path / "link" / ".github" / "workflows" / "ci.yml" + verdict = write_gate.evaluate_gate(["--gate", str(gate)], _event(path=str(via_link), content=UNPINNED)) + assert verdict is not None and verdict[0] == write_gate.VERDICT_DENY + + +def test_paths_are_made_repository_relative_posix() -> None: + assert write_gate.repo_relative("/r/.github/workflows/ci.yml", "/r") == ".github/workflows/ci.yml" + assert write_gate.repo_relative("./a/../.github/x.yml", "/r") == ".github/x.yml" + assert write_gate.repo_relative(".github/x.yml", None) == ".github/x.yml" + + +def test_windows_paths_fold_drive_separator_and_case() -> None: + windows = r"C:\Users\Dev\Repo\.github\workflows\ci.yml" + assert write_gate.repo_relative(windows, r"c:\users\dev\repo") == ".github/workflows/ci.yml" + assert write_gate.repo_relative("C:/Users/Dev/Repo/.github/x.yml", r"C:\Users\Dev\Repo") == ".github/x.yml" + + +def test_a_path_outside_the_repository_is_left_as_given() -> None: + """Outside the root it matches no relative glob, which is what out of scope means.""" + assert ( + write_gate.repo_relative("/elsewhere/.github/workflows/ci.yml", "/r") == "/elsewhere/.github/workflows/ci.yml" + ) + assert write_gate.repo_relative(r"D:\x\ci.yml", r"C:\Repo") == r"D:\x\ci.yml" From 611f369fe71dea82e71f53faaa6f8369cb70b5a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:44:54 -0400 Subject: [PATCH 02/25] Run guards under Git's bash, and ask when none could run A guard that could not run was an allow: an unparseable command (`rm -rf / #'` is valid bash, invalid shlex) and a machine with no usable bash both returned "unchecked" with the reason on a stderr nobody sees on exit 0. Both now take the errored path -- the client asks -- and the prompt says why, naming Git for Windows when bash is missing. Bash discovery follows the rule tests/conftest.py already used, now shared from guard_runner.bash_candidates(): on Windows start from `git` on PATH, prefer \bin\bash.exe over usr\bin\bash.exe, never System32's WSL launcher or a WindowsApps stub, and put Git's usr\bin first on the guard's PATH so sed and grep resolve. The winning bash is probed once per process instead of once per guard (239 bash spawns in one `chock check`). Signed-off-by: Claude Co-Authored-By: Claude Opus 5.5 --- .chock/bin/claude_code.py | 65 ++++++++++++--- .chock/bin/codex_cli.py | 66 +++++++++++++--- .chock/bin/cursor.py | 66 +++++++++++++--- .chock/bin/devin.py | 66 +++++++++++++--- .chock/bin/gemini_cli.py | 66 +++++++++++++--- .chock/bin/grok.py | 66 +++++++++++++--- .chock/bin/tabnine.py | 66 +++++++++++++--- .chock/bin/vscode_copilot.py | 66 +++++++++++++--- .chock/bin/windsurf.py | 66 +++++++++++++--- docs/enforcement-surfaces.md | 18 ++--- src/chock/eval/execute.py | 4 +- src/chock/gate/guard_runner.py | 79 +++++++++++++++---- tests/conftest.py | 13 +-- tests/fixtures/runtime_goldens/antigravity.py | 66 +++++++++++++--- tests/fixtures/runtime_goldens/claude_code.py | 65 ++++++++++++--- tests/fixtures/runtime_goldens/codex_cli.py | 66 +++++++++++++--- tests/fixtures/runtime_goldens/cursor.py | 66 +++++++++++++--- tests/fixtures/runtime_goldens/devin.py | 66 +++++++++++++--- tests/fixtures/runtime_goldens/gemini_cli.py | 66 +++++++++++++--- tests/fixtures/runtime_goldens/grok.py | 66 +++++++++++++--- tests/fixtures/runtime_goldens/tabnine.py | 66 +++++++++++++--- .../runtime_goldens/vscode_copilot.py | 66 +++++++++++++--- tests/fixtures/runtime_goldens/windsurf.py | 66 +++++++++++++--- tests/test_bash_discovery.py | 69 ++++++++++++++++ tests/test_guard_fail_to_ask.py | 21 ++--- tests/test_pretooluse_logging.py | 6 +- 26 files changed, 1240 insertions(+), 222 deletions(-) create mode 100644 tests/test_bash_discovery.py diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 1f2c65e..bb9351e 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -705,7 +705,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -735,14 +749,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -761,16 +805,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -835,7 +881,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 8718909..5becdd6 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -691,7 +692,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -721,14 +736,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -747,16 +792,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -821,7 +868,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index c8d4f06..a74b4e7 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -619,7 +620,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -649,14 +664,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -675,16 +720,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -749,7 +796,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 610d699..a3df21d 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -673,7 +674,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -703,14 +718,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -729,16 +774,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -803,7 +850,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index aad3f68..6d9223d 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -673,7 +674,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -703,14 +718,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -729,16 +774,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -803,7 +850,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 90ed41b..6d21139 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -656,7 +657,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -686,14 +701,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -712,16 +757,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -786,7 +833,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 38c3a8d..f67c4b0 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -656,7 +657,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -686,14 +701,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -712,16 +757,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -786,7 +833,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index d7eb4de..e8f6ea3 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -480,7 +481,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -510,14 +525,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -536,16 +581,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -610,7 +657,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 82ab193..6e22798 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -570,7 +571,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -600,14 +615,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -626,16 +671,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -700,7 +747,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index b64df3c..7d686ad 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -162,20 +162,17 @@ skipped with `--no-verify`. The levels above grade the **outer** boundary: what the client does when chock's hook never runs or dies outright. There is an inner one too — what the hook says when it *did* run and could not reach a verdict — and the two answers are not the same. `gate/guard_runner.py` -distinguishes five such causes and answers two of them differently from the other three. +distinguishes five such causes and answers only one of them with an allow. | Cause | What chock returns | Why | | :--- | :--- | :--- | -| The command will not tokenize (unbalanced quotes) | allow | Common and usually benign — PowerShell quoting, a Windows path. A prompt here fires on a large share of ordinary tool calls. | +| The command will not tokenize (unbalanced quotes) | **ask** | No guard read it. `rm -rf / #'` is valid bash and invalid shlex, so an allow here was a bypass. | | The command is empty after tokenizing | allow | There is nothing to check. | -| No bash on the machine can resolve the guard | allow | Uniform: it holds for every command, not this one, so a prompt says nothing per call and would fire on every tool call on a platform without Git Bash. The fix is an install step. | -| The guard crashed, or exited a code that is none of 0, 1 or 3 | **ask** | The control was installed, reachable and runnable, and still produced no answer. Rare, and anomalous. (Exit 3 is not this: it is the guard asking on purpose, and its own first line is the prompt.) | +| No bash on the machine can resolve the guard | **ask** | No guard ran. The prompt names the fix: install Git for Windows (it ships bash), or put bash on PATH. | +| The guard crashed, or exited a code that is none of 0, 1 or 3 | **ask** | The control was installed, reachable and runnable, and still produced no answer. (Exit 3 is not this: it is the guard asking on purpose, and its own first line is the prompt.) | | The guard hit its 30-second timeout | **ask** | Same: the control ran and did not decide. | -The split is deliberate, and it is a budget decision rather than a safety maximum. Oversight -capacity is finite; a control that prompts on every unparseable command trains a developer to -approve without reading, which costs the prompts that matter more than the extra coverage -gains. +A guard that fails refuses or asks; it never reports an allow it never established. **What an `ask` becomes depends on the client, and no client turns it into a silent allow.** @@ -209,9 +206,8 @@ recheck it rather than take this table's word: non-rejected arm alone — so a literal `ask` there would let the call through. **This raises no coverage grade.** A control is only as strong as its worst degradation, and -three of the five causes above still allow — so chock's in-agent controls stay at the level -the ladder gives a control that degrades to allowing. The ask is a real improvement on two -paths, not a new tier. +the empty command above still allows — so chock's in-agent controls stay at the level the +ladder gives a control that degrades to allowing until that grade is re-derived deliberately. ## Gate runner semantics diff --git a/src/chock/eval/execute.py b/src/chock/eval/execute.py index be83cf6..8011afe 100644 --- a/src/chock/eval/execute.py +++ b/src/chock/eval/execute.py @@ -17,7 +17,7 @@ from chock.eval.model import Case, CaseResult from chock.gate import runner as gate_runner from chock.gate.build import build_gate_json -from chock.gate.guard_runner import GUARD_ASK_EXIT, GUARD_VIOLATION, find_bash +from chock.gate.guard_runner import GUARD_ASK_EXIT, GUARD_VIOLATION, find_bash, interpreter_env from chock.gate.runner import GATE_LOG_ENV BLOCK = "block" @@ -140,7 +140,7 @@ def _run_guard(repo: Path, guard: Path, command: str) -> tuple[str, str]: return ERROR, f"case command has unbalanced quotes: {command}" try: - env = {**os.environ, "CHOCK_RAW_COMMAND": command} + env = {**interpreter_env(bash), "CHOCK_RAW_COMMAND": command} proc = subprocess.run( # noqa: S603 -- running the guard under test is the point of this harness [bash, str(guard), *args], cwd=str(repo), diff --git a/src/chock/gate/guard_runner.py b/src/chock/gate/guard_runner.py index 4ad3bab..2533e91 100644 --- a/src/chock/gate/guard_runner.py +++ b/src/chock/gate/guard_runner.py @@ -5,6 +5,7 @@ import json import os import shlex +import shutil import subprocess import sys from datetime import datetime, timezone @@ -16,14 +17,20 @@ PYTHON_SUFFIX = ".py" -_BASH_CANDIDATES = ( - "bash", - r"C:\Program Files\Git\usr\bin\bash.exe", - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files (x86)\Git\usr\bin\bash.exe", - "/bin/bash", - "/usr/bin/bash", -) +_POSIX_BASH = ("bash", "/bin/bash", "/usr/bin/bash") +#: Where Git for Windows installs when `git` is not on PATH to say where it is. +_GIT_FOR_WINDOWS = (r"C:\Program Files\Git", r"C:\Program Files (x86)\Git") +#: Git's own launcher first: bin/bash.exe sets up the environment usr/bin/bash.exe expects. +_GIT_BASH_DIRS = (("bin",), ("usr", "bin")) +_BASH_EXE = "bash.exe" +#: `bash` on a bare Windows PATH is System32's WSL launcher or a Store stub, neither of which +#: can see a Windows path: never a guard's interpreter. +_WINDOWS_STUB_DIRS = ("/system32/", "/windowsapps/") +#: Git's coreutils (sed, grep) live in usr/bin; a guard calling them needs it on PATH. +_COREUTILS_MARKER = "sed.exe" +_WINDOWS = "nt" +#: The bash found by the first probe, reused for every later guard in this process. +_FOUND_BASH = {} GATE_LOG_ENV = "CHOCK_GATE_LOG" _LOG_MAX_BYTES = 1_048_576 @@ -49,9 +56,41 @@ def guard_path_from_argv(argv: list[str]) -> Path | None: return None +def _git_roots() -> list[Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = shutil.which("git") + near = [Path(git).parent.parent, Path(git).parent.parent.parent] if git else [] + return [*near, *(Path(root) for root in _GIT_FOR_WINDOWS)] + + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = shutil.which("bash") + if on_path and not any(stub in on_path.lower().replace("\\", "/") for stub in _WINDOWS_STUB_DIRS): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and Path(c).is_file()] + + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(os.environ) + if os.name != _WINDOWS: + return env + home = Path(interpreter).parent + usr_bin = next((d for d in (home.parent / "usr" / "bin", home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env["PATH"] = str(usr_bin) + os.pathsep + env.get("PATH", "") + return env + + def find_bash(guard: Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if "bash" in _FOUND_BASH: + return _FOUND_BASH["bash"] + for candidate in bash_candidates(): try: proc = subprocess.run( # noqa: S603 -- probing candidate shells is this function's job [candidate, "-c", f'test -f "{guard.as_posix()}"'], @@ -62,6 +101,7 @@ def find_bash(guard: Path) -> str | None: except (OSError, subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH["bash"] = candidate return candidate return None @@ -83,18 +123,23 @@ def run_guard_detailed(guard: Path, command: str) -> tuple[str, str]: try: args = shlex.split(command) except ValueError: - print("chock: could not parse command (unbalanced quotes), not checked", file=sys.stderr) - return GUARD_UNCHECKED, "" + reason = "the command could not be parsed (unbalanced quotes), so no guard could read it" + print(f"chock: {reason}", file=sys.stderr) + return GUARD_ERRORED, reason if not args: return GUARD_UNCHECKED, "" interpreter = find_interpreter(guard) if interpreter is None: - print(f"chock: no usable interpreter found, {guard.name} not checked", file=sys.stderr) - return GUARD_UNCHECKED, "" + reason = ( + f"no usable bash was found to run {guard.name}; on Windows install Git for Windows " + "(it ships bash), elsewhere put bash on PATH" + ) + print(f"chock: {reason}", file=sys.stderr) + return GUARD_ERRORED, reason try: - env = {**os.environ, "CHOCK_RAW_COMMAND": command} + env = {**interpreter_env(interpreter), "CHOCK_RAW_COMMAND": command} proc = subprocess.run( # noqa: S603 -- running the guard script against the command is the feature [interpreter, str(guard), *args], capture_output=True, @@ -192,9 +237,9 @@ def evaluate(argv: list[str], command: str, tool: str = "") -> tuple[str, str] | else f"chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).", ) if verdict == GUARD_ERRORED: + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" return ( VERDICT_ESCALATE, - f"chock could not check this command: the {guard.stem} guard did not complete " - f"(see this hook's stderr). Approving runs it unchecked.", + f"chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.", ) return None diff --git a/tests/conftest.py b/tests/conftest.py index c5e0962..a9d8f10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,8 @@ import pytest +from chock.gate.guard_runner import bash_candidates + FRAMEWORK_ROOT = Path(__file__).resolve().parents[1] REPO_POLICIES = FRAMEWORK_ROOT / ".agents" / "policies" @@ -30,15 +32,8 @@ def bash_executable() -> str: """ if sys.platform != "win32": return shutil.which("bash") or "bash" - git = shutil.which("git") - if git: - for candidate in ( - Path(git).parent.parent / "bin" / "bash.exe", - Path(git).parent.parent / "usr" / "bin" / "bash.exe", - ): - if candidate.is_file(): - return str(candidate) - return shutil.which("bash") or "bash" + candidates = bash_candidates() + return candidates[0] if candidates else (shutil.which("bash") or "bash") def baseline_policy(policy_id: str) -> Path: diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 943e98a..8689ee4 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -639,7 +640,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -669,14 +684,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -695,16 +740,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -769,7 +816,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 1f2c65e..bb9351e 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -705,7 +705,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -735,14 +749,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -761,16 +805,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -835,7 +881,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 8718909..5becdd6 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -691,7 +692,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -721,14 +736,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -747,16 +792,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -821,7 +868,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index c8d4f06..a74b4e7 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -619,7 +620,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -649,14 +664,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -675,16 +720,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -749,7 +796,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 610d699..a3df21d 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -673,7 +674,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -703,14 +718,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -729,16 +774,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -803,7 +850,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index aad3f68..6d9223d 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -673,7 +674,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -703,14 +718,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -729,16 +774,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -803,7 +850,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 90ed41b..6d21139 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -656,7 +657,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -686,14 +701,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -712,16 +757,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -786,7 +833,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 38c3a8d..f67c4b0 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -656,7 +657,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -686,14 +701,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -712,16 +757,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -786,7 +833,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index d7eb4de..e8f6ea3 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -480,7 +481,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -510,14 +525,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -536,16 +581,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -610,7 +657,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 82ab193..6e22798 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -15,6 +15,7 @@ import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path @@ -570,7 +571,21 @@ def degrade(decision, event): PYTHON_SUFFIX = '.py' -_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -600,14 +615,44 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: + """First bash that can actually see `guard`, probed once per process; None when none can.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): try: proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) except (OSError, _chock_subprocess.SubprocessError): continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None @@ -626,16 +671,18 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: try: args = _chock_shlex.split(command) except ValueError: - print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = 'the command could not be parsed (unbalanced quotes), so no guard could read it' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) if not args: return (GUARD_UNCHECKED, '') interpreter = find_interpreter(guard) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command} proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) @@ -700,7 +747,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: - return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None _EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) diff --git a/tests/test_bash_discovery.py b/tests/test_bash_discovery.py new file mode 100644 index 0000000..c743a90 --- /dev/null +++ b/tests/test_bash_discovery.py @@ -0,0 +1,69 @@ +"""Which bash runs a guard: Git's on Windows, never the WSL launcher, found once per process.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from chock.gate import guard_runner + + +@pytest.fixture(autouse=True) +def fresh_probe(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(guard_runner, "_FOUND_BASH", {}) + + +def _touch(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + return path + + +def _as_windows(monkeypatch: pytest.MonkeyPatch, which: dict[str, str]) -> None: + """Take the Windows branch without swapping os.name, which pathlib itself reads.""" + monkeypatch.setattr(guard_runner, "_WINDOWS", os.name) + monkeypatch.setattr(guard_runner.shutil, "which", which.get) + + +def test_windows_prefers_gits_own_bin_bash_and_skips_the_wsl_launcher(tmp_path, monkeypatch) -> None: + git_root = tmp_path / "Git" + launcher = _touch(git_root / "bin" / "bash.exe") + inner = _touch(git_root / "usr" / "bin" / "bash.exe") + wsl = _touch(tmp_path / "Windows" / "System32" / "bash.exe") + _as_windows(monkeypatch, {"git": str(git_root / "cmd" / "git.exe"), "bash": str(wsl)}) + + candidates = guard_runner.bash_candidates() + + assert candidates[:2] == [str(launcher), str(inner)], "bin/bash.exe before usr/bin/bash.exe" + assert str(wsl) not in candidates, "System32's bash is the WSL launcher and cannot see the guard" + + +def test_windows_puts_gits_coreutils_on_the_guards_path(tmp_path, monkeypatch) -> None: + git_root = tmp_path / "Git" + launcher = _touch(git_root / "bin" / "bash.exe") + _touch(git_root / "usr" / "bin" / "sed.exe") + _as_windows(monkeypatch, {}) + + path = guard_runner.interpreter_env(str(launcher))["PATH"] + + assert path.split(os.pathsep)[0] == str(git_root / "usr" / "bin") + + +def test_the_bash_probe_runs_once_per_process(tmp_path, monkeypatch) -> None: + """`chock check` ran a probe per guard per case: 239 bash spawns for one check.""" + guard = _touch(tmp_path / "g.sh") + spawns = [] + real_run = subprocess.run + + def counting(*args, **kwargs): + spawns.append(args[0]) + return real_run(*args, **kwargs) + + monkeypatch.setattr(guard_runner.subprocess, "run", counting) + first = guard_runner.find_bash(guard) + assert first is not None + assert guard_runner.find_bash(_touch(tmp_path / "other.sh")) == first + assert len(spawns) == 1 diff --git a/tests/test_guard_fail_to_ask.py b/tests/test_guard_fail_to_ask.py index 348857b..f05418e 100644 --- a/tests/test_guard_fail_to_ask.py +++ b/tests/test_guard_fail_to_ask.py @@ -191,22 +191,25 @@ def test_the_ask_does_not_fire_on_a_clean_or_a_violating_guard(agent: str, tmp_p @pytest.mark.parametrize("agent", sorted(ASK_ON_THE_WIRE)) -def test_an_unparseable_command_still_allows(agent: str, tmp_path: Path, runtimes) -> None: - """The path deliberately NOT changed, pinned so nobody quietly widens the prompt.""" - guard = make_guard(tmp_path, "crash.sh", "exit 4") +def test_an_unparseable_command_asks(agent: str, tmp_path: Path, runtimes) -> None: + """`rm -rf / #'` is valid bash but not valid shlex: no guard read it, so it is not an allow.""" + guard = make_guard(tmp_path, "clean.sh", "exit 0") - verdict = decision(run(runtimes, agent, guard, "echo 'unbalanced")) + verdict = decision(run(runtimes, agent, guard, "rm -rf / #'")) - assert verdict.get("decision") in ALLOWED_WORDS[agent], "an unparseable command must not prompt" + assert verdict.get("decision") == ASK_ON_THE_WIRE[agent], "an unparsed command must not pass unchecked" + assert "could not be parsed" in verdict.get("reason", "") -def test_a_missing_bash_still_allows(tmp_path: Path, monkeypatch) -> None: - """The other path deliberately not changed, and the strongest case for leaving it.""" +def test_a_missing_bash_asks_and_names_what_to_install(tmp_path: Path, monkeypatch) -> None: + """No bash means no guard ran; the old silent allow hid the reason on a stderr nobody reads.""" guard = make_guard(tmp_path, "clean.sh", "exit 0") monkeypatch.setattr(guard_runner, "find_bash", lambda _: None) - assert guard_runner.run_guard(guard, "ls -la") == guard_runner.GUARD_UNCHECKED - assert guard_runner.evaluate(["--guard", str(guard)], "ls -la", "Bash") is None + assert guard_runner.run_guard(guard, "ls -la") == guard_runner.GUARD_ERRORED + outcome, reason = guard_runner.evaluate(["--guard", str(guard)], "ls -la", "Bash") + assert outcome == guard_runner.VERDICT_ESCALATE + assert "bash" in reason and "Git for Windows" in reason def test_a_timed_out_guard_asks(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_pretooluse_logging.py b/tests/test_pretooluse_logging.py index bbef6d3..c7c9485 100644 --- a/tests/test_pretooluse_logging.py +++ b/tests/test_pretooluse_logging.py @@ -146,12 +146,12 @@ def test_guard_outside_an_chock_repo_is_not_recorded(tmp_path: Path, monkeypatch assert read_log(tmp_path) == [] -def test_unparseable_command_is_allowed_and_unrecorded(tmp_path: Path, monkeypatch) -> None: +def test_unparseable_command_asks_and_is_unrecorded(tmp_path: Path, monkeypatch) -> None: """Real run_guard, no stubbing: a parse failure must not masquerade as a clean check.""" guard = make_guard(tmp_path, CLEAN_GUARD) - assert guard_runner.run_guard(guard, 'echo "unbalanced') == guard_runner.GUARD_UNCHECKED - assert evaluate(guard, 'echo "unbalanced') is None + assert guard_runner.run_guard(guard, 'echo "unbalanced') == guard_runner.GUARD_ERRORED + assert evaluate(guard, 'echo "unbalanced')[0] == guard_runner.VERDICT_ESCALATE assert read_log(tmp_path) == [] From eb6f44d72d18a2d39cb7c44e4b80f372af5740b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:46:39 -0400 Subject: [PATCH 03/25] Refuse when the gate or guard a hook names is missing A hook command names its compiled gate.json or guard script, so a missing one is a broken install, not "nothing to check" -- yet the write handler, the guard runner and the vendored gate runner all allowed silently, with nothing on stderr. Each now refuses with the fix (`chock sync --repo .`); the gate runner exits 2. A re-entered Stop is still let through so a refusal cannot trap the turn. Signed-off-by: Claude Co-Authored-By: Claude Opus 5.5 --- .chock/bin/claude_code.py | 22 ++++++++++++--- .chock/bin/codex_cli.py | 22 ++++++++++++--- .chock/bin/cursor.py | 22 ++++++++++++--- .chock/bin/devin.py | 22 ++++++++++++--- .chock/bin/gate.py | 7 ++++- .chock/bin/gemini_cli.py | 22 ++++++++++++--- .chock/bin/grok.py | 22 ++++++++++++--- .chock/bin/tabnine.py | 22 ++++++++++++--- .chock/bin/vscode_copilot.py | 22 ++++++++++++--- .chock/bin/windsurf.py | 22 ++++++++++++--- docs/enforcement-surfaces.md | 3 ++- src/chock/gate/guard_runner.py | 9 ++++++- src/chock/gate/runner.py | 7 ++++- src/chock/gate/write_gate.py | 27 +++++++++++++++---- tests/fixtures/runtime_goldens/antigravity.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/claude_code.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/codex_cli.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/cursor.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/devin.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/gemini_cli.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/grok.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/tabnine.py | 22 ++++++++++++--- .../runtime_goldens/vscode_copilot.py | 22 ++++++++++++--- tests/fixtures/runtime_goldens/windsurf.py | 22 ++++++++++++--- tests/test_coverage_grades.py | 3 ++- tests/test_gate_core.py | 6 +++-- tests/test_gate_logging.py | 3 ++- tests/test_pretooluse.py | 6 +++-- tests/test_write_gate_handler.py | 16 +++++++++++ 29 files changed, 414 insertions(+), 91 deletions(-) diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index bb9351e..41ad5ae 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -870,8 +870,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1268,17 +1270,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 5becdd6..e8290e5 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -857,8 +857,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1255,17 +1257,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index a74b4e7..3016870 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -785,8 +785,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1183,17 +1185,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index a3df21d..e27bfb9 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -839,8 +839,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1237,17 +1239,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index b45e601..954753d 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -512,7 +512,12 @@ def run( ) -> int: gate_path = Path(gate_path) if not gate_path.exists(): - return 0 + print( + f"gate: {gate_path} is missing -- this hook names it, so the install is incomplete. " + "Run `chock sync --repo .` to rebuild the compiled gates.", + file=sys.stderr, + ) + return 2 try: spec = json.loads(gate_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 6d9223d..59659e1 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -839,8 +839,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1237,17 +1239,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 6d21139..1e42b95 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -822,8 +822,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1220,17 +1222,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index f67c4b0..9a2c607 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -822,8 +822,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1220,17 +1222,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index e8f6ea3..f02b5cb 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -646,8 +646,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1044,17 +1046,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 6e22798..3d57413 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -736,8 +736,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1134,17 +1136,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 7d686ad..47516dd 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -162,13 +162,14 @@ skipped with `--no-verify`. The levels above grade the **outer** boundary: what the client does when chock's hook never runs or dies outright. There is an inner one too — what the hook says when it *did* run and could not reach a verdict — and the two answers are not the same. `gate/guard_runner.py` -distinguishes five such causes and answers only one of them with an allow. +distinguishes six such causes and answers only one of them with an allow. | Cause | What chock returns | Why | | :--- | :--- | :--- | | The command will not tokenize (unbalanced quotes) | **ask** | No guard read it. `rm -rf / #'` is valid bash and invalid shlex, so an allow here was a bypass. | | The command is empty after tokenizing | allow | There is nothing to check. | | No bash on the machine can resolve the guard | **ask** | No guard ran. The prompt names the fix: install Git for Windows (it ships bash), or put bash on PATH. | +| The guard the hook names is not on disk | **deny** | The hook config names it, so its absence is a broken install, not nothing to check. The reason says to run `chock sync --repo .`. | | The guard crashed, or exited a code that is none of 0, 1 or 3 | **ask** | The control was installed, reachable and runnable, and still produced no answer. (Exit 3 is not this: it is the guard asking on purpose, and its own first line is the prompt.) | | The guard hit its 30-second timeout | **ask** | Same: the control ran and did not decide. | diff --git a/src/chock/gate/guard_runner.py b/src/chock/gate/guard_runner.py index 2533e91..ccd4058 100644 --- a/src/chock/gate/guard_runner.py +++ b/src/chock/gate/guard_runner.py @@ -219,8 +219,15 @@ def log_outcome(guard: Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str = "") -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + # The hook config names this guard, so its absence is a broken install, not "nothing to check". + return ( + VERDICT_DENY, + f"chock guard {guard} is missing, so this command cannot be checked. " + "Run `chock sync --repo .` to reinstall the policy's guards.", + ) verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: "block", GUARD_ASKED: "ask", GUARD_CLEAN: "allow"} if verdict in logged: diff --git a/src/chock/gate/runner.py b/src/chock/gate/runner.py index b45e601..954753d 100644 --- a/src/chock/gate/runner.py +++ b/src/chock/gate/runner.py @@ -512,7 +512,12 @@ def run( ) -> int: gate_path = Path(gate_path) if not gate_path.exists(): - return 0 + print( + f"gate: {gate_path} is missing -- this hook names it, so the install is incomplete. " + "Run `chock sync --repo .` to rebuild the compiled gates.", + file=sys.stderr, + ) + return 2 try: spec = json.loads(gate_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index 9484dc4..cfba159 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -228,20 +228,37 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get("stop_hook_active") or raw.get("loop_count"): - # A refusal that re-entered its own stop hook would never terminate: Claude Code marks - # the re-entry `stop_hook_active`, Cursor counts it in `loop_count`. + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + # A refusal that re-entered its own stop hook would never terminate. + raw = event.raw or {} + return bool(raw.get("stop_hook_active") or raw.get("loop_count")) + + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return ( + VERDICT_DENY, + f"chock gate {gate} is missing, so this write cannot be checked. " + "Run `chock sync --repo .` to rebuild the compiled gates.", + ) + + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, "event", "")) - if gate is None or name is None or not gate.exists(): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 8689ee4..c136f4c 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -805,8 +805,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1203,17 +1205,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index bb9351e..41ad5ae 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -870,8 +870,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1268,17 +1270,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 5becdd6..e8290e5 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -857,8 +857,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1255,17 +1257,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index a74b4e7..3016870 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -785,8 +785,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1183,17 +1185,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index a3df21d..e27bfb9 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -839,8 +839,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1237,17 +1239,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 6d9223d..59659e1 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -839,8 +839,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1237,17 +1239,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 6d21139..1e42b95 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -822,8 +822,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1220,17 +1222,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index f67c4b0..9a2c607 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -822,8 +822,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1220,17 +1222,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index e8f6ea3..f02b5cb 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -646,8 +646,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1044,17 +1046,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 6e22798..3d57413 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -736,8 +736,10 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") verdict, message = run_guard_detailed(guard, command) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: @@ -1134,17 +1136,29 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event, repo_root_for(event, gate)) - raw = event.raw or {} - if raw.get('stop_hook_active') or raw.get('loop_count'): + if _reentered(event): return {} return writes_from_worktree(repo_root_for(event, gate)) +def _reentered(event): + """Whether this stop re-entered its own hook: Claude Code's `stop_hook_active`, Cursor's `loop_count`.""" + raw = event.raw or {} + return bool(raw.get('stop_hook_active') or raw.get('loop_count')) + +def _missing_gate(gate, event): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + if event.event != PRE_TOOL and _reentered(event): + return None + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" gate = gate_path_from_argv(argv) name = _EVENT_ARG.get(getattr(event, 'event', '')) - if gate is None or name is None or (not gate.exists()): + if gate is None or name is None: return None + if not gate.exists(): + return _missing_gate(gate, event) root = repo_root_for(event, gate) writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} if not writes: diff --git a/tests/test_coverage_grades.py b/tests/test_coverage_grades.py index 8f8a80a..9099a18 100644 --- a/tests/test_coverage_grades.py +++ b/tests/test_coverage_grades.py @@ -150,7 +150,8 @@ def test_chocks_degradation_constant_is_derived_from_the_running_guard(tmp_path: f"the claims in docs/enforcement-surfaces.md must move with it (per path: {observed})" ) - assert guard_runner.evaluate(["--guard", str(tmp_path / "absent.sh")], "rm -rf /", "Bash") is None + absent = guard_runner.evaluate(["--guard", str(tmp_path / "absent.sh")], "rm -rf /", "Bash") + assert _degradation(absent) == DEGRADES_TO_DENY, "a guard the hook names but cannot find is a broken install" def test_chock_does_not_award_itself_the_new_level() -> None: diff --git a/tests/test_gate_core.py b/tests/test_gate_core.py index 1e24fdd..e5ecd76 100644 --- a/tests/test_gate_core.py +++ b/tests/test_gate_core.py @@ -226,9 +226,11 @@ def test_dependency_allowlist_no_crash_on_unparseable(tmp_path: Path) -> None: assert run(gate, "pre-commit", None, repo) == 0 -def test_run_missing_gate(tmp_path: Path) -> None: +def test_run_missing_gate_refuses_and_says_to_sync(tmp_path: Path, capsys) -> None: + """The hook names the gate, so a missing one is a broken install, never "nothing to check".""" missing = tmp_path / "no.json" - assert run(missing, "pre-commit", None, tmp_path) == 0 + assert run(missing, "pre-commit", None, tmp_path) == 2 + assert "chock sync" in capsys.readouterr().err def test_run_event_not_covered(tmp_path: Path) -> None: diff --git a/tests/test_gate_logging.py b/tests/test_gate_logging.py index a9ebda5..ef7c907 100644 --- a/tests/test_gate_logging.py +++ b/tests/test_gate_logging.py @@ -119,10 +119,11 @@ def test_uncovered_event_is_not_recorded(tmp_path: Path) -> None: def test_missing_gate_is_not_recorded(tmp_path: Path) -> None: + """A missing gate refuses (a broken install), but no gate ran, so there is no outcome to log.""" init_repo(tmp_path) absent = tmp_path / ".chock" / "compiled" / "scan-secrets" / "git-hook" / "gate.json" - assert run(absent, "pre-commit", None, tmp_path) == 0 + assert run(absent, "pre-commit", None, tmp_path) == 2 assert not log_path(tmp_path).exists() diff --git a/tests/test_pretooluse.py b/tests/test_pretooluse.py index dc4daa3..3c3f1d4 100644 --- a/tests/test_pretooluse.py +++ b/tests/test_pretooluse.py @@ -94,10 +94,12 @@ def test_unparseable_input_allows(claude_code_runtime: Path) -> None: assert result.stdout.strip() == "" -def test_missing_guard_allows(tmp_path: Path, claude_code_runtime: Path) -> None: +def test_missing_guard_denies_and_says_to_sync(tmp_path: Path, claude_code_runtime: Path) -> None: + """The hook names the guard; a missing one is a broken install, never "nothing to check".""" result = _adapter(claude_code_runtime, "rm -rf /", guard=tmp_path / "absent.sh") assert result.returncode == 0 - assert not _denied(result) + assert _denied(result) + assert "chock sync" in result.stdout def test_non_command_tool_input_is_ignored(claude_code_runtime: Path) -> None: diff --git a/tests/test_write_gate_handler.py b/tests/test_write_gate_handler.py index 76372e8..b75a4b2 100644 --- a/tests/test_write_gate_handler.py +++ b/tests/test_write_gate_handler.py @@ -203,3 +203,19 @@ def test_a_path_outside_the_repository_is_left_as_given() -> None: write_gate.repo_relative("/elsewhere/.github/workflows/ci.yml", "/r") == "/elsewhere/.github/workflows/ci.yml" ) assert write_gate.repo_relative(r"D:\x\ci.yml", r"C:\Repo") == r"D:\x\ci.yml" + + +def test_a_gate_named_but_missing_refuses_and_says_to_sync(tmp_path: Path) -> None: + """The hook config names the gate, so its absence is misconfiguration, not nothing to check.""" + gate = _installed(tmp_path) + gate.unlink() + verdict = write_gate.evaluate_gate(["--gate", str(gate)], _event(path="app.py", content="x = 1\n")) + assert verdict is not None and verdict[0] == write_gate.VERDICT_DENY + assert "chock sync" in verdict[1] + + +def test_a_missing_gate_does_not_trap_a_reentered_stop(tmp_path: Path) -> None: + """A refusal on re-entry would never let the turn end.""" + gate = _installed(tmp_path) + gate.unlink() + assert write_gate.evaluate_gate(["--gate", str(gate)], _event("stop", raw={"stop_hook_active": True})) is None From 3b12fb6303a96c36e19396fd4c08c20a546af2d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:48:07 -0400 Subject: [PATCH 04/25] Never judge a gate's own policy folder or the generated tree A policy's evals and skill assets carry the content its gate refuses, so java-security refused its own adoption commit on .agents/policies/java-security/evals/suite.yaml and skill/setup.html, and every Claude Stop after it. Declarative content kinds had the same self-trigger; the catalog worked around it per policy with self-safe patterns and pragmas (#32). The runner now leaves `.chock/` (generated) and `.agents/policies//` out of every kind's material, derived from the compiled gate's own location. Another policy's folder is still judged. Signed-off-by: Claude Co-Authored-By: Claude Opus 5.5 --- .chock/bin/gate.py | 27 ++++++++++-- docs/enforcement-surfaces.md | 4 ++ src/chock/gate/runner.py | 27 ++++++++++-- tests/test_gate_own_policy.py | 77 +++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 tests/test_gate_own_policy.py diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 954753d..db36196 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -37,6 +37,7 @@ def __init__( base: str | None = None, head_ref: str | None = None, scope: Sequence[str] | None = None, + own: Sequence[str] = (), ) -> None: self.repo_root = Path(repo_root) self._push_stdin = push_stdin or "" @@ -44,6 +45,8 @@ def __init__( self.head_ref = head_ref #: The policy's applies_to.paths. Empty means every changed file is in scope. self.scope = tuple(scope or ()) + #: Path prefixes this gate never judges: chock's generated tree and its own policy folder. + self.own = tuple(own) def in_scope(self, path: str) -> bool: """Whether this policy may judge this file at all. @@ -52,6 +55,8 @@ def in_scope(self, path: str) -> bool: A gate with no scope sees every changed file, which is what every gate did before applies_to.paths was read. """ + if path.startswith(self.own): + return False return not self.scope or any(fnmatch.fnmatchcase(path, g) for g in self.scope) def _range(self) -> list[str]: @@ -138,8 +143,9 @@ def __init__( writes: Mapping[str, str], scope: Sequence[str] | None = None, added: Mapping[str, str] | None = None, + own: Sequence[str] = (), ) -> None: - super().__init__(repo_root=repo_root, scope=scope) + super().__init__(repo_root=repo_root, scope=scope, own=own) self._writes = dict(writes) self._added = dict(added or {}) @@ -465,6 +471,18 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> #: `script_base` value naming the gate file's own directory as where `params.script` lives. SCRIPT_BASE_GATE = "gate" +#: chock's generated tree: compiled gates and vendored runtimes, never an author's content. +GENERATED_PREFIX = ".chock/" +POLICIES_PREFIX = ".agents/policies/" + + +def own_paths(gate_path: Path) -> tuple[str, ...]: + """Prefixes a gate never judges: the generated tree, and its own policy's shipped files.""" + parents = gate_path.resolve().parents + if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": + return (GENERATED_PREFIX,) + return (GENERATED_PREFIX, f"{POLICIES_PREFIX}{parents[1].name}/") + def _params(gate_path: Path, spec: dict) -> dict: """The gate's params, with a packaged script gate's program located beside the gate file.""" @@ -483,11 +501,12 @@ def _context( head_ref: str | None, writes: Mapping[str, str] | None, added: Mapping[str, str] | None = None, + own: Sequence[str] = (), ) -> GateContext | None: """The material this event puts under judgement, or None when the kind cannot read it.""" if event not in AGENT_EVENTS: return GateContext( - repo_root=repo_root, push_stdin=push_stdin, base=base, head_ref=head_ref, scope=spec.get("paths") + repo_root=repo_root, push_stdin=push_stdin, base=base, head_ref=head_ref, scope=spec.get("paths"), own=own ) if spec.get("kind") not in WRITE_PATH_KINDS: print( @@ -497,7 +516,7 @@ def _context( file=sys.stderr, ) return None - return WriteContext(repo_root=repo_root, writes=writes or {}, scope=spec.get("paths"), added=added) + return WriteContext(repo_root=repo_root, writes=writes or {}, scope=spec.get("paths"), added=added, own=own) def run( @@ -534,7 +553,7 @@ def run( if kind is None: print(f"gate: unknown kind {spec.get('kind')!r}", file=sys.stderr) return 2 - ctx = _context(event, spec, repo_root, push_stdin, base, head_ref, writes, added) + ctx = _context(event, spec, repo_root, push_stdin, base, head_ref, writes, added, own_paths(gate_path)) if ctx is None: return 2 if event == "ci" and base and not ctx.rev_exists(base): diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 47516dd..aba74ce 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -218,6 +218,10 @@ non-ASCII paths arrive unescaped and are scanned like any other file. `dependenc gates match their watched manifest basenames (e.g. `package.json`) anywhere in the tree, not only at the repo root. In CI range mode, a base ref that cannot be resolved fails **closed** — the gate exits 2 rather than passing an unscanned range. +A compiled gate never judges chock's generated tree (`.chock/`) or its own policy's folder +(`.agents/policies//`): that folder's evals and references show the very content the gate +refuses, so judging them refused the policy's own adoption commit. Every other path, another +policy's folder included, is judged as before. ## Reading the coverage report diff --git a/src/chock/gate/runner.py b/src/chock/gate/runner.py index 954753d..db36196 100644 --- a/src/chock/gate/runner.py +++ b/src/chock/gate/runner.py @@ -37,6 +37,7 @@ def __init__( base: str | None = None, head_ref: str | None = None, scope: Sequence[str] | None = None, + own: Sequence[str] = (), ) -> None: self.repo_root = Path(repo_root) self._push_stdin = push_stdin or "" @@ -44,6 +45,8 @@ def __init__( self.head_ref = head_ref #: The policy's applies_to.paths. Empty means every changed file is in scope. self.scope = tuple(scope or ()) + #: Path prefixes this gate never judges: chock's generated tree and its own policy folder. + self.own = tuple(own) def in_scope(self, path: str) -> bool: """Whether this policy may judge this file at all. @@ -52,6 +55,8 @@ def in_scope(self, path: str) -> bool: A gate with no scope sees every changed file, which is what every gate did before applies_to.paths was read. """ + if path.startswith(self.own): + return False return not self.scope or any(fnmatch.fnmatchcase(path, g) for g in self.scope) def _range(self) -> list[str]: @@ -138,8 +143,9 @@ def __init__( writes: Mapping[str, str], scope: Sequence[str] | None = None, added: Mapping[str, str] | None = None, + own: Sequence[str] = (), ) -> None: - super().__init__(repo_root=repo_root, scope=scope) + super().__init__(repo_root=repo_root, scope=scope, own=own) self._writes = dict(writes) self._added = dict(added or {}) @@ -465,6 +471,18 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> #: `script_base` value naming the gate file's own directory as where `params.script` lives. SCRIPT_BASE_GATE = "gate" +#: chock's generated tree: compiled gates and vendored runtimes, never an author's content. +GENERATED_PREFIX = ".chock/" +POLICIES_PREFIX = ".agents/policies/" + + +def own_paths(gate_path: Path) -> tuple[str, ...]: + """Prefixes a gate never judges: the generated tree, and its own policy's shipped files.""" + parents = gate_path.resolve().parents + if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": + return (GENERATED_PREFIX,) + return (GENERATED_PREFIX, f"{POLICIES_PREFIX}{parents[1].name}/") + def _params(gate_path: Path, spec: dict) -> dict: """The gate's params, with a packaged script gate's program located beside the gate file.""" @@ -483,11 +501,12 @@ def _context( head_ref: str | None, writes: Mapping[str, str] | None, added: Mapping[str, str] | None = None, + own: Sequence[str] = (), ) -> GateContext | None: """The material this event puts under judgement, or None when the kind cannot read it.""" if event not in AGENT_EVENTS: return GateContext( - repo_root=repo_root, push_stdin=push_stdin, base=base, head_ref=head_ref, scope=spec.get("paths") + repo_root=repo_root, push_stdin=push_stdin, base=base, head_ref=head_ref, scope=spec.get("paths"), own=own ) if spec.get("kind") not in WRITE_PATH_KINDS: print( @@ -497,7 +516,7 @@ def _context( file=sys.stderr, ) return None - return WriteContext(repo_root=repo_root, writes=writes or {}, scope=spec.get("paths"), added=added) + return WriteContext(repo_root=repo_root, writes=writes or {}, scope=spec.get("paths"), added=added, own=own) def run( @@ -534,7 +553,7 @@ def run( if kind is None: print(f"gate: unknown kind {spec.get('kind')!r}", file=sys.stderr) return 2 - ctx = _context(event, spec, repo_root, push_stdin, base, head_ref, writes, added) + ctx = _context(event, spec, repo_root, push_stdin, base, head_ref, writes, added, own_paths(gate_path)) if ctx is None: return 2 if event == "ci" and base and not ctx.rev_exists(base): diff --git a/tests/test_gate_own_policy.py b/tests/test_gate_own_policy.py new file mode 100644 index 0000000..6b029ff --- /dev/null +++ b/tests/test_gate_own_policy.py @@ -0,0 +1,77 @@ +"""A gate never judges its own policy's shipped files, nor chock's generated tree. + +java-security refused its own adoption commit on evals/suite.yaml and skill/setup.html, and every +Stop after it: a policy's evals carry the very content its gate refuses (#32). +""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path + +import pytest +from conftest import init_repo, stage + +from chock.gate.runner import run + +POLICY_ID = "scripted" +MARKER = "FORBIDDEN" +SCRIPT = textwrap.dedent( + """\ + import json, sys + hits = sorted(p for p, t in json.load(sys.stdin)["writes"].items() if "FORBIDDEN" in t) + if hits: + print("refused: " + ", ".join(hits), file=sys.stderr) + sys.exit(1) + """ +) + + +def _gate(repo: Path, spec: dict, policy_id: str = POLICY_ID) -> Path: + gate = repo / ".chock" / "compiled" / policy_id / "git-hook" / "gate.json" + gate.parent.mkdir(parents=True, exist_ok=True) + gate.write_text(json.dumps(spec), encoding="utf-8") + return gate + + +def _script_gate(repo: Path) -> Path: + script = f".agents/policies/{POLICY_ID}/implementations/gate.py" + (repo / script).parent.mkdir(parents=True) + (repo / script).write_text(SCRIPT, encoding="utf-8") + return _gate(repo, {"kind": "script", "on": ["commit", "tool_use"], "params": {"script": script}}) + + +@pytest.mark.parametrize("event", ["pre-commit", "pre-tool-use", "stop"]) +def test_a_script_gate_does_not_refuse_its_own_evals_or_the_generated_tree(tmp_path: Path, event: str) -> None: + init_repo(tmp_path) + gate = _script_gate(tmp_path) + own = { + f".agents/policies/{POLICY_ID}/evals/suite.yaml": f"payload: {MARKER}\n", + f".agents/policies/{POLICY_ID}/skill/setup.html": f"

{MARKER}

\n", + f".chock/compiled/{POLICY_ID}/stop/gate.json": f'{{"x": "{MARKER}"}}\n', + } + if event == "pre-commit": + for path, text in own.items(): + stage(tmp_path, path, text) + assert run(gate, event, None, tmp_path) == 0 + else: + assert run(gate, event, None, tmp_path, writes=own) == 0 + + +def test_another_policys_folder_and_ordinary_files_are_still_judged(tmp_path: Path) -> None: + init_repo(tmp_path) + gate = _script_gate(tmp_path) + assert run(gate, "stop", None, tmp_path, writes={".agents/policies/other/x.yaml": MARKER}) == 1 + assert run(gate, "stop", None, tmp_path, writes={"App.java": MARKER}) == 1 + + +def test_a_content_gate_follows_the_same_rule(tmp_path: Path) -> None: + """Declarative kinds had the same self-trigger; the catalog dodged it per policy with self-safe patterns.""" + init_repo(tmp_path) + spec = {"kind": "content_regex", "on": ["commit"], "params": {"scan": "added_lines", "content_pattern": MARKER}} + gate = _gate(tmp_path, spec, "pin") + stage(tmp_path, ".agents/policies/pin/evals/suite.yaml", f"{MARKER}\n") + assert run(gate, "pre-commit", None, tmp_path) == 0 + stage(tmp_path, ".agents/policies/other/evals/suite.yaml", f"{MARKER}\n") + assert run(gate, "pre-commit", None, tmp_path) == 1 From 291f653aed7589802a033d2df6d4e7fada536f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:49:57 -0400 Subject: [PATCH 05/25] Decode gate subprocess output as UTF-8 on every console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write handler's `git status -z`, its read of the runner's stderr, and the session-start hooks-path probe decoded text with the console code page: on a cp1252 Windows console `café.py` came back as `café.py`, failed to read, and the Stop gate skipped it. They now decode UTF-8 (surrogateescape for paths, so a non-UTF-8 name round-trips). gate.py's entrypoint reconfigures stdin/stderr to UTF-8 so printing a non-ASCII match cannot raise -- a crash exits 1, which reads as a verdict. Signed-off-by: Claude Co-Authored-By: Claude Opus 5.5 --- .chock/bin/claude_code.py | 10 ++- .chock/bin/codex_cli.py | 8 ++- .chock/bin/cursor.py | 8 ++- .chock/bin/devin.py | 8 ++- .chock/bin/gate.py | 8 +++ .chock/bin/gemini_cli.py | 8 ++- .chock/bin/grok.py | 8 ++- .chock/bin/tabnine.py | 8 ++- .chock/bin/vscode_copilot.py | 8 ++- .chock/bin/windsurf.py | 8 ++- src/chock/gate/runner.py | 8 +++ src/chock/gate/sessionstart.py | 2 + src/chock/gate/write_gate.py | 7 +++ tests/fixtures/runtime_goldens/antigravity.py | 8 ++- tests/fixtures/runtime_goldens/claude_code.py | 10 ++- tests/fixtures/runtime_goldens/codex_cli.py | 8 ++- tests/fixtures/runtime_goldens/cursor.py | 8 ++- tests/fixtures/runtime_goldens/devin.py | 8 ++- tests/fixtures/runtime_goldens/gemini_cli.py | 8 ++- tests/fixtures/runtime_goldens/grok.py | 8 ++- tests/fixtures/runtime_goldens/tabnine.py | 8 ++- .../runtime_goldens/vscode_copilot.py | 8 ++- tests/fixtures/runtime_goldens/windsurf.py | 8 ++- tests/test_gate_encoding.py | 62 +++++++++++++++++++ 24 files changed, 203 insertions(+), 40 deletions(-) diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 41ad5ae..18f0e01 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1102,6 +1102,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1191,7 +1195,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1236,7 +1240,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -1318,7 +1322,7 @@ def _repo_root() -> _chock_Path: def _hooks_pre_commit(repo_root: _chock_Path) -> _chock_Path | None: """The active pre-commit hook path, honouring core.hooksPath. None when git is absent.""" try: - proc = _chock_subprocess.run([_GIT, 'rev-parse', '--git-path', 'hooks'], cwd=repo_root, capture_output=True, text=True, timeout=15, check=False) + proc = _chock_subprocess.run([_GIT, 'rev-parse', '--git-path', 'hooks'], cwd=repo_root, capture_output=True, text=True, encoding='utf-8', errors='surrogateescape', timeout=15, check=False) except (OSError, _chock_subprocess.TimeoutExpired): return None if proc.returncode != 0: diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index e8290e5..4fea531 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1089,6 +1089,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1178,7 +1182,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1223,7 +1227,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 3016870..86867cb 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1017,6 +1017,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1106,7 +1110,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1151,7 +1155,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index e27bfb9..f2c3fca 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1071,6 +1071,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1160,7 +1164,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1205,7 +1209,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index db36196..e151c00 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -600,7 +600,15 @@ def _writes(raw: str) -> dict[str, str]: return _texts(raw, "writes") +def _utf8_streams() -> None: + """Speak UTF-8 on stdin and stderr whatever the console code page, so a match cannot crash the verdict.""" + for stream in (sys.stdin, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + + def main(argv: list[str] | None = None) -> int: + _utf8_streams() parser = argparse.ArgumentParser(prog="gate.py") sub = parser.add_subparsers(dest="command", required=True) run_p = sub.add_parser("run", help="Run a compiled gate") diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 59659e1..bd64575 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1071,6 +1071,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1160,7 +1164,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1205,7 +1209,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 1e42b95..d07f8d0 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1054,6 +1054,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1143,7 +1147,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1188,7 +1192,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 9a2c607..5bb6f8d 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1054,6 +1054,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1143,7 +1147,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1188,7 +1192,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index f02b5cb..5f35eb1 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -878,6 +878,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -967,7 +971,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1012,7 +1016,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 3d57413..bd3fa66 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -968,6 +968,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1057,7 +1061,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1102,7 +1106,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/src/chock/gate/runner.py b/src/chock/gate/runner.py index db36196..e151c00 100644 --- a/src/chock/gate/runner.py +++ b/src/chock/gate/runner.py @@ -600,7 +600,15 @@ def _writes(raw: str) -> dict[str, str]: return _texts(raw, "writes") +def _utf8_streams() -> None: + """Speak UTF-8 on stdin and stderr whatever the console code page, so a match cannot crash the verdict.""" + for stream in (sys.stdin, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + + def main(argv: list[str] | None = None) -> int: + _utf8_streams() parser = argparse.ArgumentParser(prog="gate.py") sub = parser.add_subparsers(dest="command", required=True) run_p = sub.add_parser("run", help="Run a compiled gate") diff --git a/src/chock/gate/sessionstart.py b/src/chock/gate/sessionstart.py index 79a38fc..f84476a 100644 --- a/src/chock/gate/sessionstart.py +++ b/src/chock/gate/sessionstart.py @@ -30,6 +30,8 @@ def _hooks_pre_commit(repo_root: Path) -> Path | None: cwd=repo_root, capture_output=True, text=True, + encoding="utf-8", + errors="surrogateescape", timeout=15, check=False, ) diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index cfba159..295c90a 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -34,6 +34,9 @@ _PACKAGED_RUNNER = "gate.py" _GIT = "git" +#: git speaks UTF-8 whatever the console code page; a path that is not UTF-8 survives the round trip. +_UTF8 = "utf-8" +_PATH_ERRORS = "surrogateescape" _PARENT = ".." #: A Windows root is spelled with a drive (`C:`), which a POSIX root never is. _DRIVE_COLON = ":" @@ -130,6 +133,8 @@ def changed_paths(repo_root): [_GIT, "-C", str(repo_root), "status", "--porcelain=v1", "--untracked-files=all", "-z"], capture_output=True, text=True, + encoding=_UTF8, + errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False, ) @@ -184,6 +189,8 @@ def run_gate(gate, writes, event, root=None, added=None): input=json.dumps({"writes": writes, **({"added": added} if added else {})}), capture_output=True, text=True, + encoding=_UTF8, + errors="replace", timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None, diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index c136f4c..13e739f 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -1037,6 +1037,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1126,7 +1130,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1171,7 +1175,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 41ad5ae..18f0e01 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -1102,6 +1102,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1191,7 +1195,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1236,7 +1240,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -1318,7 +1322,7 @@ def _repo_root() -> _chock_Path: def _hooks_pre_commit(repo_root: _chock_Path) -> _chock_Path | None: """The active pre-commit hook path, honouring core.hooksPath. None when git is absent.""" try: - proc = _chock_subprocess.run([_GIT, 'rev-parse', '--git-path', 'hooks'], cwd=repo_root, capture_output=True, text=True, timeout=15, check=False) + proc = _chock_subprocess.run([_GIT, 'rev-parse', '--git-path', 'hooks'], cwd=repo_root, capture_output=True, text=True, encoding='utf-8', errors='surrogateescape', timeout=15, check=False) except (OSError, _chock_subprocess.TimeoutExpired): return None if proc.returncode != 0: diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index e8290e5..4fea531 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -1089,6 +1089,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1178,7 +1182,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1223,7 +1227,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 3016870..86867cb 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -1017,6 +1017,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1106,7 +1110,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1151,7 +1155,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index e27bfb9..f2c3fca 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -1071,6 +1071,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1160,7 +1164,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1205,7 +1209,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 59659e1..bd64575 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -1071,6 +1071,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1160,7 +1164,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1205,7 +1209,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 1e42b95..d07f8d0 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -1054,6 +1054,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1143,7 +1147,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1188,7 +1192,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 9a2c607..5bb6f8d 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -1054,6 +1054,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1143,7 +1147,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1188,7 +1192,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index f02b5cb..5f35eb1 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -878,6 +878,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -967,7 +971,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1012,7 +1016,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 3d57413..bd3fa66 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -968,6 +968,10 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + _PARENT = '..' _DRIVE_COLON = ':' @@ -1057,7 +1061,7 @@ def repo_relative(path, root): def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: - proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=_GATE_TIMEOUT_SECONDS, check=False) except (OSError, _chock_subprocess.SubprocessError): return [] if proc.returncode != 0: @@ -1102,7 +1106,7 @@ def run_gate(gate, writes, event, root=None, added=None): if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes, **({'added': added} if added else {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: diff --git a/tests/test_gate_encoding.py b/tests/test_gate_encoding.py index 97fdd28..2f6bdd3 100644 --- a/tests/test_gate_encoding.py +++ b/tests/test_gate_encoding.py @@ -3,10 +3,16 @@ from __future__ import annotations import json +import os +import subprocess +import sys from pathlib import Path +from types import SimpleNamespace +import pytest from conftest import init_repo, stage +from chock.gate import sessionstart, write_gate from chock.gate.runner import GateContext, run HOSTILE = { @@ -58,3 +64,59 @@ def test_a_secret_next_to_hostile_bytes_is_still_caught(tmp_path: Path) -> None: code = run(_gate(repo, "AKIA[0-9A-Z]{16}"), "pre-commit", None, repo) assert code == 1, "the gate missed a secret sitting beside a non-cp1252 character" + + +# --- the agent-side callers, under a Windows console code page ----------------------------------- + + +RUNNER = Path(__file__).resolve().parents[1] / "src" / "chock" / "gate" / "runner.py" + + +@pytest.fixture +def cp1252(monkeypatch: pytest.MonkeyPatch) -> None: + """What subprocess decodes text-mode output with on a Western-European Windows console.""" + monkeypatch.setattr(subprocess, "_text_encoding", lambda: "cp1252") + + +def test_the_turns_end_sees_a_non_ascii_path(tmp_path: Path, cp1252) -> None: + """Decoded as cp1252, `café.py` became `café.py`, failed to read, and went unjudged.""" + init_repo(tmp_path) + (tmp_path / "café.py").write_text("x = 1\n", encoding="utf-8") + assert write_gate.writes_from_worktree(tmp_path) == {"café.py": "x = 1\n"} + + +def test_the_runners_reason_reaches_the_client_intact(tmp_path: Path, cp1252) -> None: + gate = tmp_path / ".chock" / "compiled" / "p" / "pre-tool-use" / "gate.json" + gate.parent.mkdir(parents=True) + gate.write_text(_gate(tmp_path, "BAD").read_text(encoding="utf-8").replace('"commit"', '"tool_use"'), "utf-8") + (tmp_path / ".chock" / "bin").mkdir() + (tmp_path / ".chock" / "bin" / "gate.py").write_text(RUNNER.read_text(encoding="utf-8"), encoding="utf-8") + event = SimpleNamespace(event="pre_tool", path="café.py", content="BAD\n", raw={}) + verdict = write_gate.evaluate_gate(["--gate", str(gate)], event) + assert verdict is not None and "café.py" in verdict[1] + + +def test_the_hooks_path_survives_a_non_ascii_directory(tmp_path: Path, cp1252) -> None: + (tmp_path / "repo").mkdir() + repo = init_repo(tmp_path / "repo") + hooks = tmp_path / "hööks" + subprocess.run(["git", "config", "core.hooksPath", str(hooks)], cwd=repo, check=True) + assert sessionstart._hooks_pre_commit(repo) == hooks / "pre-commit" + + +def test_a_match_cannot_crash_the_runner_on_a_narrow_console(tmp_path: Path) -> None: + """Printing a non-ASCII match to a cp1252/ascii stderr raised, and exit 1 read as a verdict.""" + repo = init_repo(tmp_path) + stage(repo, "設定.md", "BAD\n") + env = {**os.environ, "PYTHONIOENCODING": "ascii", "CHOCK_GATE_LOG": "0"} + proc = subprocess.run( + [sys.executable, str(RUNNER), "run", "--gate", str(_gate(tmp_path, "BAD")), "--event", "pre-commit"], + cwd=repo, + capture_output=True, + env=env, + check=False, + ) + err = proc.stderr.decode("utf-8") + assert proc.returncode == 1 + assert "Traceback" not in err + assert "設定.md" in err From e83229fed655e9152d582e99471b9ab8ca10af9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:50:41 -0400 Subject: [PATCH 06/25] Probe PowerShell hook interpreters without terminating on a miss pre-commit.ps1 ran under $ErrorActionPreference = "Stop" and probed each candidate with `& $candidate ... 2>$null`. In Windows PowerShell 5.1 a missing command (a stale baked venv path) or stderr from a native command is then a terminating error, so the commit was blocked before a working python was ever tried. The probe now runs under "Continue", skips what Get-Command cannot resolve, wraps the call in try/catch, and counts a candidate only when it runs and imports chock (the WindowsApps stub exits 9009); "Stop" is restored for the check itself. Signed-off-by: Claude Co-Authored-By: Claude Opus 5.5 --- src/chock/hooks/data/pre-commit.ps1 | 15 +++++++++++-- tests/test_validate_hook_interpreter.py | 30 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/chock/hooks/data/pre-commit.ps1 b/src/chock/hooks/data/pre-commit.ps1 index dfc00a3..442bd5b 100644 --- a/src/chock/hooks/data/pre-commit.ps1 +++ b/src/chock/hooks/data/pre-commit.ps1 @@ -7,12 +7,23 @@ $repoRoot = git rev-parse --show-toplevel # The interpreter that installed this hook is tried first; see the bash variant for why a # bare `python` is wrong. chock normally lives in a virtualenv, and PATH usually # points somewhere else. +# Under "Stop", Windows PowerShell 5.1 turns a missing command, or stderr from `& ... 2>$null`, +# into a terminating error that blocks the commit. The probe runs under "Continue", skips what +# Get-Command cannot find, and counts a candidate only when it runs (the WindowsApps python +# stub exits 9009) and imports chock. $py = $null +$ErrorActionPreference = "Continue" foreach ($candidate in @("@CHOCK_PYTHON@", "python", "python3", "py")) { if (-not $candidate) { continue } - & $candidate -c "import chock" 2>$null - if ($LASTEXITCODE -eq 0) { $py = $candidate; break } + if (-not (Get-Command $candidate -ErrorAction SilentlyContinue)) { continue } + try { + & $candidate -c "import chock" 2>$null + if ($LASTEXITCODE -eq 0) { $py = $candidate; break } + } catch { + continue + } } +$ErrorActionPreference = "Stop" # Fail open, loudly: a missing interpreter means the check did not happen, which is not the # same as a policy violation. diff --git a/tests/test_validate_hook_interpreter.py b/tests/test_validate_hook_interpreter.py index 2a2dd64..db27280 100644 --- a/tests/test_validate_hook_interpreter.py +++ b/tests/test_validate_hook_interpreter.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shutil import subprocess import sys from pathlib import Path @@ -76,3 +77,32 @@ def test_the_hook_runs_and_allows_a_clean_repo(installed_hook: Path, tmp_path: P proc = subprocess.run([bash, str(installed_hook)], cwd=str(repo), capture_output=True, text=True, check=False) assert proc.returncode == 0, f"the hook blocked a clean repo:\n{proc.stdout}\n{proc.stderr}" assert "No module named chock" not in (proc.stdout + proc.stderr) + + +POWERSHELL_HOOK = Path(__file__).resolve().parents[1] / "src" / "chock" / "hooks" / "data" / "pre-commit.ps1" + + +def test_the_powershell_probe_cannot_end_the_hook_on_a_missing_candidate() -> None: + """Under "Stop", PS 5.1 makes `& 2>$null` a terminating error: the commit was blocked.""" + body = POWERSHELL_HOOK.read_text(encoding="utf-8") + loop = body[ + body.index("foreach ($candidate") : body.index('$ErrorActionPreference = "Stop"', body.index("foreach")) + ] + probe_start = body.index("foreach ($candidate") + assert body.rindex('$ErrorActionPreference = "Continue"', 0, probe_start) > body.index('= "Stop"') + assert "Get-Command $candidate -ErrorAction SilentlyContinue" in loop + assert "try {" in loop and "} catch {" in loop + assert loop.index("Get-Command") < loop.index("& $candidate"), "resolve before invoking" + + +@pytest.mark.skipif(not (shutil.which("pwsh") or shutil.which("powershell")), reason="no PowerShell here") +def test_the_powershell_hook_survives_a_stale_baked_interpreter(installed_hook: Path, tmp_path: Path) -> None: + body = POWERSHELL_HOOK.read_text(encoding="utf-8").replace("@CHOCK_PYTHON@", str(tmp_path / "gone" / "python.exe")) + hook = tmp_path / "hook.ps1" + hook.write_text(body, encoding="utf-8") + shell = shutil.which("pwsh") or shutil.which("powershell") + repo = installed_hook.parents[3] + proc = subprocess.run( + [shell, "-NoProfile", "-File", str(hook)], cwd=repo, capture_output=True, text=True, check=False + ) + assert "is not recognized" not in proc.stderr + proc.stdout From 27a87b7befc6f234614b3955361f81545f2979f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:05:21 -0400 Subject: [PATCH 07/25] Parse each YAML text and extract each bundled module once `chock check` on the catalog (27 policies) spent most of its time in the pure-Python YAML loader -- 1389 parses, manifests ~460 times and each eval suite 4 times -- and in ast.get_source_segment, which re-splits the whole module source on every call (2178 calls; each runtime rendered twice). - chock.yamlio.safe_load: libyaml's CSafeLoader when present, results memoised on the text itself (so a file rewritten in place can never be served stale, whatever its mtime/size), deep-copied per caller. Every yaml.safe_load in src/ now goes through it. - runtime_bundle: split each module's source once and slice segments by the node offsets (byte-identical to get_source_segment; goldens unchanged), and memoise _extract and render per process. chock check on a chock-catalog clone: ~13.5s -> ~7.4s on top of the bash probe memo (~19s before this branch). Signed-off-by: Claude Co-Authored-By: Claude Opus 5.5 --- src/chock/config.py | 3 +- src/chock/eval/context_report.py | 3 +- src/chock/eval/suites.py | 3 +- src/chock/gate/runtime_bundle.py | 14 +++++- src/chock/hooks/autocompile.py | 5 +- src/chock/manifest.py | 7 +-- src/chock/registry/core.py | 3 +- src/chock/review/policy.py | 3 +- src/chock/scaffold/add.py | 5 +- src/chock/scaffold/init.py | 3 +- src/chock/validation/checks_baseline.py | 3 +- src/chock/validation/checks_content.py | 3 +- src/chock/validation/checks_evals.py | 5 +- src/chock/validation/checks_repo.py | 3 +- src/chock/validation/checks_security.py | 3 +- src/chock/validation/loading.py | 9 ++-- src/chock/yamlio.py | 26 +++++++++++ tests/test_parse_once.py | 62 +++++++++++++++++++++++++ 18 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 src/chock/yamlio.py create mode 100644 tests/test_parse_once.py diff --git a/src/chock/config.py b/src/chock/config.py index b7c7ff6..46de37d 100644 --- a/src/chock/config.py +++ b/src/chock/config.py @@ -7,6 +7,7 @@ import yaml +from chock import yamlio from chock.compile.surface_kinds import Surface from chock.vendors import CHOCK_AGENT @@ -19,7 +20,7 @@ def load_config(repo_root: Path | str) -> dict[str, Any]: path = Path(repo_root) / CONFIG_DIR / CONFIG_NAME if not path.exists(): return {} - return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return yamlio.safe_load(path.read_text(encoding="utf-8")) or {} def agents_from_config(repo_root: Path) -> list[str]: diff --git a/src/chock/eval/context_report.py b/src/chock/eval/context_report.py index 6032c93..3d38cb8 100644 --- a/src/chock/eval/context_report.py +++ b/src/chock/eval/context_report.py @@ -15,6 +15,7 @@ import yaml +from chock import yamlio from chock.compile.emitters.advisory import advisory_lines from chock.emit import write_generated, write_generated_json from chock.eval.suites import Policy, discover_policies @@ -95,7 +96,7 @@ def path_slug(text: str) -> str: def _read_yaml(path: Path) -> dict[str, Any]: - return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return yamlio.safe_load(path.read_text(encoding="utf-8")) or {} def tier3_cases(policy: Policy) -> list[Tier3Case]: diff --git a/src/chock/eval/suites.py b/src/chock/eval/suites.py index 30857c6..9b1a87b 100644 --- a/src/chock/eval/suites.py +++ b/src/chock/eval/suites.py @@ -7,6 +7,7 @@ import yaml +from chock import yamlio from chock.compile.emitters import GUARD_SUFFIXES, SCRIPT_EVENTS from chock.eval.model import Case from chock.manifest import load_manifest @@ -22,7 +23,7 @@ def _suite_doc(policy_dir: Path) -> dict[str, Any]: if not suite_file.exists(): return {} try: - doc = yaml.safe_load(suite_file.read_text(encoding="utf-8")) or {} + doc = yamlio.safe_load(suite_file.read_text(encoding="utf-8")) or {} except (yaml.YAMLError, OSError): return {} if not isinstance(doc, dict): diff --git a/src/chock/gate/runtime_bundle.py b/src/chock/gate/runtime_bundle.py index 4d4c930..0bc0f67 100644 --- a/src/chock/gate/runtime_bundle.py +++ b/src/chock/gate/runtime_bundle.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import functools import inspect import re @@ -47,14 +48,24 @@ def visit_Attribute(self, node: ast.Attribute) -> ast.AST: return node +def _segment(lines: list[bytes], node: ast.stmt) -> str: + """`ast.get_source_segment` over lines split once: it re-splits the whole source on every call.""" + chunk = lines[node.lineno - 1 : node.end_lineno] + chunk[-1] = chunk[-1][: node.end_col_offset] + chunk[0] = chunk[0][node.col_offset :] + return b"".join(chunk).decode("utf-8") + + +@functools.cache def _extract(module) -> str: """Every top-level def/assignment in `module`, source order, minus its own imports --""" source = inspect.getsource(module) tree = ast.parse(source) + lines = source.encode("utf-8").splitlines(keepends=True) # bytes split on \r, \n only, as ast counts lines segments = [] for node in tree.body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Assign)): - segments.append(_Renamer().visit(ast.parse(ast.get_source_segment(source, node)))) + segments.append(_Renamer().visit(ast.parse(_segment(lines, node)))) return "\n\n".join(ast.unparse(seg) for seg in segments) + "\n" @@ -103,6 +114,7 @@ def _needed_imports(handler_source: str) -> str: return "\n".join(lines) + ("\n" if lines else "") +@functools.cache def render(agent: str) -> str: """Render `agent`'s self-contained vendored runtime: agentseam's bundle, chock's""" source = bundler.bundle(agent) diff --git a/src/chock/hooks/autocompile.py b/src/chock/hooks/autocompile.py index 7cb599d..cd310cf 100644 --- a/src/chock/hooks/autocompile.py +++ b/src/chock/hooks/autocompile.py @@ -4,8 +4,7 @@ from pathlib import Path -import yaml - +from chock import yamlio from chock.compile.surfaces import Surface from chock.config import agents_from_config, load_config, policy_status from chock.output import warn @@ -23,7 +22,7 @@ def compile_one_dropin( mf_path = pack_dir / "manifest.yaml" if not mf_path.exists(): return False - manifest = yaml.safe_load(mf_path.read_text(encoding="utf-8")) or {} + manifest = yamlio.safe_load(mf_path.read_text(encoding="utf-8")) or {} if manifest.get("artifact") != "hook": return False diff --git a/src/chock/manifest.py b/src/chock/manifest.py index 2b6620e..f58ada0 100644 --- a/src/chock/manifest.py +++ b/src/chock/manifest.py @@ -7,6 +7,7 @@ import yaml +from chock import yamlio from chock.skill_metadata import _as_bool, _as_list, _chock_metadata CANONICAL_MANIFEST = "manifest.yaml" @@ -60,7 +61,7 @@ def _load_interface( return {} try: - data = yaml.safe_load(interface_path.read_text(encoding="utf-8")) or {} + data = yamlio.safe_load(interface_path.read_text(encoding="utf-8")) or {} except (yaml.YAMLError, OSError) as exc: if warnings is not None: warnings.append(f"interface.yaml parse error: {exc}") @@ -191,7 +192,7 @@ def _parse_skill_frontmatter(text: str) -> dict[str, Any]: if end == -1: return {} - return yaml.safe_load("\n".join(lines[:end])) or {} + return yamlio.safe_load("\n".join(lines[:end])) or {} def normalize_manifest(data: dict[str, Any]) -> dict[str, Any]: @@ -212,7 +213,7 @@ def load_manifest_file( frontmatter = _parse_skill_frontmatter(text) return _project_skill_frontmatter(manifest_path, frontmatter, warnings) - data = yaml.safe_load(text) or {} + data = yamlio.safe_load(text) or {} return normalize_manifest(data) diff --git a/src/chock/registry/core.py b/src/chock/registry/core.py index ef6e242..4804e9a 100644 --- a/src/chock/registry/core.py +++ b/src/chock/registry/core.py @@ -10,6 +10,7 @@ import yaml +from chock import yamlio from chock.emit import write_generated_json from chock.manifest import ( ManifestSourceError, @@ -79,7 +80,7 @@ def _append_entry(artifact_dir: Path, default_type: str | None) -> None: art = default_type if art is None: try: - data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {} + data = yamlio.safe_load(manifest.read_text(encoding="utf-8")) or {} art = data.get("artifact", "unknown") except (yaml.YAMLError, OSError): art = "unknown" diff --git a/src/chock/review/policy.py b/src/chock/review/policy.py index 7d0c876..b8a3df9 100644 --- a/src/chock/review/policy.py +++ b/src/chock/review/policy.py @@ -10,6 +10,7 @@ import yaml +from chock import yamlio from chock.config import CONFIG_DIR, CONFIG_NAME, load_config BUILTIN_CHECKS: dict[str, list[str]] = { @@ -45,7 +46,7 @@ def _review_at(root: Path, ref: str) -> dict[str, Any] | None: return None text = shown.stdout try: - return _review_section(yaml.safe_load(text)) + return _review_section(yamlio.safe_load(text)) except yaml.YAMLError as exc: msg = f"{CONFIG_DIR}/{CONFIG_NAME} at {ref} is not valid YAML: {exc}" raise ReviewPolicyError(msg) from exc diff --git a/src/chock/scaffold/add.py b/src/chock/scaffold/add.py index 21384ec..3336e26 100644 --- a/src/chock/scaffold/add.py +++ b/src/chock/scaffold/add.py @@ -13,6 +13,7 @@ import yaml +from chock import yamlio from chock.config import agents_from_config as _agents_from_config from chock.lock import compute_pack_hash, read_lock, write_lock from chock.output import error @@ -107,7 +108,7 @@ def _reject_foreign_id(pack_dir: Path, artifact_id: str) -> None: if not manifest.is_file(): return try: - declared = (yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}).get("id") + declared = (yamlio.safe_load(manifest.read_text(encoding="utf-8")) or {}).get("id") except (yaml.YAMLError, AttributeError) as exc: msg = f"{artifact_id}: the catalog pack's manifest.yaml does not parse ({exc}). Nothing was installed." raise IntegrityError(msg) from exc @@ -123,7 +124,7 @@ def locate(catalog_root: Path, artifact_id: str) -> tuple[Path, Path]: registry = catalog_root / "registry.yaml" if registry.exists(): - data = yaml.safe_load(registry.read_text(encoding="utf-8")) or {} + data = yamlio.safe_load(registry.read_text(encoding="utf-8")) or {} for entry in data.get("policies", []) or []: if entry.get("id") == artifact_id and entry.get("path"): candidate = catalog_root / entry["path"] diff --git a/src/chock/scaffold/init.py b/src/chock/scaffold/init.py index 6ad4c38..e9fed51 100644 --- a/src/chock/scaffold/init.py +++ b/src/chock/scaffold/init.py @@ -9,6 +9,7 @@ import yaml +from chock import yamlio from chock.compile.surfaces import AGENTS_ARG_REQUIRED_MSG from chock.config import agents_from_config, load_config from chock.emit import write_generated @@ -54,7 +55,7 @@ def _fresh_config(agents: list[str], *, agent_agnostic: bool) -> dict[str, objec agent_agnostic="true" if agent_agnostic else "false", onboarded_at=datetime.now(timezone.utc).isoformat(), ) - return yaml.safe_load(text) or {} # type: ignore[return-value] + return yamlio.safe_load(text) or {} # type: ignore[return-value] def _fresh_policies(repo_root: Path, fresh: dict[str, object]) -> dict[str, object]: diff --git a/src/chock/validation/checks_baseline.py b/src/chock/validation/checks_baseline.py index 40b7c8a..e076ff6 100644 --- a/src/chock/validation/checks_baseline.py +++ b/src/chock/validation/checks_baseline.py @@ -10,6 +10,7 @@ import yaml +from chock import yamlio from chock.config import policy_status from chock.validation.report import Finding, Report, emit @@ -32,7 +33,7 @@ def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess: def _parse(text: str, where: str) -> dict[str, Any]: try: - loaded = yaml.safe_load(text) + loaded = yamlio.safe_load(text) except yaml.YAMLError as exc: msg = f"{CONFIG_REL.as_posix()} at {where} is not valid YAML: {exc}" raise BaselineError(msg) from exc diff --git a/src/chock/validation/checks_content.py b/src/chock/validation/checks_content.py index a80ea73..24f93c5 100644 --- a/src/chock/validation/checks_content.py +++ b/src/chock/validation/checks_content.py @@ -8,6 +8,7 @@ import yaml +from chock import yamlio from chock.manifest import AGENT_SPECIFIC_VOCABULARY_KEY, CANONICAL_MANIFEST, resolve_manifest_path from chock.validation.loading import ( ARTIFACT_TYPES, @@ -225,7 +226,7 @@ def extract_skill_md_description(skill_md: Path) -> str | None: if len(parts) < _FRONTMATTER_SPLIT_PARTS: return None try: - front = yaml.safe_load(parts[1]) or {} + front = yamlio.safe_load(parts[1]) or {} except yaml.YAMLError: return None return front.get("description") diff --git a/src/chock/validation/checks_evals.py b/src/chock/validation/checks_evals.py index c0767a4..279af9a 100644 --- a/src/chock/validation/checks_evals.py +++ b/src/chock/validation/checks_evals.py @@ -7,6 +7,7 @@ import yaml +from chock import yamlio from chock.validation.loading import ( ARTIFACT_TYPES, BUDGETS, @@ -19,7 +20,7 @@ def _schema_validate_suite(suite_file: Path, report: Report) -> None: """Validate an eval suite file against the canonical eval schema.""" try: - doc = yaml.safe_load(suite_file.read_text(encoding="utf-8")) or {} + doc = yamlio.safe_load(suite_file.read_text(encoding="utf-8")) or {} except yaml.YAMLError as exc: report.add(Finding(str(suite_file), "eval_first", "error", f"Invalid YAML: {exc}")) return @@ -49,7 +50,7 @@ def check_eval_first(artifact_dir: Path, manifest: dict[str, Any], artifact_type _schema_validate_suite(suite_file, report) try: - doc = yaml.safe_load(suite_file.read_text(encoding="utf-8")) or {} + doc = yamlio.safe_load(suite_file.read_text(encoding="utf-8")) or {} except yaml.YAMLError as exc: report.add(Finding(str(suite_file), "eval_first", "error", f"Invalid YAML: {exc}")) return diff --git a/src/chock/validation/checks_repo.py b/src/chock/validation/checks_repo.py index e6972f5..f99af52 100644 --- a/src/chock/validation/checks_repo.py +++ b/src/chock/validation/checks_repo.py @@ -10,6 +10,7 @@ import yaml +from chock import yamlio from chock.index.builder import max_tokens_for from chock.index.cli import is_stale from chock.scaffold.agents_md import POINTER_BLOCK, POINTER_END, POINTER_START @@ -123,7 +124,7 @@ def _resolve_id(root: Path, policy_id: str) -> bool: if not manifest.exists(): continue try: - data = yaml.safe_load(_frontmatter(manifest)) or {} + data = yamlio.safe_load(_frontmatter(manifest)) or {} except yaml.YAMLError: continue if isinstance(data, dict) and data.get("id") == policy_id: diff --git a/src/chock/validation/checks_security.py b/src/chock/validation/checks_security.py index 345f146..dd718e6 100644 --- a/src/chock/validation/checks_security.py +++ b/src/chock/validation/checks_security.py @@ -8,6 +8,7 @@ import yaml +from chock import yamlio from chock.manifest import CANONICAL_MANIFEST, CONTENT_INSTRUCTIONS_KEY from chock.validation.loading import ( find_manifest, @@ -31,7 +32,7 @@ def _split_eval_suite(path: Path) -> tuple[str, list[str]] | None: if "evals" not in path.parts: return None try: - doc = yaml.safe_load(path.read_text(encoding="utf-8")) + doc = yamlio.safe_load(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, yaml.YAMLError): return None if not isinstance(doc, dict): diff --git a/src/chock/validation/loading.py b/src/chock/validation/loading.py index 6547c00..9ff59ef 100644 --- a/src/chock/validation/loading.py +++ b/src/chock/validation/loading.py @@ -10,6 +10,7 @@ import yaml from referencing import Registry, Resource +from chock import yamlio from chock.manifest import MANIFEST_NAMES, SKILL_MD from chock.resources import package_data_dir from chock.validation.report import Finding, Report @@ -53,7 +54,7 @@ def load_schema(name: str) -> dict[str, Any]: with path.open("r", encoding="utf-8") as f: if name.endswith(".json"): return json.load(f) - return yaml.safe_load(f) + return yamlio.safe_load(f) ARTIFACT_TYPES: frozenset[str] = frozenset(load_schema(MANIFEST_SCHEMA)["properties"]["artifact"]["enum"]) @@ -89,7 +90,7 @@ def discover_artifacts(root: Path) -> Iterable[tuple[str, Path]]: if not manifest.exists(): continue try: - data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {} + data = yamlio.safe_load(manifest.read_text(encoding="utf-8")) or {} except yaml.YAMLError: yield ("unknown", root) return @@ -121,7 +122,7 @@ def _yield_policy_dir(sub: Path, default_type: str | None) -> None: if not manifest.exists(): continue try: - data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + data = yamlio.safe_load(manifest.read_text(encoding="utf-8")) art = (data or {}).get("artifact") except yaml.YAMLError: yield ("unknown", sub) @@ -138,7 +139,7 @@ def _yield_policy_dir(sub: Path, default_type: str | None) -> None: for eval_file in base.rglob("suite.yaml"): if not rel_dir.startswith(".agents/"): try: - doc = yaml.safe_load(eval_file.read_text(encoding="utf-8")) + doc = yamlio.safe_load(eval_file.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError): continue if not isinstance(doc, dict) or not ("eval_suite" in doc or "suite" in doc): diff --git a/src/chock/yamlio.py b/src/chock/yamlio.py new file mode 100644 index 0000000..d017fff --- /dev/null +++ b/src/chock/yamlio.py @@ -0,0 +1,26 @@ +"""YAML parsing for chock: libyaml's safe loader when present, each distinct text parsed once.""" + +from __future__ import annotations + +import copy +import functools +from typing import IO, Any + +import yaml + +#: libyaml's SafeLoader: the same safe schema, an order of magnitude faster than pure Python. +SAFE_LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader) + +_PARSED_TEXTS = 1024 + + +@functools.lru_cache(maxsize=_PARSED_TEXTS) +def _parsed(text: str) -> Any: + """The document `text` holds. Keyed on the text itself, so a rewritten file can never read stale.""" + return yaml.load(text, Loader=SAFE_LOADER) # noqa: S506 -- SAFE_LOADER is a SafeLoader + + +def safe_load(source: str | IO[str]) -> Any: + """`yaml.safe_load`, with a private copy of the result so no caller sees another's edits.""" + text = source if isinstance(source, str) else source.read() + return copy.deepcopy(_parsed(text)) diff --git a/tests/test_parse_once.py b/tests/test_parse_once.py new file mode 100644 index 0000000..5c235ae --- /dev/null +++ b/tests/test_parse_once.py @@ -0,0 +1,62 @@ +"""`chock check` parses each YAML text and extracts each bundled module once, and never reads stale.""" + +from __future__ import annotations + +import ast +import inspect +import os + +import yaml + +from chock import yamlio +from chock.gate import edit_image, guard_runner, patch_image, runtime_bundle, sessionstart, write_gate + + +def test_the_libyaml_loader_is_used_when_present() -> None: + assert yamlio.SAFE_LOADER is getattr(yaml, "CSafeLoader", yaml.SafeLoader) + + +def test_a_text_is_parsed_once(monkeypatch) -> None: + yamlio._parsed.cache_clear() + calls = [] + real = yaml.load + monkeypatch.setattr(yaml, "load", lambda *a, **k: calls.append(a) or real(*a, **k)) + text = "id: parse-once-probe\nlist: [1, 2]\n" + assert yamlio.safe_load(text) == yamlio.safe_load(text) == {"id": "parse-once-probe", "list": [1, 2]} + assert len(calls) == 1 + + +def test_a_caller_cannot_edit_another_callers_document() -> None: + first = yamlio.safe_load("list: [1]\n") + first["list"].append(2) + assert yamlio.safe_load("list: [1]\n") == {"list": [1]} + + +def test_a_file_rewritten_in_place_is_never_read_stale(tmp_path) -> None: + """Same size, same mtime: a stat-keyed cache would serve the old document; a text key cannot.""" + path = tmp_path / "manifest.yaml" + path.write_text("id: aaaa\n", encoding="utf-8") + stamp = path.stat().st_mtime_ns + assert yamlio.safe_load(path.read_text(encoding="utf-8")) == {"id": "aaaa"} + path.write_text("id: bbbb\n", encoding="utf-8") + os.utime(path, ns=(stamp, stamp)) + assert yamlio.safe_load(path.read_text(encoding="utf-8")) == {"id": "bbbb"} + + +def test_the_bundle_segments_match_ast_exactly() -> None: + """The one-split slicer must return what ast.get_source_segment would, byte for byte.""" + for module in (guard_runner, edit_image, patch_image, write_gate, sessionstart): + source = inspect.getsource(module) + lines = source.encode("utf-8").splitlines(keepends=True) + for node in ast.parse(source).body: + assert runtime_bundle._segment(lines, node) == ast.get_source_segment(source, node) + + +def test_a_runtime_is_rendered_once_per_process(monkeypatch) -> None: + runtime_bundle.render.cache_clear() + calls = [] + real = runtime_bundle.bundler.bundle + monkeypatch.setattr(runtime_bundle.bundler, "bundle", lambda agent: calls.append(agent) or real(agent)) + assert runtime_bundle.render("claude_code") == runtime_bundle.render("claude_code") + assert calls == ["claude_code"] + runtime_bundle.render.cache_clear() From f8c865254fda38b16b47861c758d2765e7221865 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:18:59 -0400 Subject: [PATCH 08/25] Run every agent hook through a committed launcher, not a baked interpreter Hook commands in committed agent configs named the absolute Python of whoever last ran `chock sync` ("/usr/bin/python3", even a deleted "/tmp/.../python3"). On any other machine every hook failed to start: Claude Code and Codex treat that as a non-blocking error and run the tool unchecked, so the gate silently did nothing -- on Windows, always. Relative paths also broke when a session started below the repo root (Codex then denied every command). Every in-agent command is now one string that bash, PowerShell and cmd.exe read identically: git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook .chock/bin/.py ... git runs the alias from the repository's top level under its own sh, so the repo-relative paths always resolve and no per-machine setup is needed (a fresh clone's SessionStart self-heal can run). The committed launcher picks the first Python that actually executes: `chock.python` from the clone's local .git/config (written by sync), then python3/python/py -- a candidate must run, so the Windows Store python3 alias is skipped. With none it refuses (exit 2) and says how to fix it. Cursor's pre-tool and shell entries now carry failClosed, so a hook that cannot start blocks instead of allowing. The interpreter bake/normalise machinery and the per-shell templates are gone: an installed entry is current exactly when it equals the compiled one. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .../compile/emitters/data/agent_hook_bash.sh | 1 - .../emitters/data/agent_hook_powershell.ps1 | 1 - src/chock/compile/emitters/in_agent.py | 56 +++++---------- src/chock/compile/emitters/in_agent_hooks.py | 19 +++-- src/chock/hooks/data/launch.sh | 22 ++++++ src/chock/hooks/in_agent_generic.py | 62 ++--------------- src/chock/hooks/in_agent_install.py | 4 +- src/chock/hooks/in_agent_merged.py | 69 +------------------ src/chock/hooks/launch.py | 64 +++++++++++++++++ src/chock/hooks/runtime_vendor.py | 8 ++- src/chock/hooks/sessionstart_install.py | 21 +----- src/chock/lifecycle.py | 5 ++ 12 files changed, 139 insertions(+), 193 deletions(-) delete mode 100644 src/chock/compile/emitters/data/agent_hook_bash.sh delete mode 100644 src/chock/compile/emitters/data/agent_hook_powershell.ps1 create mode 100644 src/chock/hooks/data/launch.sh create mode 100644 src/chock/hooks/launch.py diff --git a/src/chock/compile/emitters/data/agent_hook_bash.sh b/src/chock/compile/emitters/data/agent_hook_bash.sh deleted file mode 100644 index 4b5f102..0000000 --- a/src/chock/compile/emitters/data/agent_hook_bash.sh +++ /dev/null @@ -1 +0,0 @@ -repo="$(git rev-parse --show-toplevel)"; PY="$(command -v python3 || command -v python || command -v py)"; [ -n "$PY" ] || { echo "chock: no python interpreter found" >&2; exit 1; }; exec "$PY" "$repo/__ADAPTER__" --guard "$repo/__GUARD__" diff --git a/src/chock/compile/emitters/data/agent_hook_powershell.ps1 b/src/chock/compile/emitters/data/agent_hook_powershell.ps1 deleted file mode 100644 index 2019d36..0000000 --- a/src/chock/compile/emitters/data/agent_hook_powershell.ps1 +++ /dev/null @@ -1 +0,0 @@ -$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py "$repo/__ADAPTER__" --guard "$repo/__GUARD__"; exit $LASTEXITCODE diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 0a8a87a..3d06034 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -7,15 +7,13 @@ from typing import Any from chock import vendors -from chock.compile.emitters import DATA_DIR, GUARD_SUFFIXES, policy_rel_path +from chock.compile.emitters import GUARD_SUFFIXES, policy_rel_path from chock.compile.emitters.advisory import repo_root_from_output from chock.compile.emitters.in_agent_hooks import TIMEOUT_SECONDS, cursor_entry, generic_hooks_file, hook_entry from chock.emit import write_generated_json from chock.gate.build import build_gate_json from chock.gate.runner import WRITE_PATH_KINDS - -_BASH_TEMPLATE = DATA_DIR.joinpath("agent_hook_bash.sh").read_text(encoding="utf-8").rstrip("\n") -_POWERSHELL_TEMPLATE = DATA_DIR.joinpath("agent_hook_powershell.ps1").read_text(encoding="utf-8").rstrip("\n") +from chock.hooks.launch import hook_command GUARD_SCRIPTS = { "block-destructive-commands": "block-destructive.sh", @@ -39,11 +37,6 @@ def _guard_script(policy_dir: Path, policy_id: str) -> str | None: MATCHER = vendors.shell_matcher("claude_code") assert MATCHER is not None # noqa: S101 -- import-time upstream-data invariant, not request handling -#: Wire token Claude Code substitutes for the repo root, read from agentseam's vendor -#: config (`repo_root_token`) instead of chock's own hardcoded copy. -PROJECT_DIR_TOKEN = vendors.repo_root_token("claude_code") -assert PROJECT_DIR_TOKEN is not None # noqa: S101 -- import-time upstream-data invariant, not request handling - # Witnessed overrides: chock's agent-hooks file speaks `preToolUse` with bash/powershell/ # timeoutSec entry keys (live deny, data/witnesses.json: vscode_copilot x agent-hooks); # agentseam 0.2.0 records `PreToolUse` with {type, command, windows} instead. The facts @@ -102,20 +95,22 @@ def _tool_use_gate(policy_dir: Path, output_dir: Path) -> dict[str, Any] | None: def _guard_fragments(policy_dir: Path, script: str, output_dir: Path) -> list[Path]: """The shell-command fragments, one per wired vendor. Behaviour unchanged.""" - rel = policy_rel_path(policy_dir) - guard = f"{PROJECT_DIR_TOKEN}/{rel}/implementations/{script}" + guard = f"{policy_rel_path(policy_dir)}/implementations/{script}" written: list[Path] = [] for vendor, name, build in ( ("claude_code", "pretooluse.json", lambda cmd: hook_entry(cmd, matcher=MATCHER)), - ("cursor", "cursor-hooks.json", lambda cmd: {vendors.shell_gate_event("cursor"): [cursor_entry(cmd)]}), + ( + "cursor", + "cursor-hooks.json", + lambda cmd: {vendors.shell_gate_event("cursor"): [cursor_entry(cmd, fail_closed=True)]}, + ), ): - adapter = f"{PROJECT_DIR_TOKEN}/{_adapter_rel(vendor)}" - command = f'@CHOCK_PYTHON@ "{adapter}" --guard "{guard}"' + command = hook_command(_adapter_rel(vendor), "--guard", guard) dest = output_dir / name write_generated_json(dest, build(command)) written.append(dest) for vendor in GENERIC_VENDORS: - command = f'@CHOCK_PYTHON@ "{_adapter_rel(vendor)}" --guard "{rel}/implementations/{script}"' + command = hook_command(_adapter_rel(vendor), "--guard", guard) dest = output_dir / f"{vendor}-hooks.json" write_generated_json(dest, generic_hooks_file(vendor, command)) written.append(dest) @@ -133,19 +128,18 @@ def _gate_fragments(policy_id: str, spec: dict[str, Any], output_dir: Path) -> l write_generated_json(gate, spec) written: list[Path] = [gate] - reference = f"{PROJECT_DIR_TOKEN}/{_compiled_rel(policy_id)}/{GATE_FILE}" + reference = f"{_compiled_rel(policy_id)}/{GATE_FILE}" for vendor in sorted(vendors.in_agent_vendors()): matcher = vendors.write_matcher(vendor) if matcher is None: continue - adapter = f"{PROJECT_DIR_TOKEN}/{_adapter_rel(vendor)}" - command = f'@CHOCK_PYTHON@ "{adapter}" --gate "{reference}"' + command = hook_command(_adapter_rel(vendor), "--gate", reference) name = WRITE_FRAGMENT if vendor == "claude_code" else f"{vendor}-write-hooks.json" dest = output_dir / name if vendors.hook_entry_flat(vendor): # A flat entry carries no matcher: the runtime answers every tool under the event # and judges only a write it recognises; an unmatched tool is allowed unremarked. - doc: dict[str, Any] = {vendors.pre_tool_event(vendor): [cursor_entry(command)]} + doc: dict[str, Any] = {vendors.pre_tool_event(vendor): [cursor_entry(command, fail_closed=True)]} else: doc = hook_entry(command, matcher=matcher) write_generated_json(dest, doc) @@ -175,15 +169,11 @@ def _stop_fragments(policy_id: str, spec: dict[str, Any], output_dir: Path) -> l written: list[Path] = [gate] for vendor in vendors.stop_vendors(): - token = vendors.repo_root_token(vendor) - root = f"{token}/" if token else "" - command = f'@CHOCK_PYTHON@ "{root}{_adapter_rel(vendor)}" --gate "{root}{_stop_rel(policy_id)}/{GATE_FILE}"' + command = hook_command(_adapter_rel(vendor), "--gate", f"{_stop_rel(policy_id)}/{GATE_FILE}") if vendor == "claude_code": dest, doc = output_dir / STOP_FRAGMENT, hook_entry(command) elif vendors.hook_entry_flat(vendor): - # Cursor's fragment is the event's entry list, the shape its merged installer reads, - # rooted the way its shell and write entries are so the installer recognises it. - command = f'@CHOCK_PYTHON@ "{PROJECT_DIR_TOKEN}/{_adapter_rel(vendor)}" --gate "{PROJECT_DIR_TOKEN}/{_stop_rel(policy_id)}/{GATE_FILE}"' + # Cursor's fragment is the event's entry list, the shape its merged installer reads. dest, doc = output_dir / f"{vendor}-hooks.json", {vendors.stop_event(vendor): [cursor_entry(command)]} else: dest, doc = output_dir / f"{vendor}-hooks.json", vendors.stop_hook_config(vendor, command) @@ -221,25 +211,15 @@ def emit_pre_tool_use(policy_dir: Path, output_dir: Path, manifest: dict[str, An return _gate_fragments(str(policy_id), spec or {}, output_dir) -def _bash_command(adapter: str, guard: str) -> str: - return _BASH_TEMPLATE.replace("__ADAPTER__", adapter).replace("__GUARD__", guard) - - -def _powershell_command(adapter: str, guard: str) -> str: - return _POWERSHELL_TEMPLATE.replace("__ADAPTER__", adapter).replace("__GUARD__", guard) - - def build_entry(policy_dir: Path, manifest: dict[str, Any]) -> dict[str, Any] | None: """The single agent-hooks entry for one policy, or None when it has no guard script.""" policy_id = manifest.get("id", policy_dir.name) script = _guard_script(policy_dir, policy_id) if not script: return None - rel = policy_rel_path(policy_dir) - adapter = _adapter_rel("vscode_copilot") - guard = f"{rel}/implementations/{script}" - bash = _bash_command(adapter, guard) - powershell = _powershell_command(adapter, guard) + # One string for both keys: the launcher form reads the same under bash and PowerShell. + command = hook_command(_adapter_rel("vscode_copilot"), "--guard", f"{policy_rel_path(policy_dir)}/implementations/{script}") + bash = powershell = command return { "type": "command", "matcher": SHELL_MATCHER, diff --git a/src/chock/compile/emitters/in_agent_hooks.py b/src/chock/compile/emitters/in_agent_hooks.py index 1606bc9..f476d0e 100644 --- a/src/chock/compile/emitters/in_agent_hooks.py +++ b/src/chock/compile/emitters/in_agent_hooks.py @@ -12,10 +12,8 @@ def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: """`vendor`'s full hook-config document for one guard command, agentseam's rendering. - Paths inside `command` are repo-relative: no repo-root token is recorded upstream for - these vendors (the `${CLAUDE_PROJECT_DIR}` gap), so the entry resolves only where the - vendor runs hooks from the repo root -- the same condition under which the relative - adapter path resolves at all. + Paths inside `command` are repo-relative and resolve wherever the session started: the + launcher form has git run them from the repository's top level. """ return vendors.pre_tool_hook_config(vendor, command, matcher=vendors.shell_matcher(vendor)) @@ -42,9 +40,16 @@ def hooks_map_file(vendor: str, command: str) -> dict[str, Any]: return event_map if vendors.hook_entry_bare(vendor) else {"hooks": event_map} -def cursor_entry(command: str) -> dict[str, Any]: - """One cursor hook entry: the flat `cursor` wrapper shape plus chock's timeout.""" - return {"command": command, "timeout": TIMEOUT_SECONDS} +def cursor_entry(command: str, *, fail_closed: bool = False) -> dict[str, Any]: + """One cursor hook entry: the flat `cursor` wrapper shape plus chock's timeout. + + `fail_closed` for a gate that must refuse: Cursor otherwise allows when the hook crashes, + times out or cannot start (docs: cursor.com/docs/agent/hooks, `failClosed`). + """ + entry: dict[str, Any] = {"command": command, "timeout": TIMEOUT_SECONDS} + if fail_closed: + entry["failClosed"] = True + return entry def cursor_hooks_file(command: str) -> dict[str, Any]: diff --git a/src/chock/hooks/data/launch.sh b/src/chock/hooks/data/launch.sh new file mode 100644 index 0000000..6f72207 --- /dev/null +++ b/src/chock/hooks/data/launch.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# chock hook launcher. Every agent hook chock writes runs: +# git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook [args...] +# git runs the alias from the repository's top level under its own sh (bash, PowerShell +# and cmd.exe all pass that string through unchanged), so relative paths resolve and no +# absolute interpreter path is ever committed. This script picks the first Python that +# actually runs: `chock.python` (written into .git/config by `chock sync`), then PATH. +# A candidate must execute, not merely exist: Windows' python3.exe Store alias exists and +# exits 9009. With no working Python it refuses (exit 2) and says why -- never allows. +runtime="$1" +shift +configured="$(git config --get chock.python 2>/dev/null)" +if [ -n "$configured" ] && [ -f "$configured" ]; then + exec "$configured" -X utf8 "$runtime" "$@" +fi +for candidate in python3 python py; do + if "$candidate" -c 'import sys; sys.exit(sys.version_info < (__MIN_PYTHON__,))' /dev/null 2>&1; then + exec "$candidate" -X utf8 "$runtime" "$@" + fi +done +echo "chock: no working Python __MIN_PYTHON_TEXT__+ found (tried chock.python, python3, python, py), so this hook cannot check anything. Install Python, or point chock at one: git config chock.python /path/to/python" >&2 +exit 2 diff --git a/src/chock/hooks/in_agent_generic.py b/src/chock/hooks/in_agent_generic.py index 2ff10df..ecc022e 100644 --- a/src/chock/hooks/in_agent_generic.py +++ b/src/chock/hooks/in_agent_generic.py @@ -3,8 +3,6 @@ from __future__ import annotations import json -import re -import sys from pathlib import Path from typing import Any @@ -12,10 +10,6 @@ from chock.emit import write_generated_json from chock.hooks.runtime_vendor import runtime_rel, vendor_runtime -INTERPRETER_PLACEHOLDER = "@CHOCK_PYTHON@" - -_INTERP_RE = re.compile(r'(^|&\s+)("[^"]+"|\S+)(?=\s+"\.chock/bin/)') - def load_config(path: Path) -> dict: """The vendor's config file as a dict; a file that is not readable JSON is refused.""" @@ -39,43 +33,6 @@ def _ours(node: Any, marker: str) -> bool: return marker in json.dumps(node) -def _map_strings(node: Any, fn) -> Any: - if isinstance(node, dict): - return {key: _map_strings(value, fn) for key, value in node.items()} - if isinstance(node, list): - return [_map_strings(value, fn) for value in node] - return fn(node) if isinstance(node, str) else node - - -def _bake(node: Any) -> Any: - exe = f'"{sys.executable}"' - return _map_strings(node, lambda s: s.replace(INTERPRETER_PLACEHOLDER, exe)) - - -def _normalize(node: Any) -> Any: - return _map_strings(node, lambda s: _INTERP_RE.sub(rf"\g<1>{INTERPRETER_PLACEHOLDER}", s)) - - -def _norm_key(entry: dict) -> str: - return json.dumps(_normalize(entry), sort_keys=True) - - -def _interpreter_runs(entry: dict) -> bool: - """Whether every baked interpreter in `entry` still resolves on this machine.""" - stale = [] - - def _probe(value: str) -> str: - match = _INTERP_RE.search(value) - if match: - interpreter = match.group(2).strip('"') - if interpreter != INTERPRETER_PLACEHOLDER and not Path(interpreter).is_file(): - stale.append(interpreter) - return value - - _map_strings(entry, _probe) - return not stale - - def _collect_ours(node: Any, marker: str, into: dict[str, dict]) -> None: """Every list-borne entry of ours anywhere under `node`, keyed by its normalized form.""" if isinstance(node, dict): @@ -84,7 +41,7 @@ def _collect_ours(node: Any, marker: str, into: dict[str, dict]) -> None: elif isinstance(node, list): for entry in node: if isinstance(entry, dict) and _ours(entry, marker): - into[_norm_key(entry)] = entry + into[json.dumps(entry, sort_keys=True)] = entry def _strip_ours(node: dict, marker: str) -> None: @@ -103,30 +60,21 @@ def _strip_ours(node: dict, marker: str) -> None: del node[key] -def _merge(settings: dict, fragment: dict, prior: dict[str, dict]) -> None: +def _merge(settings: dict, fragment: dict) -> None: """Deep-merge one rendered fragment: append entries, keep the vendor's own keys.""" for key, value in fragment.items(): if isinstance(value, dict): if not isinstance(settings.get(key), dict): settings[key] = {} - _merge(settings[key], value, prior) + _merge(settings[key], value) elif isinstance(value, list): existing = settings.get(key) base = existing if isinstance(existing, list) else [] - settings[key] = base + [_install_form(entry, prior) for entry in value] + settings[key] = base + list(value) else: settings.setdefault(key, value) -def _install_form(entry: Any, prior: dict[str, dict]) -> Any: - if not isinstance(entry, dict): - return entry - installed = prior.get(_norm_key(entry)) - if installed is not None and _interpreter_runs(installed): - return installed - return _bake(entry) - - #: The compiled surfaces whose fragments are whole hook-config documents for this vendor. #: Named rather than globbed over `*/`: a surface dir that happens to hold a like-named file #: is not thereby something to merge into a vendor config. @@ -176,7 +124,7 @@ def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> vendor_runtime(repo_root, vendor) for _policy_id, fragment in fragments: - _merge(settings, fragment, prior) + _merge(settings, fragment) config_path.parent.mkdir(parents=True, exist_ok=True) write_generated_json(config_path, settings) return [policy_id for policy_id, _ in fragments] diff --git a/src/chock/hooks/in_agent_install.py b/src/chock/hooks/in_agent_install.py index 8d87ddc..6f6a045 100644 --- a/src/chock/hooks/in_agent_install.py +++ b/src/chock/hooks/in_agent_install.py @@ -9,13 +9,11 @@ from chock.compile.emitters.in_agent import AGENT_HOOKS_ENVELOPE, AGENT_HOOKS_EVENT, GENERIC_VENDORS from chock.emit import write_generated_json from chock.hooks.in_agent_generic import install_generic, installed_generic_ids -from chock.hooks.in_agent_merged import INTERPRETER_PLACEHOLDER, MERGED, install_merged, installed_merged_ids +from chock.hooks.in_agent_merged import MERGED, install_merged, installed_merged_ids from chock.hooks.runtime_vendor import vendor_runtime -#: Re-exported: the placeholder is one token, shared by both halves of the installer. __all__ = [ "AGENT_HOOKS_VENDORS", - "INTERPRETER_PLACEHOLDER", "WIRED_VENDORS", "agent_hooks_rel", "install_hooks", diff --git a/src/chock/hooks/in_agent_merged.py b/src/chock/hooks/in_agent_merged.py index 9897fd4..46412d5 100644 --- a/src/chock/hooks/in_agent_merged.py +++ b/src/chock/hooks/in_agent_merged.py @@ -11,8 +11,6 @@ import copy import json -import re -import sys from pathlib import Path from typing import NamedTuple @@ -21,49 +19,6 @@ from chock.hooks.in_agent_generic import load_config as _load_config from chock.hooks.runtime_vendor import owned_markers, runtime_rel, vendor_runtime -INTERPRETER_PLACEHOLDER = "@CHOCK_PYTHON@" - -_COMMAND_TAIL_RE = re.compile(r'^.*?(?="\$\{CLAUDE_PROJECT_DIR\}[^"]*?/\.chock/bin/[a-z_]+\.py")') -_BIN_MARKER = "/.chock/bin/" - - -def bake_interpreter(fragment: dict) -> dict: - """A copy of `fragment` with the interpreter placeholder replaced by this machine's python.""" - exe = f'"{sys.executable}"' - baked = copy.deepcopy(fragment) - for hook in baked.get("hooks", []) or []: - if isinstance(hook, dict) and isinstance(hook.get("command"), str): - hook["command"] = hook["command"].replace(INTERPRETER_PLACEHOLDER, exe) - return baked - - -def normalize_fragment(fragment: dict) -> dict: - """A copy of `fragment` with the interpreter token normalised to the placeholder.""" - normalized = copy.deepcopy(fragment) - for hook in normalized.get("hooks", []) or []: - command = hook.get("command") if isinstance(hook, dict) else None - if isinstance(command, str) and _BIN_MARKER in command: - hook["command"] = _COMMAND_TAIL_RE.sub(f"{INTERPRETER_PLACEHOLDER} ", command, count=1) - return normalized - - -def interpreter_runs_here(fragment: dict) -> bool: - """Whether every baked interpreter in `fragment` still resolves on this machine.""" - for hook in fragment.get("hooks", []) or []: - command = hook.get("command") if isinstance(hook, dict) else None - if not isinstance(command, str) or _BIN_MARKER not in command: - continue - match = _COMMAND_TAIL_RE.match(command) - if not match: - continue - interpreter = match.group(0).strip().strip('"') - if not interpreter or interpreter == INTERPRETER_PLACEHOLDER: - continue - if not Path(interpreter).is_file(): - return False - return True - - class Wiring(NamedTuple): """One (event key, fragment shape) pair a vendor's config file receives.""" @@ -138,10 +93,6 @@ class VendorWiring(NamedTuple): } -def _wrap(entry: dict) -> dict: - return {"hooks": [copy.deepcopy(entry)]} - - def _compiled(repo_root: Path, wiring: Wiring) -> list[dict]: """Compiled fragments (claude shape) or entries (cursor shape), ordered by policy id.""" compiled = Path(repo_root) / ".chock" / "compiled" @@ -162,11 +113,6 @@ def _compiled(repo_root: Path, wiring: Wiring) -> list[dict]: return found -def _norm_entry(entry: dict, wiring: Wiring) -> dict: - """`entry` with its interpreter normalised, in whichever shape this wiring speaks.""" - return normalize_fragment(_wrap(entry))["hooks"][0] if wiring.flat else normalize_fragment(entry) - - def _ours_under(entry: dict, wiring: Wiring, markers: tuple[str, ...]) -> bool: """Whether this installed entry is one chock put there, by the vendored-runtime path in it.""" if wiring.flat: @@ -183,19 +129,11 @@ def _merge_event(hooks: dict, wiring: Wiring, wanted: list[dict], markers: tuple """Replace chock's entries under one event key in place, keeping entries that are not ours.""" existing = hooks.get(wiring.event) existing = existing if isinstance(existing, list) else [] - ours_before = [e for e in existing if _ours_under(e, wiring, markers)] kept = [e for e in existing if not _ours_under(e, wiring, markers)] - def _install_form(entry: dict) -> dict: - target = _norm_entry(entry, wiring) - for installed in ours_before: - runs = interpreter_runs_here(_wrap(installed) if wiring.flat else installed) - if _norm_entry(installed, wiring) == target and runs: - return installed - return bake_interpreter(_wrap(entry))["hooks"][0] if wiring.flat else bake_interpreter(entry) - + # Ours are replaced wholesale: an entry carries nothing machine-specific worth keeping. if wanted: - hooks[wiring.event] = kept + [_install_form(entry) for entry in wanted] + hooks[wiring.event] = kept + [copy.deepcopy(entry) for entry in wanted] elif kept: hooks[wiring.event] = kept else: @@ -287,7 +225,6 @@ def installed_merged_ids(repo_root: Path, vendor: str) -> set[str]: continue wanted = list(fragment.get(wiring.event, []) or []) if wiring.flat else [fragment] for candidate in wanted: - target = _norm_entry(candidate, wiring) - if any(_norm_entry(e, wiring) == target for e in entries if isinstance(e, dict)): + if any(e == candidate for e in entries if isinstance(e, dict)): installed.add(path.parent.parent.name) return installed diff --git a/src/chock/hooks/launch.py b/src/chock/hooks/launch.py new file mode 100644 index 0000000..09f64a3 --- /dev/null +++ b/src/chock/hooks/launch.py @@ -0,0 +1,64 @@ +"""The portable hook command: git runs chock's committed launcher from the repository root.""" + +from __future__ import annotations + +import contextlib +import subprocess +import sys +from pathlib import Path, PurePath + +from chock.emit import write_generated +from chock.resources import package_data_dir + +#: Where the launcher lives in a consumer repo, beside the vendored runtimes it starts. +LAUNCHER_REL = ".chock/bin/launch.sh" + +#: Local-only git config key naming the interpreter `chock sync` ran under on this machine. +PYTHON_CONFIG_KEY = "chock.python" + +#: The oldest Python the vendored runtime runs on (chock's own `requires-python`). +MIN_PYTHON = (3, 11) + +ALIAS = "chock-hook" + +#: No `$`, no backslash, no single quote: bash, PowerShell and cmd.exe read it identically. +_PREFIX = f'git -c "alias.{ALIAS}=!sh {LAUNCHER_REL}" {ALIAS}' + +_TEMPLATE = package_data_dir("chock", "hooks", "data").joinpath("launch.sh").read_text(encoding="utf-8") + + +def hook_command(runtime: str, *args: str) -> str: + """The command an agent config carries to run `runtime` (repo-relative) with `args`.""" + words = [f'"{arg}"' if "/" in arg else arg for arg in args] + return " ".join([_PREFIX, runtime, *words]) + + +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( + "__MIN_PYTHON__", ", ".join(map(str, MIN_PYTHON)) + ) + + +def write_launcher(repo_root: Path) -> Path: + """Write the launcher into the consumer repo (LF, so git's sh reads it on Windows too).""" + dest = Path(repo_root) / LAUNCHER_REL + dest.parent.mkdir(parents=True, exist_ok=True) + write_generated(dest, launcher_text()) + with contextlib.suppress(OSError): + dest.chmod(0o755) + return dest + + +def record_interpreter(repo_root: Path) -> bool: + """Name this interpreter in the clone's own .git/config; never committed. True when set.""" + with contextlib.suppress(OSError, subprocess.SubprocessError): + proc = subprocess.run( + ["git", "config", "--local", PYTHON_CONFIG_KEY, PurePath(sys.executable).as_posix()], # noqa: S607 -- git on PATH is the repo route's premise + cwd=repo_root, + capture_output=True, + check=False, + timeout=30, + ) + return proc.returncode == 0 + return False diff --git a/src/chock/hooks/runtime_vendor.py b/src/chock/hooks/runtime_vendor.py index 54b2016..7367935 100644 --- a/src/chock/hooks/runtime_vendor.py +++ b/src/chock/hooks/runtime_vendor.py @@ -8,6 +8,7 @@ from chock.emit import write_generated from chock.gate import runtime_bundle +from chock.hooks.launch import write_launcher from chock.resources import package_data_dir #: Basenames chock has written under `.chock/bin/` before the current per-vendor naming @@ -26,9 +27,11 @@ def runtime_rel(agent: str) -> Path: def owned_markers(agent: str) -> tuple[str, ...]: """Every `.chock/bin/` command substring that identifies a hook entry as `agent`'s own.""" - current = f"/{runtime_rel(agent).as_posix()}" + rel = runtime_rel(agent).as_posix() + # `/...` in a root-token command (`${CLAUDE_PROJECT_DIR}/.chock/bin/x.py`) from before the + # launcher; ` ...` in the launcher form (`chock-hook .chock/bin/x.py`). legacy = tuple(f"/.chock/bin/{name}" for name in LEGACY_RUNTIME_BASENAMES) - return (current, *legacy) + return (f"/{rel}", f" {rel}", *legacy) def vendor_runtime(repo_root: Path, agent: str) -> Path: @@ -40,4 +43,5 @@ def vendor_runtime(repo_root: Path, agent: str) -> Path: write_generated(dest, runtime_bundle.render(agent)) with contextlib.suppress(OSError): dest.chmod(0o755) + write_launcher(repo_root) return dest diff --git a/src/chock/hooks/sessionstart_install.py b/src/chock/hooks/sessionstart_install.py index 608e48d..9e3f694 100644 --- a/src/chock/hooks/sessionstart_install.py +++ b/src/chock/hooks/sessionstart_install.py @@ -9,12 +9,7 @@ from chock import vendors from chock.emit import write_generated_json -from chock.hooks.in_agent_merged import ( - INTERPRETER_PLACEHOLDER, - bake_interpreter, - interpreter_runs_here, - normalize_fragment, -) +from chock.hooks.launch import hook_command from chock.hooks.runtime_vendor import owned_markers, runtime_rel, vendor_runtime from chock.output import warn @@ -27,7 +22,7 @@ "hooks": [ { "type": "command", - "command": f'{INTERPRETER_PLACEHOLDER} "${{CLAUDE_PROJECT_DIR}}/.chock/bin/claude_code.py"', + "command": hook_command(ADAPTER_REL.as_posix()), "timeout": 300, } ] @@ -64,21 +59,11 @@ def install_sessionstart_hook(repo_root: Path) -> bool: hooks = settings.setdefault("hooks", {}) if isinstance(settings.get("hooks", {}), dict) else {} settings["hooks"] = hooks existing = hooks.get(ARM_EVENT) - ours_before = [e for e in existing if _is_ours(e)] if isinstance(existing, list) else [] kept = [e for e in existing if not _is_ours(e)] if isinstance(existing, list) else [] - wanted = normalize_fragment(ARM_FRAGMENT) - install_form = None - for entry in ours_before: - if normalize_fragment(entry) == wanted and interpreter_runs_here(entry): - install_form = entry - break - if install_form is None: - install_form = bake_interpreter(ARM_FRAGMENT) - vendor_adapter(repo_root) - desired = [*kept, install_form] + desired = [*kept, json.loads(json.dumps(ARM_FRAGMENT))] if isinstance(existing, list) and desired == existing: return False hooks[ARM_EVENT] = desired diff --git a/src/chock/lifecycle.py b/src/chock/lifecycle.py index 9c90447..6cdf4d9 100644 --- a/src/chock/lifecycle.py +++ b/src/chock/lifecycle.py @@ -36,6 +36,11 @@ def sync_main(argv: list[str] | None) -> int: if rc or args.check: return rc + from chock.hooks.launch import record_interpreter + + # The launcher every agent hook runs prefers this interpreter; local config, never committed. + record_interpreter(Path(args.repo)) + if args.ci: from chock.scaffold.install_ci import main as install_ci_main From 70ff1afcff96d13bdb03dabf599cca6bd883deb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:48:56 -0400 Subject: [PATCH 09/25] Lint the launcher change: import spacing, line length, S603 reason `ruff check` and `ruff format --check` failed on the launcher commit: an import block missing its blank line, a line over the limit, and an unannotated subprocess call. The call runs a fixed argv (git config with this process's own interpreter path), so it gets a reasoned noqa like the repo's other git calls. No behaviour change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- src/chock/compile/emitters/in_agent.py | 4 +++- src/chock/hooks/in_agent_merged.py | 1 + src/chock/hooks/launch.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 3d06034..5da22bd 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -218,7 +218,9 @@ def build_entry(policy_dir: Path, manifest: dict[str, Any]) -> dict[str, Any] | if not script: return None # One string for both keys: the launcher form reads the same under bash and PowerShell. - command = hook_command(_adapter_rel("vscode_copilot"), "--guard", f"{policy_rel_path(policy_dir)}/implementations/{script}") + command = hook_command( + _adapter_rel("vscode_copilot"), "--guard", f"{policy_rel_path(policy_dir)}/implementations/{script}" + ) bash = powershell = command return { "type": "command", diff --git a/src/chock/hooks/in_agent_merged.py b/src/chock/hooks/in_agent_merged.py index 46412d5..6da39b2 100644 --- a/src/chock/hooks/in_agent_merged.py +++ b/src/chock/hooks/in_agent_merged.py @@ -19,6 +19,7 @@ from chock.hooks.in_agent_generic import load_config as _load_config from chock.hooks.runtime_vendor import owned_markers, runtime_rel, vendor_runtime + class Wiring(NamedTuple): """One (event key, fragment shape) pair a vendor's config file receives.""" diff --git a/src/chock/hooks/launch.py b/src/chock/hooks/launch.py index 09f64a3..d1a1f2c 100644 --- a/src/chock/hooks/launch.py +++ b/src/chock/hooks/launch.py @@ -53,7 +53,7 @@ def write_launcher(repo_root: Path) -> Path: def record_interpreter(repo_root: Path) -> bool: """Name this interpreter in the clone's own .git/config; never committed. True when set.""" with contextlib.suppress(OSError, subprocess.SubprocessError): - proc = subprocess.run( + proc = subprocess.run( # noqa: S603 -- fixed argv: git config with this process's own interpreter path ["git", "config", "--local", PYTHON_CONFIG_KEY, PurePath(sys.executable).as_posix()], # noqa: S607 -- git on PATH is the repo route's premise cwd=repo_root, capture_output=True, From 42b266ea6b7b0599b790c2cc78b1f108469262a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:49:08 -0400 Subject: [PATCH 10/25] Regenerate chock's own hook wiring and emitter goldens in the launcher form chock adopts itself, so its committed agent configs still carried the old baked form -- including an interpreter under a temporary directory that no longer exists anywhere. `chock sync --repo .` rewrote every entry to the launcher form, recognising the old ones as its own rather than keeping them beside the new, and wrote the committed .chock/bin/launch.sh (LF, 0755). A second sync is a zero diff. The emitter-stability goldens change with the wire bytes (regenerated with CHOCK_REGEN_GOLDENS=1); the runtime goldens do not, since the vendored runtimes are unchanged. chock.lock moves only in artifacts hashes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/launch.sh | 22 +++++++++++++ .../agent-hooks/agent-hooks.json | 8 ++--- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +-- .../pre-tool-use/cursor-hooks.json | 5 +-- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +-- .../agent-hooks/agent-hooks.json | 8 ++--- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +-- .../pre-tool-use/cursor-hooks.json | 5 +-- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +-- .../pre-tool-use/cursor-write-hooks.json | 5 +-- .../pre-tool-use/gemini_cli-write-hooks.json | 2 +- .../pre-tool-use/pretooluse-write.json | 2 +- .../stop/antigravity-hooks.json | 2 +- .../stop/codex_cli-hooks.json | 4 +-- .../pin-github-actions/stop/cursor-hooks.json | 2 +- .../pin-github-actions/stop/devin-hooks.json | 2 +- .../stop/gemini_cli-hooks.json | 2 +- .../pin-github-actions/stop/stop.json | 2 +- .../stop/tabnine-hooks.json | 2 +- .../agent-hooks/agent-hooks.json | 8 ++--- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +-- .../pre-tool-use/cursor-hooks.json | 5 +-- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +-- .../agent-hooks/agent-hooks.json | 8 ++--- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +-- .../pre-tool-use/cursor-hooks.json | 5 +-- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +-- .claude/settings.json | 14 ++++---- .codex/hooks.json | 20 ++++++------ .cursor/hooks.json | 27 +++++++++------- .devin/hooks.v1.json | 10 +++--- .gemini/settings.json | 10 +++--- .github/hooks/chock.json | 32 +++++++++---------- .grok/hooks/agentseam.json | 8 ++--- .tabnine/agent/settings.json | 10 +++--- .windsurf/hooks.json | 16 +++++----- chock.lock | 10 +++--- .../agent-hooks/agent-hooks.json | 8 ++--- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +-- .../pre-tool-use/cursor-hooks.json | 5 +-- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +-- .../pre-tool-use/cursor-write-hooks.json | 5 +-- .../pre-tool-use/gemini_cli-write-hooks.json | 2 +- .../pre-tool-use/pretooluse-write.json | 2 +- .../stop/antigravity-hooks.json | 2 +- .../stop/codex_cli-hooks.json | 4 +-- .../stability-script/stop/cursor-hooks.json | 2 +- .../stability-script/stop/devin-hooks.json | 2 +- .../stop/gemini_cli-hooks.json | 2 +- .../golden/stability-script/stop/stop.json | 2 +- .../stability-script/stop/tabnine-hooks.json | 2 +- 81 files changed, 214 insertions(+), 180 deletions(-) create mode 100755 .chock/bin/launch.sh diff --git a/.chock/bin/launch.sh b/.chock/bin/launch.sh new file mode 100755 index 0000000..f2e033a --- /dev/null +++ b/.chock/bin/launch.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# chock hook launcher. Every agent hook chock writes runs: +# git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook [args...] +# git runs the alias from the repository's top level under its own sh (bash, PowerShell +# and cmd.exe all pass that string through unchanged), so relative paths resolve and no +# absolute interpreter path is ever committed. This script picks the first Python that +# actually runs: `chock.python` (written into .git/config by `chock sync`), then PATH. +# A candidate must execute, not merely exist: Windows' python3.exe Store alias exists and +# exits 9009. With no working Python it refuses (exit 2) and says why -- never allows. +runtime="$1" +shift +configured="$(git config --get chock.python 2>/dev/null)" +if [ -n "$configured" ] && [ -f "$configured" ]; then + exec "$configured" -X utf8 "$runtime" "$@" +fi +for candidate in python3 python py; do + if "$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 11,))' /dev/null 2>&1; then + exec "$candidate" -X utf8 "$runtime" "$@" + fi +done +echo "chock: no working Python 3.11+ found (tried chock.python, python3, python, py), so this hook cannot check anything. Install Python, or point chock at one: git config chock.python /path/to/python" >&2 +exit 2 diff --git a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json index c2b6511..0faa317 100644 --- a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json index df7646d..9a98034 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json index f0b695b..4e37e74 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json index c7fb60f..9f61148 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json @@ -1,8 +1,9 @@ { "beforeShellExecution": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "timeout": 30, + "failClosed": true } ] } \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json index b4635d3..0b2de74 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json index 1e0bb4f..288ef7b 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json index 74adcdd..fba31f4 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json b/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json index 2c966da..7ca0611 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json index fcf4b83..d9f1626 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json index 5501b31..db62906 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json index 7ac1d33..bc45cef 100644 --- a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json index 5de15fe..7acaa28 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json index ef9b11d..6760f15 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json index 96bfccf..05fd1fd 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json @@ -1,8 +1,9 @@ { "beforeShellExecution": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 30, + "failClosed": true } ] } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json index 773dc47..c5ad0fa 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json index f5e839d..8a1d798 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json index 2126899..d643f31 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json index c1d61be..5eb9e59 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json index 6a9fd70..705f920 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json index 5e47270..6f1ce90 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json index 9297163..c57408b 100644 --- a/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json +++ b/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json @@ -1,8 +1,9 @@ { "preToolUse": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 30, + "failClosed": true } ] } \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json index 74123ab..1b99e33 100644 --- a/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json +++ b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/gemini_cli.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30 } ] diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json b/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json index 540fa80..cf60de4 100644 --- a/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json +++ b/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30 } ] diff --git a/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json b/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json index 658e55c..6d9dd32 100644 --- a/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json index 5ae4a01..5d533fe 100644 --- a/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/cursor-hooks.json b/.chock/compiled/pin-github-actions/stop/cursor-hooks.json index af2f34f..a5d96c8 100644 --- a/.chock/compiled/pin-github-actions/stop/cursor-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/cursor-hooks.json @@ -1,7 +1,7 @@ { "stop": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.chock/compiled/pin-github-actions/stop/devin-hooks.json b/.chock/compiled/pin-github-actions/stop/devin-hooks.json index e7770bd..4c6828d 100644 --- a/.chock/compiled/pin-github-actions/stop/devin-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json b/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json index 8c1a1bc..c8d8f1b 100644 --- a/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/stop.json b/.chock/compiled/pin-github-actions/stop/stop.json index 20879d5..3364572 100644 --- a/.chock/compiled/pin-github-actions/stop/stop.json +++ b/.chock/compiled/pin-github-actions/stop/stop.json @@ -2,7 +2,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json b/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json index 7383256..b45c5c2 100644 --- a/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "name": "agentseam" } ] diff --git a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json index 9dc0283..82ce044 100644 --- a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json index fd03142..de3e641 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json index 1c37a96..f75d4c3 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json index 1e72925..82468d1 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json @@ -1,8 +1,9 @@ { "beforeShellExecution": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "timeout": 30, + "failClosed": true } ] } \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json index 5e49f06..0b89d17 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json index 26410ef..e07ec83 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json index a91eb2f..2377427 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json b/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json index e7e9236..7b04d6a 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json index 3449fbc..9fb76d7 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json index 2d6e6c4..0dde434 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json index 93d4c29..4fb4242 100644 --- a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json index 3c9c9df..4be3fff 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json index 463a0a4..f231dd9 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json index d66b4ee..d1b74da 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json @@ -1,8 +1,9 @@ { "beforeShellExecution": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "timeout": 30, + "failClosed": true } ] } \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json index e240927..f9e0a0a 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json index c424885..9e40d1e 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json index c774838..b65d808 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json index c794424..d87a7e6 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json index ca4f949..03d4547 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json index e66c257..c0f91cf 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.claude/settings.json b/.claude/settings.json index 5036b51..87d403c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "timeout": 30 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "timeout": 30 } ] @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30 } ] @@ -36,7 +36,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "timeout": 30 } ] @@ -46,7 +46,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "timeout": 30 } ] @@ -57,7 +57,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py", "timeout": 300 } ] @@ -68,7 +68,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.codex/hooks.json b/.codex/hooks.json index a6c16da..39117d0 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "Bash" @@ -15,8 +15,8 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "Bash" @@ -25,8 +25,8 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "Bash" @@ -35,8 +35,8 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "Bash" @@ -47,8 +47,8 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& \"/tmp/chock-cursor/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 81942b3..27b6d24 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -2,31 +2,36 @@ "hooks": { "beforeShellExecution": [ { - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "timeout": 30, + "failClosed": true }, { - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 30, + "failClosed": true }, { - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "timeout": 30, + "failClosed": true }, { - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "timeout": 30, + "failClosed": true } ], "preToolUse": [ { - "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 30, + "failClosed": true } ], "stop": [ { - "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.devin/hooks.v1.json b/.devin/hooks.v1.json index a40fc7c..1a2db43 100644 --- a/.devin/hooks.v1.json +++ b/.devin/hooks.v1.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] }, @@ -12,7 +12,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] }, @@ -20,7 +20,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] }, @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/devin.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.gemini/settings.json b/.gemini/settings.json index ff176da..17835c8 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "run_shell_command" @@ -14,7 +14,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "run_shell_command" @@ -23,7 +23,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "run_shell_command" @@ -32,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "run_shell_command" @@ -43,7 +43,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.github/hooks/chock.json b/.github/hooks/chock.json index af3054a..ee93deb 100644 --- a/.github/hooks/chock.json +++ b/.github/hooks/chock.json @@ -7,40 +7,40 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" }, { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" }, { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" }, { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.grok/hooks/agentseam.json b/.grok/hooks/agentseam.json index e56ef93..d38beea 100644 --- a/.grok/hooks/agentseam.json +++ b/.grok/hooks/agentseam.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] }, @@ -13,7 +13,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] }, @@ -21,7 +21,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] }, @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.tabnine/agent/settings.json b/.tabnine/agent/settings.json index 63988e9..2a1683f 100644 --- a/.tabnine/agent/settings.json +++ b/.tabnine/agent/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "name": "agentseam" } ] @@ -14,7 +14,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "name": "agentseam" } ] @@ -23,7 +23,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "name": "agentseam" } ] @@ -32,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "name": "agentseam" } ] @@ -43,7 +43,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/tabnine.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "name": "agentseam" } ] diff --git a/.windsurf/hooks.json b/.windsurf/hooks.json index 442b4f3..0e20d67 100644 --- a/.windsurf/hooks.json +++ b/.windsurf/hooks.json @@ -2,30 +2,30 @@ "hooks": { "pre_run_command": [ { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" }, { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" }, { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" }, { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" }, { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" }, { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" }, { - "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/chock.lock b/chock.lock index d4ed938..8908551 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "f5ce8291003c254099de2b7b0bcd30b3bd749ebfd97ed1071ad37e51237c10cd" + "artifacts_sha256": "f621c131b3c183d70b729be8360212ff3b896af35d8f8c26899dc961d47e7a35" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "fc65a16e8d3d84f2735a86922e16fa21324fcb63553c17cdd32bb87ef5a6d906" + "artifacts_sha256": "2382a8a41fc1d0920311ae9cf8b5498c54ef3931b25f330db1f6244a73800f22" }, { "id": "block-wildcard-agent-permissions", @@ -96,7 +96,7 @@ "managed": false, "sha256": "e73dbc039cdcbf137049a1f5035e4b595d12afe74a12a0f78e2e102d20d29479", "source": "local", - "artifacts_sha256": "c352efb451fe61dc6aad8f799fa8628d3adb6305e22f3ae2efd1ab1a215b641f" + "artifacts_sha256": "2b7866b0674eaec540538b8d65091eadfa6ba8959fec734527b26ebffcc5d422" }, { "id": "pre-generated-scripts", @@ -112,7 +112,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "1d695d9651e43d3ea6aaea6d74d4d1fa9cf2661ed99b5438f1d25cbc56e08470" + "artifacts_sha256": "f9087daed9092fb0ab4f41b3f478a1d79e9e004d892f90b2cd18793f008aba5d" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "e27d75a558ff05e9c5a3858d68ecbbfa4ec7a9d3243b85fa8ce76aa0da7c72ba" + "artifacts_sha256": "59517e11f8226a731f7371a48682aa6f3d0741ce6c9497f74e29beb0a85a1223" }, { "id": "protect-main-branch", diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json index 16ddd1a..3f6da5a 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; exit $LASTEXITCODE" + "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json index 136e014..4b836d4 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json index 73cab23..4e5969e 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ], "matcher": "Bash" diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json index aae9e1c..a12c657 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json @@ -1,8 +1,9 @@ { "beforeShellExecution": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "timeout": 30, + "failClosed": true } ] } \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json index e5e6b1f..292bda4 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json index 64afae7..6581bbd 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ], "matcher": "run_shell_command" diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json index 28fef9f..fbe82ab 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json index 9303ae6..471317c 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json index d0dc7fe..b1dbba2 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "name": "agentseam" } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json index d40f39a..83a20ba 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json index 5f2a244..6f0a822 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json @@ -1,8 +1,9 @@ { "preToolUse": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/stability-script/pre-tool-use/gate.json\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "timeout": 30, + "failClosed": true } ] } \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json index 746a39d..10723c7 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/gemini_cli.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/stability-script/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json index 35134b6..dad3b65 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/stability-script/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json index c2cc02f..1f6e5f6 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json index 02c51fb..0514481 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/stability-script/stop/gate.json\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json index e040f0b..da93fb3 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json @@ -1,7 +1,7 @@ { "stop": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/stability-script/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/stability-script/stop/gate.json\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json index 2eb7295..2672271 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json index 453dd4f..e868a7e 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json index 592f036..5ba9942 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json @@ -2,7 +2,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/stability-script/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/stability-script/stop/gate.json\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json index c26c700..3efe650 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --gate \".chock/compiled/stability-script/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/stability-script/stop/gate.json\"", "name": "agentseam" } ] From 330c9c3ba81fe0963ea8d48eb24fafbb09c18518 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:49:19 -0400 Subject: [PATCH 11/25] Test the launcher hook contract instead of the baked interpreter The interpreter tests asserted what the launcher commit removed: a baked `sys.executable`, `@CHOCK_PYTHON@` in fragments, `${CLAUDE_PROJECT_DIR}` paths, re-baking a stale interpreter, and the per-shell templates. They now assert the contract that replaced it: - an installed command names no interpreter and equals the compiled one; - a reinstall under another interpreter is a byte-for-byte no-op; - an old baked entry is recognised as chock's and replaced, while an adopter's own entries are kept (Claude, Cursor, generic vendors, the SessionStart arm hook); - the end-to-end tests run the installed string through bash from the repo root and from a nested subdirectory, and a guard still denies with only git and sh on PATH once `chock.python` is recorded. tests/test_hook_launcher.py covers the launcher itself: it runs the runtime from the top level with -X utf8, skips a python3 that does not run, prefers the recorded interpreter, falls back past a stale one, and with no Python exits 2 with a fix-it message; it is written LF and executable; record_interpreter touches only the clone's local config; hook_command has no `$`, backslash or single quote; Cursor gate entries carry failClosed and stop entries do not; and no committed config in this repo carries anything but the launcher form. The removed template-token tests become one for launch.sh. The acceptance schema step and CI's shellcheck target (the deleted bash template) move to the launcher too. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .github/workflows/ci.yml | 4 +- acceptance/conftest.py | 2 +- acceptance/test_features.py | 4 +- tests/conftest.py | 9 + tests/test_agent_hooks.py | 33 +-- tests/test_cursor_hooks.py | 97 ++++---- tests/test_generic_hooks_install.py | 24 +- tests/test_hook_launcher.py | 210 ++++++++++++++++++ tests/test_pretooluse.py | 18 +- tests/test_pretooluse_interpreter.py | 161 ++++++++------ tests/test_sessionstart_arm.py | 61 +++-- .../test_sync_wires_only_supported_agents.py | 4 +- tests/test_template_tokens.py | 23 +- tests/test_vendor_wire_facts.py | 30 +-- 14 files changed, 459 insertions(+), 221 deletions(-) create mode 100644 tests/test_hook_launcher.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b4540..bd7c165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,8 +89,8 @@ jobs: src/chock/compile/emitters/data/git_hook_shim.sh \ src/chock/hooks/data/dispatcher.sh \ src/chock/hooks/data/validate_wrapper_windows.sh \ - src/chock/hooks/data/policy_wrapper.sh - shellcheck --shell=bash src/chock/compile/emitters/data/agent_hook_bash.sh + src/chock/hooks/data/policy_wrapper.sh \ + src/chock/hooks/data/launch.sh - name: Install actionlint (pinned, checksum-verified) run: | diff --git a/acceptance/conftest.py b/acceptance/conftest.py index a3acbe2..c6c0459 100644 --- a/acceptance/conftest.py +++ b/acceptance/conftest.py @@ -130,7 +130,7 @@ def fire_pretooluse(self, command: str) -> bool: settings = self.read_json(".claude/settings.json") for entry in settings.get("hooks", {}).get("PreToolUse", []): for hook in entry.get("hooks", []): - cmd = hook["command"].replace("${CLAUDE_PROJECT_DIR}", str(self.repo)) + cmd = hook["command"] # This harness runs the installed hook COMMAND STRING exactly as the # client does, shell interpretation included; an argv list would test # a different mechanism than the one adopters get. diff --git a/acceptance/test_features.py b/acceptance/test_features.py index 859bc81..cd67ca2 100644 --- a/acceptance/test_features.py +++ b/acceptance/test_features.py @@ -159,7 +159,9 @@ def _hook_schema(repo: Adopter) -> None: assert entry["matcher"], "an entry has no matcher" for hook in entry["hooks"]: assert hook["type"] == "command" - assert "${CLAUDE_PROJECT_DIR}" in hook["command"], "paths must survive a repo move" + # Repo-relative, run by git from the top level: survives a repo move, names no interpreter. + assert hook["command"].startswith('git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook ') + assert "${" not in hook["command"], hook["command"] @then("the hand-written hook survives") diff --git a/tests/conftest.py b/tests/conftest.py index c5e0962..51c48b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,15 @@ def bash_executable() -> str: return shutil.which("bash") or "bash" +def run_hook_command( + command: str, cwd: Path, payload: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess: + """Run an installed agent-hook command the way an agent does: one shell string, from `cwd`.""" + return subprocess.run( + [bash_executable(), "-c", command], cwd=cwd, env=env, input=payload, capture_output=True, text=True, check=False + ) + + def baseline_policy(policy_id: str) -> Path: """Directory of one of this repo's own baseline-derived policies.""" path = REPO_POLICIES / policy_id diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 8b254bd..538f92f 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -13,7 +13,7 @@ from chock.compile.emitters.in_agent import SHELL_MATCHER, build_entry from chock.compile.emitters.in_agent import emit_agent_hooks as emit -from chock.gate import runtime_bundle +from chock.hooks.runtime_vendor import vendor_runtime def _payload(command: str) -> str: @@ -34,12 +34,9 @@ def _guard_body() -> str: def _synced_repo(tmp_path: Path) -> tuple[Path, dict]: """A git repo with the vendored runtime and one guard at the paths the entry references.""" repo = tmp_path / "adopter" - (repo / ".chock" / "bin").mkdir(parents=True) pol = repo / ".agents" / "policies" / "block-destructive-commands" / "implementations" pol.mkdir(parents=True) - (repo / ".chock" / "bin" / "vscode_copilot.py").write_text( - runtime_bundle.render("vscode_copilot"), encoding="utf-8" - ) + vendor_runtime(repo, "vscode_copilot") (pol / "block-destructive.sh").write_text(_guard_body(), encoding="utf-8") subprocess.run(["git", "init", "-q"], cwd=repo, check=True) manifest = {"id": "block-destructive-commands"} @@ -55,6 +52,7 @@ def test_build_entry_has_all_four_command_fields(tmp_path): assert set(entry) >= {"bash", "command", "powershell", "windows", "matcher", "type"} assert entry["bash"] == entry["command"] assert entry["powershell"] == entry["windows"] + assert entry["bash"] == entry["powershell"], "the launcher form reads the same under both shells" assert entry["matcher"] == SHELL_MATCHER @@ -83,16 +81,21 @@ def test_emitted_bash_command_denies_end_to_end(tmp_path): entry = build_entry(Path(".agents/policies/block-destructive-commands"), manifest) bash_cmd = entry["bash"] - blocked = subprocess.run( - [bash, "-c", bash_cmd], cwd=repo, input=_payload("rm -rf /"), capture_output=True, text=True - ) - assert blocked.returncode == 0, (blocked.stdout, blocked.stderr) - decision = json.loads(blocked.stdout) - assert decision["hookSpecificOutput"]["permissionDecision"] == "deny", (blocked.stdout, blocked.stderr) - - allowed = subprocess.run([bash, "-c", bash_cmd], cwd=repo, input=_payload("ls -la"), capture_output=True, text=True) - assert allowed.returncode == 0, (allowed.stdout, allowed.stderr) - assert allowed.stdout.strip() == "", (allowed.stdout, allowed.stderr) + nested = repo / "src" / "deep" + nested.mkdir(parents=True) + for cwd in (repo, nested): + blocked = subprocess.run( + [bash, "-c", bash_cmd], cwd=cwd, input=_payload("rm -rf /"), capture_output=True, text=True + ) + assert blocked.returncode == 0, (cwd, blocked.stdout, blocked.stderr) + decision = json.loads(blocked.stdout) + assert decision["hookSpecificOutput"]["permissionDecision"] == "deny", (cwd, blocked.stdout, blocked.stderr) + + allowed = subprocess.run( + [bash, "-c", bash_cmd], cwd=cwd, input=_payload("ls -la"), capture_output=True, text=True + ) + assert allowed.returncode == 0, (cwd, allowed.stdout, allowed.stderr) + assert allowed.stdout.strip() == "", (cwd, allowed.stdout, allowed.stderr) def _pwsh() -> str | None: diff --git a/tests/test_cursor_hooks.py b/tests/test_cursor_hooks.py index f5c84ec..7e3b80c 100644 --- a/tests/test_cursor_hooks.py +++ b/tests/test_cursor_hooks.py @@ -10,11 +10,13 @@ import tempfile from pathlib import Path -from conftest import baseline_policy +import pytest +from conftest import baseline_policy, run_hook_command from chock.compile.compiler import compile_policy from chock.compile.surfaces import Surface -from chock.hooks.in_agent_install import INTERPRETER_PLACEHOLDER, install_hooks, installed_policy_ids +from chock.hooks.in_agent_install import install_hooks, installed_policy_ids +from chock.hooks.launch import hook_command FRAMEWORK_ROOT = Path(__file__).resolve().parents[1] @@ -59,68 +61,56 @@ def test_both_fragments_reference_the_same_guard(tmp_path: Path) -> None: surface_dir = out / "block-destructive-commands" / "pre-tool-use" claude = json.loads((surface_dir / "pretooluse.json").read_text())["hooks"][0]["command"] cursor = json.loads((surface_dir / "cursor-hooks.json").read_text())["beforeShellExecution"][0]["command"] - assert INTERPRETER_PLACEHOLDER in claude - assert INTERPRETER_PLACEHOLDER in cursor - assert '--guard "${CLAUDE_PROJECT_DIR}/.agents/policies/block-destructive-commands' in claude + guard = ".agents/policies/block-destructive-commands/implementations/block-destructive.sh" + assert claude == hook_command(".chock/bin/claude_code.py", "--guard", guard) + assert cursor == hook_command(".chock/bin/cursor.py", "--guard", guard) assert claude.split("--guard", 1)[1] == cursor.split("--guard", 1)[1], "same guard, both envelopes" - assert '"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py"' in claude - assert '"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py"' in cursor -def test_install_bakes_and_preserves_foreign_entries() -> None: +def _hooks_path(repo: Path) -> Path: + return repo / ".cursor" / "hooks.json" + + +def test_install_writes_the_compiled_entry_and_preserves_foreign_entries() -> None: repo = _fresh_repo() - hooks_path = repo / ".cursor" / "hooks.json" + hooks_path = _hooks_path(repo) hooks_path.parent.mkdir(parents=True, exist_ok=True) theirs = {"command": "./scripts/audit.sh"} hooks_path.write_text(json.dumps({"version": 1, "hooks": {"beforeShellExecution": [theirs]}}), encoding="utf-8") install_hooks(repo, "cursor") - settings = json.loads(hooks_path.read_text(encoding="utf-8")) - entries = settings["hooks"]["beforeShellExecution"] + entries = json.loads(hooks_path.read_text(encoding="utf-8"))["hooks"]["beforeShellExecution"] assert entries[0] == theirs, "the adopter's own entry must survive, first" assert len(entries) == 2 - assert INTERPRETER_PLACEHOLDER not in entries[1]["command"] - assert sys.executable in entries[1]["command"] - - -def _fake_but_real_interpreter(tmp_path: Path) -> str: - """A path that is not `sys.executable` but genuinely resolves on this machine.""" - import shutil + compiled = repo / ".chock" / "compiled" / "block-destructive-commands" / "pre-tool-use" / "cursor-hooks.json" + assert entries[1] == json.loads(compiled.read_text(encoding="utf-8"))["beforeShellExecution"][0] + assert sys.executable not in entries[1]["command"] - fake = tmp_path / "another-machine-python3" - shutil.copy(sys.executable, fake) - fake.chmod(0o755) - return str(fake) - -def test_reinstall_does_not_churn_a_committed_entry(tmp_path: Path) -> None: +def test_reinstall_on_another_machine_is_byte_identical(monkeypatch: pytest.MonkeyPatch) -> None: repo = _fresh_repo() install_hooks(repo, "cursor") - hooks_path = repo / ".cursor" / "hooks.json" - settings = json.loads(hooks_path.read_text(encoding="utf-8")) - entry = settings["hooks"]["beforeShellExecution"][0] - other = _fake_but_real_interpreter(tmp_path) - entry["command"] = f'"{other}"' + entry["command"][entry["command"].index(' "') :] - hooks_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") - before = hooks_path.read_text(encoding="utf-8") + before = _hooks_path(repo).read_bytes() + monkeypatch.setattr(sys, "executable", "/opt/elsewhere/bin/python3") install_hooks(repo, "cursor") - assert json.loads(hooks_path.read_text(encoding="utf-8")) == json.loads(before) + assert _hooks_path(repo).read_bytes() == before assert "block-destructive-commands" in installed_policy_ids(repo, "cursor") -def test_reinstall_rebakes_an_interpreter_that_no_longer_resolves() -> None: +def test_an_old_baked_entry_is_replaced_by_the_launcher_form() -> None: repo = _fresh_repo() install_hooks(repo, "cursor") - hooks_path = repo / ".cursor" / "hooks.json" + hooks_path = _hooks_path(repo) settings = json.loads(hooks_path.read_text(encoding="utf-8")) - entry = settings["hooks"]["beforeShellExecution"][0] - entry["command"] = ( - '"/usr/local/bin/definitely-not-a-real-interpreter3"' + entry["command"][entry["command"].index(' "') :] - ) + current = settings["hooks"]["beforeShellExecution"][0] + root = "${CLAUDE_PROJECT_DIR}" + guard = ".agents/policies/block-destructive-commands/implementations/block-destructive.sh" + settings["hooks"]["beforeShellExecution"] = [ + {"command": f'"/usr/bin/python3" "{root}/.chock/bin/cursor.py" --guard "{root}/{guard}"', "timeout": 30} + ] hooks_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") install_hooks(repo, "cursor") - settings = json.loads(hooks_path.read_text(encoding="utf-8")) - command = settings["hooks"]["beforeShellExecution"][0]["command"] - assert sys.executable in command, "a dead interpreter path must be rebaked to one that runs here" + entries = json.loads(hooks_path.read_text(encoding="utf-8"))["hooks"]["beforeShellExecution"] + assert entries == [current], "the old entry is ours: replaced by the launcher form, not duplicated" def test_coverage_witness_is_per_agent() -> None: @@ -164,19 +154,14 @@ def test_adapter_parses_cursor_payload_and_denies() -> None: repo = _fresh_repo() install_hooks(repo, "cursor") settings = json.loads((repo / ".cursor" / "hooks.json").read_text(encoding="utf-8")) - command = settings["hooks"]["beforeShellExecution"][0]["command"].replace("${CLAUDE_PROJECT_DIR}", str(repo)) + command = settings["hooks"]["beforeShellExecution"][0]["command"] payload = json.dumps({"command": "rm -rf /", "cwd": str(repo), "hook_event_name": "beforeShellExecution"}) - proc = subprocess.run( - command, - cwd=repo, - shell=True, - env={**os.environ, "CLAUDE_PROJECT_DIR": str(repo)}, - capture_output=True, - text=True, - input=payload, - ) - assert proc.returncode == 0, f"adapter errored on a Cursor-shaped payload:\n{proc.stdout}{proc.stderr}" - decision = json.loads(proc.stdout) - assert decision["permission"] == "deny", ( - f"adapter did not deny a Cursor-shaped payload:\n{proc.stdout}{proc.stderr}" - ) + nested = repo / "sub" / "dir" + nested.mkdir(parents=True) + for cwd in (repo, nested): + proc = run_hook_command(command, cwd, payload) + assert proc.returncode == 0, f"adapter errored on a Cursor-shaped payload:\n{proc.stdout}{proc.stderr}" + decision = json.loads(proc.stdout) + assert decision["permission"] == "deny", ( + f"adapter did not deny a Cursor-shaped payload from {cwd}:\n{proc.stdout}{proc.stderr}" + ) diff --git a/tests/test_generic_hooks_install.py b/tests/test_generic_hooks_install.py index 97221de..8e35db0 100644 --- a/tests/test_generic_hooks_install.py +++ b/tests/test_generic_hooks_install.py @@ -14,6 +14,7 @@ from chock.compile.emitters.in_agent import GENERIC_VENDORS from chock.compile.surfaces import Surface from chock.hooks.in_agent_install import install_hooks, installed_policy_ids +from chock.hooks.launch import LAUNCHER_REL, hook_command POLICY = "block-destructive-commands" @@ -68,10 +69,13 @@ def test_install_writes_config_runtime_and_reports(tmp_path: Path, vendor: str) assert (repo / ".chock" / "bin" / f"{vendor}.py").exists() commands = _commands(_config(repo, vendor)) assert any(f".chock/bin/{vendor}.py" in c for c in commands) + compiled = repo / ".chock" / "compiled" / POLICY / "pre-tool-use" / f"{vendor}-hooks.json" + assert commands == _commands(json.loads(compiled.read_text(encoding="utf-8"))), "installed == compiled" + assert (repo / LAUNCHER_REL).is_file() # Compared on the parsed strings, not the JSON text: a Windows interpreter path is # backslash-escaped on disk and would never match its own `sys.executable`. - assert any(sys.executable in c for c in commands), "the interpreter placeholder must be baked at install" - assert not any("@CHOCK_PYTHON@" in c for c in commands) + assert not any(sys.executable in c for c in commands), "no interpreter path may be committed" + assert not any("@CHOCK_PYTHON@" in c or "${" in c for c in commands) assert installed_policy_ids(repo, vendor) == {POLICY} @@ -132,16 +136,18 @@ def test_windsurf_wires_both_recorded_pre_tool_events(tmp_path: Path) -> None: assert set(hooks) == {"pre_run_command", "pre_mcp_tool_use"} -def test_a_stale_interpreter_is_rebaked_not_reused(tmp_path: Path) -> None: +def test_an_old_baked_entry_is_replaced_by_the_launcher_form(tmp_path: Path) -> None: repo = _repo(tmp_path) install_hooks(repo, "devin") config_path = repo / vendors.config_path("devin") - stale = json.loads(config_path.read_text(encoding="utf-8")) - _replace_in_commands(stale, sys.executable, "/no/such/python3") - config_path.write_text(json.dumps(stale, indent=2), encoding="utf-8") + current = config_path.read_bytes() + old = json.loads(current) + launcher = hook_command(".chock/bin/devin.py") + _replace_in_commands(old, launcher, '"/no/such/python3" ".chock/bin/devin.py"') + assert "/no/such/python3" in json.dumps(old) + config_path.write_text(json.dumps(old, indent=2), encoding="utf-8") + assert installed_policy_ids(repo, "devin") == set(), "an old-form entry is not current" install_hooks(repo, "devin") - commands = _commands(_config(repo, "devin")) - assert not any("/no/such/python3" in c for c in commands) - assert any(sys.executable in c for c in commands) + assert config_path.read_bytes() == current, "the old entry is ours: replaced, not kept beside the new one" diff --git a/tests/test_hook_launcher.py b/tests/test_hook_launcher.py new file mode 100644 index 0000000..aa051ba --- /dev/null +++ b/tests/test_hook_launcher.py @@ -0,0 +1,210 @@ +"""The committed hook launcher: one command form every agent runs, and the Python it picks.""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import sys +from pathlib import Path, PurePath + +import pytest +from conftest import FRAMEWORK_ROOT, baseline_policy, bash_executable, init_repo + +from chock.compile.compiler import compile_policy +from chock.compile.surfaces import Surface +from chock.hooks import launch +from chock.hooks.launch import LAUNCHER_REL, PYTHON_CONFIG_KEY, hook_command, record_interpreter, write_launcher + +posix_only = pytest.mark.skipif(sys.platform == "win32", reason="symlinked PATH shims are a POSIX construct") + +#: A stand-in runtime: reports which interpreter ran it, from where, and with what. +_PROBE = ( + "import json, os, sys\n" + "print(json.dumps({'exe': sys.executable, 'cwd': os.getcwd(), 'args': sys.argv[1:], 'utf8': sys.flags.utf8_mode}))\n" +) +_PROBE_REL = ".chock/bin/probe.py" + + +def _git_repo(tmp_path: Path) -> Path: + (tmp_path / "repo").mkdir() + return init_repo(tmp_path / "repo") + + +def _repo(tmp_path: Path) -> Path: + repo = _git_repo(tmp_path) + write_launcher(repo) + (repo / _PROBE_REL).write_text(_PROBE, encoding="utf-8") + return repo + + +def _bin_dir(tmp_path: Path, name: str, **links: str) -> Path: + """A PATH directory holding git and sh plus `links` (name -> target), nothing else.""" + bin_dir = tmp_path / name + bin_dir.mkdir() + for tool in ("git", "sh"): + found = shutil.which(tool) + assert found, tool + (bin_dir / tool).symlink_to(found) + for link, target in links.items(): + (bin_dir / link).symlink_to(target) + return bin_dir + + +def _failing(bin_dir: Path, name: str) -> None: + """A `name` that exists on PATH 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 _run(repo: Path, cwd: Path, path: str | None = None) -> subprocess.CompletedProcess: + env = dict(os.environ) + if path is not None: + env["PATH"] = path + command = hook_command(_PROBE_REL, "--guard", "a/b.sh") + return subprocess.run( + [bash_executable(), "-c", command], cwd=cwd, env=env, capture_output=True, text=True, check=False + ) + + +def _probe(proc: subprocess.CompletedProcess) -> dict: + assert proc.returncode == 0, proc.stdout + proc.stderr + return json.loads(proc.stdout) + + +def test_launcher_runs_the_runtime_from_the_repo_root_when_started_in_a_subdirectory(tmp_path: Path) -> None: + repo = _repo(tmp_path) + nested = repo / "a" / "b" + nested.mkdir(parents=True) + out = _probe(_run(repo, nested)) + assert Path(out["cwd"]).resolve() == repo.resolve() + assert out["args"] == ["--guard", "a/b.sh"] + assert out["utf8"] == 1, "the launcher runs Python with -X utf8" + + +@posix_only +def test_a_python3_that_does_not_run_is_skipped(tmp_path: Path) -> None: + repo = _repo(tmp_path) + bin_dir = _bin_dir(tmp_path, "bin", python=sys.executable) + _failing(bin_dir, "python3") + out = _probe(_run(repo, repo, path=str(bin_dir))) + assert out["exe"] == str(bin_dir / "python"), "the failing python3 must be passed over" + + +@posix_only +def test_the_recorded_interpreter_is_preferred(tmp_path: Path) -> None: + repo = _repo(tmp_path) + recorded = _bin_dir(tmp_path, "recorded", python3=sys.executable) / "python3" + bin_dir = _bin_dir(tmp_path, "bin", python3=sys.executable) + subprocess.run(["git", "config", "--local", PYTHON_CONFIG_KEY, str(recorded)], cwd=repo, check=True) + out = _probe(_run(repo, repo, path=str(bin_dir))) + assert out["exe"] == str(recorded) + + +@posix_only +def test_a_stale_recorded_interpreter_falls_back_to_path(tmp_path: Path) -> None: + repo = _repo(tmp_path) + bin_dir = _bin_dir(tmp_path, "bin", python3=sys.executable) + subprocess.run(["git", "config", "--local", PYTHON_CONFIG_KEY, "/no/such/python3"], cwd=repo, check=True) + out = _probe(_run(repo, repo, path=str(bin_dir))) + assert out["exe"] == str(bin_dir / "python3") + + +@posix_only +def test_no_python_anywhere_refuses_with_an_actionable_message(tmp_path: Path) -> None: + repo = _repo(tmp_path) + bin_dir = _bin_dir(tmp_path, "bin") + proc = _run(repo, repo, path=str(bin_dir)) + assert proc.returncode == 2, "no Python must refuse (exit 2), never allow" + assert proc.stdout == "" + assert "no working Python" in proc.stderr + assert "git config chock.python" in proc.stderr + + +def test_the_launcher_is_written_with_lf_and_the_exec_bit(tmp_path: Path) -> None: + dest = write_launcher(tmp_path) + assert dest == tmp_path / LAUNCHER_REL + raw = dest.read_bytes() + assert b"\r" not in raw + assert raw.decode("utf-8") == launch.launcher_text() + if sys.platform != "win32": + assert dest.stat().st_mode & stat.S_IXUSR + + +def test_record_interpreter_writes_local_config_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + global_config = tmp_path / "global.gitconfig" + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(global_config)) + repo = _git_repo(tmp_path) + assert record_interpreter(repo) is True + local = subprocess.run( + ["git", "config", "--local", "--get", PYTHON_CONFIG_KEY], cwd=repo, capture_output=True, text=True, check=True + ) + assert local.stdout.strip() == PurePath(sys.executable).as_posix() + assert not global_config.exists(), "the interpreter is this clone's, never the user's global config" + status = subprocess.run(["git", "status", "--porcelain"], cwd=repo, capture_output=True, text=True, check=True) + assert status.stdout == "", "nothing committable is written" + + +@pytest.mark.parametrize( + "args", + [(), ("--guard", ".agents/policies/p/implementations/g.sh"), ("--gate", ".chock/compiled/p/stop/gate.json")], +) +def test_hook_command_reads_the_same_under_every_shell(args: tuple[str, ...]) -> None: + command = hook_command(".chock/bin/cursor.py", *args) + assert command.startswith('git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook .chock/bin/cursor.py') + for char in ("$", "\\", "'"): + assert char not in command, f"{char!r} is read differently by bash, PowerShell or cmd.exe" + + +def _commands(node) -> list[str]: + if isinstance(node, dict): + return [v for k, v in node.items() if k in {"command", "bash", "powershell"} and isinstance(v, str)] + [ + c for v in node.values() for c in _commands(v) + ] + if isinstance(node, list): + return [c for v in node for c in _commands(v)] + return [] + + +def test_this_repo_commits_only_launcher_form_hook_commands() -> None: + """chock adopts itself: no committed vendor config or fragment names an interpreter.""" + listed = subprocess.run( + ["git", "ls-files", "*.json"], cwd=FRAMEWORK_ROOT, capture_output=True, text=True, check=True + ).stdout.split() + checked = 0 + for rel in listed: + if rel.startswith("tests/"): + continue + try: + doc = json.loads((FRAMEWORK_ROOT / rel).read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + continue + for command in _commands(doc): + if ".chock/bin/" not in command: + continue + checked += 1 + assert command.startswith(launch._PREFIX), f"{rel}: {command}" + assert "@CHOCK_PYTHON@" not in command and "$" not in command, f"{rel}: {command}" + assert checked, "expected this repo's own wired hook commands" + + +def test_cursor_fail_closed_on_gates_and_not_on_stop(tmp_path: Path) -> None: + out = tmp_path / ".chock" / "compiled" + for policy in ("block-destructive-commands", "pin-github-actions"): + compile_policy( + baseline_policy(policy), + targets=[Surface.PRE_TOOL_USE.value, Surface.STOP.value], + output_root=out, + agents=["cursor"], + repo_root=tmp_path, + ) + shell = json.loads((out / "block-destructive-commands" / "pre-tool-use" / "cursor-hooks.json").read_text()) + write = json.loads((out / "pin-github-actions" / "pre-tool-use" / "cursor-write-hooks.json").read_text()) + stop = json.loads((out / "pin-github-actions" / "stop" / "cursor-hooks.json").read_text()) + assert all(e.get("failClosed") is True for e in shell["beforeShellExecution"]) + assert all(e.get("failClosed") is True for e in write["preToolUse"]) + assert stop["stop"], "expected a stop entry" + assert all("failClosed" not in e for e in stop["stop"]) diff --git a/tests/test_pretooluse.py b/tests/test_pretooluse.py index dc4daa3..186e10a 100644 --- a/tests/test_pretooluse.py +++ b/tests/test_pretooluse.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest -from conftest import baseline_policy +from conftest import baseline_policy, run_hook_command from chock.gate import runtime_bundle from chock.gate.guard_runner import find_bash @@ -164,7 +164,9 @@ def test_install_writes_claude_settings_schema() -> None: assert entry["matcher"] == "Bash" hook = entry["hooks"][0] assert hook["type"] == "command" - assert "${CLAUDE_PROJECT_DIR}" in hook["command"], "paths must survive a repo move" + assert hook["command"].startswith('git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook ') + assert "${" not in hook["command"], "repo-relative: git runs the launcher from the top level" + assert sys.executable not in hook["command"], "no interpreter path may be committed" assert (repo / ".chock" / "bin" / "claude_code.py").exists() @@ -247,20 +249,20 @@ def test_end_to_end_installed_hooks_block_real_commands() -> None: subprocess.run([sys.executable, "-m", "chock.cli", "install-hooks", "."], cwd=repo, capture_output=True, env=env) settings = json.loads((repo / ".claude" / "settings.json").read_text(encoding="utf-8")) entries = settings["hooks"]["PreToolUse"] - env = {**env, "CLAUDE_PROJECT_DIR": str(repo)} + nested = repo / "pkg" / "sub" + nested.mkdir(parents=True) - def blocked(command: str) -> bool: + def blocked(command: str, cwd: Path = repo) -> bool: for entry in entries: - cmd = entry["hooks"][0]["command"].replace("${CLAUDE_PROJECT_DIR}", str(repo)) - proc = subprocess.run( - cmd, cwd=repo, shell=True, env=env, capture_output=True, text=True, input=_payload(command) - ) + proc = run_hook_command(entry["hooks"][0]["command"], cwd, _payload(command), env=env) if _denied(proc): return True return False assert blocked("rm -rf /") + assert blocked("rm -rf /", nested), "a session started below the repo root is still guarded" assert blocked("git push --force origin main") assert blocked("git commit --no-verify -m x") assert not blocked("git push --force-with-lease origin main") assert not blocked("ls -la") + assert not blocked("ls -la", nested) diff --git a/tests/test_pretooluse_interpreter.py b/tests/test_pretooluse_interpreter.py index bbe81f9..e0cfa5f 100644 --- a/tests/test_pretooluse_interpreter.py +++ b/tests/test_pretooluse_interpreter.py @@ -1,26 +1,27 @@ -"""The PreToolUse guard must run even where a bare `python` is not on PATH.""" +"""The PreToolUse guard runs through the launcher: no interpreter path is ever committed.""" from __future__ import annotations import json import os +import shutil import subprocess import sys import tempfile from pathlib import Path -from conftest import baseline_policy +import pytest +from conftest import baseline_policy, bash_executable, run_hook_command from chock.compile.compiler import compile_policy from chock.compile.surfaces import Surface -from chock.hooks.in_agent_install import INTERPRETER_PLACEHOLDER, install_hooks, installed_policy_ids +from chock.hooks.in_agent_install import install_hooks, installed_policy_ids +from chock.hooks.launch import hook_command, record_interpreter FRAMEWORK_ROOT = Path(__file__).resolve().parents[1] def _fresh_repo() -> tuple[Path, dict]: - import shutil - repo = Path(tempfile.mkdtemp()) / "r" repo.mkdir() env = {**os.environ, "PYTHONPATH": str(FRAMEWORK_ROOT / "src")} @@ -53,93 +54,121 @@ def _payload(command: str) -> str: ) -def test_compiled_fragment_keeps_the_placeholder(tmp_path: Path) -> None: +GUARD = ".agents/policies/block-destructive-commands/implementations/block-destructive.sh" + + +def _settings_path(repo: Path) -> Path: + return repo / ".claude" / "settings.json" + + +def _pre_tool_command(repo: Path) -> str: + settings = json.loads(_settings_path(repo).read_text(encoding="utf-8")) + return settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + + +def test_compiled_fragment_is_the_launcher_form(tmp_path: Path) -> None: policy = baseline_policy("block-destructive-commands") out = tmp_path / ".chock" / "compiled" compile_policy(policy, targets=[Surface.PRE_TOOL_USE.value], output_root=out, agents=["claude"]) frag = json.loads((out / "block-destructive-commands" / "pre-tool-use" / "pretooluse.json").read_text()) - command = frag["hooks"][0]["command"] - assert INTERPRETER_PLACEHOLDER in command - assert not command.startswith("python ") + assert frag["hooks"][0]["command"] == hook_command(".chock/bin/claude_code.py", "--guard", GUARD) -def test_installed_command_bakes_a_real_interpreter() -> None: +def test_installed_command_carries_no_interpreter_and_equals_the_compiled_fragment() -> None: repo, _ = _fresh_repo() install_hooks(repo, "claude_code") - settings = json.loads((repo / ".claude" / "settings.json").read_text(encoding="utf-8")) - command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - assert INTERPRETER_PLACEHOLDER not in command, "install must substitute the placeholder" - assert sys.executable in command - - -def test_guard_blocks_even_with_no_python_on_path() -> None: + command = _pre_tool_command(repo) + compiled = repo / ".chock" / "compiled" / "block-destructive-commands" / "pre-tool-use" / "pretooluse.json" + assert command == json.loads(compiled.read_text(encoding="utf-8"))["hooks"][0]["command"] + assert sys.executable not in command + assert "@CHOCK_PYTHON@" not in command + assert "${" not in command + + +def _only_git_and_sh(tmp_path: Path) -> str: + """A PATH directory holding git and sh and nothing else: no python on it at all.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + for tool in ("git", "sh"): + found = shutil.which(tool) + assert found, tool + (bin_dir / tool).symlink_to(found) + return str(bin_dir) + + +@pytest.mark.skipif(sys.platform == "win32", reason="symlinked PATH shims are a POSIX construct") +def test_guard_blocks_with_no_python_on_path_via_the_recorded_interpreter(tmp_path: Path) -> None: repo, _ = _fresh_repo() install_hooks(repo, "claude_code") - settings = json.loads((repo / ".claude" / "settings.json").read_text(encoding="utf-8")) - command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"].replace("${CLAUDE_PROJECT_DIR}", str(repo)) - stripped = {k: v for k, v in os.environ.items() if k != "PATH"} - stripped["PATH"] = "" - stripped["CLAUDE_PROJECT_DIR"] = str(repo) + assert record_interpreter(repo) + env = {k: v for k, v in os.environ.items() if k != "PATH"} + env["PATH"] = _only_git_and_sh(tmp_path) proc = subprocess.run( - command, cwd=repo, shell=True, env=stripped, capture_output=True, text=True, input=_payload("rm -rf /") + [bash_executable(), "-c", _pre_tool_command(repo)], + cwd=repo, + env=env, + capture_output=True, + text=True, + input=_payload("rm -rf /"), + check=False, ) - assert proc.returncode == 0, f"guard errored with PATH stripped:\n{proc.stdout}{proc.stderr}" + assert proc.returncode == 0, f"guard errored with no python on PATH:\n{proc.stdout}{proc.stderr}" decision = json.loads(proc.stdout) - assert decision["hookSpecificOutput"]["permissionDecision"] == "deny", ( - f"guard did not block with PATH stripped:\n{proc.stdout}{proc.stderr}" - ) + assert decision["hookSpecificOutput"]["permissionDecision"] == "deny", proc.stdout + proc.stderr -def test_coverage_detection_survives_the_bake() -> None: - repo, _ = _fresh_repo() +def test_guard_denies_from_a_nested_subdirectory() -> None: + repo, env = _fresh_repo() install_hooks(repo, "claude_code") - assert "block-destructive-commands" in installed_policy_ids(repo, "claude_code") - - -def _rewrite_interpreter(repo: Path, token: str) -> None: - settings_path = repo / ".claude" / "settings.json" - settings = json.loads(settings_path.read_text(encoding="utf-8")) - for entry in settings["hooks"]["PreToolUse"]: - for hook in entry["hooks"]: - command = hook["command"] - hook["command"] = token + command[command.index(' "') :] - settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + nested = repo / "a" / "b" + nested.mkdir(parents=True) + proc = run_hook_command(_pre_tool_command(repo), nested, _payload("rm -rf /"), env=env) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert json.loads(proc.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" -def test_coverage_is_machine_independent() -> None: +def test_coverage_detection_sees_the_installed_entry() -> None: repo, _ = _fresh_repo() install_hooks(repo, "claude_code") - for token in ('"/usr/bin/python3.12"', "python"): - _rewrite_interpreter(repo, token) - assert "block-destructive-commands" in installed_policy_ids(repo, "claude_code"), token - - -def _fake_but_real_interpreter(tmp_path: Path) -> str: - """A path that is not `sys.executable` but genuinely resolves on this machine.""" - import shutil - - fake = tmp_path / "another-machine-python3" - shutil.copy(sys.executable, fake) - fake.chmod(0o755) - return str(fake) + assert "block-destructive-commands" in installed_policy_ids(repo, "claude_code") -def test_reinstall_does_not_churn_a_committed_entry(tmp_path: Path) -> None: +def test_reinstall_on_another_machine_is_byte_identical(monkeypatch: pytest.MonkeyPatch) -> None: repo, _ = _fresh_repo() install_hooks(repo, "claude_code") - other = _fake_but_real_interpreter(tmp_path) - _rewrite_interpreter(repo, f'"{other}"') - before = (repo / ".claude" / "settings.json").read_text(encoding="utf-8") + before = _settings_path(repo).read_bytes() + monkeypatch.setattr(sys, "executable", "/opt/elsewhere/bin/python3") install_hooks(repo, "claude_code") - after = (repo / ".claude" / "settings.json").read_text(encoding="utf-8") - assert json.loads(after) == json.loads(before), "an equivalent, still-runnable installed entry was re-baked" + assert _settings_path(repo).read_bytes() == before, "a reinstall elsewhere must be a zero diff" + + +def _old_baked_entry(interpreter: str) -> dict: + root = "${CLAUDE_PROJECT_DIR}" + return { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": f'"{interpreter}" "{root}/.chock/bin/claude_code.py" --guard "{root}/{GUARD}"', + "timeout": 30, + } + ], + } -def test_reinstall_rebakes_an_interpreter_that_no_longer_resolves() -> None: +def test_an_old_baked_entry_is_replaced_by_the_launcher_form_and_foreign_entries_kept() -> None: repo, _ = _fresh_repo() + theirs = {"matcher": "Bash", "hooks": [{"type": "command", "command": "./scripts/audit.sh"}]} + old = _old_baked_entry("/usr/bin/python3.12") + path = _settings_path(repo) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"hooks": {"PreToolUse": [theirs, old]}}), encoding="utf-8") + assert "block-destructive-commands" not in installed_policy_ids(repo, "claude_code"), ( + "an old-form entry is not current: sync must rewrite it" + ) install_hooks(repo, "claude_code") - _rewrite_interpreter(repo, '"/usr/local/bin/definitely-not-a-real-interpreter3"') - install_hooks(repo, "claude_code") - settings = json.loads((repo / ".claude" / "settings.json").read_text(encoding="utf-8")) - command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - assert sys.executable in command, "a dead interpreter path must be rebaked to one that runs here" + entries = json.loads(path.read_text(encoding="utf-8"))["hooks"]["PreToolUse"] + assert entries[0] == theirs, "the adopter's own entry must survive, first" + assert len(entries) == 2, "the old entry is ours: replaced, not kept beside the new one" + assert entries[1]["hooks"][0]["command"] == hook_command(".chock/bin/claude_code.py", "--guard", GUARD) + assert "block-destructive-commands" in installed_policy_ids(repo, "claude_code") diff --git a/tests/test_sessionstart_arm.py b/tests/test_sessionstart_arm.py index 3d93447..5a2b1e9 100644 --- a/tests/test_sessionstart_arm.py +++ b/tests/test_sessionstart_arm.py @@ -11,7 +11,7 @@ from pathlib import Path from chock.gate import runtime_bundle -from chock.hooks.in_agent_install import INTERPRETER_PLACEHOLDER +from chock.hooks.launch import LAUNCHER_REL, hook_command from chock.hooks.sessionstart_install import install_sessionstart_hook FRAMEWORK_ROOT = Path(__file__).resolve().parents[1] @@ -46,16 +46,18 @@ def _entry_command(settings: dict) -> str: return settings["hooks"]["SessionStart"][-1]["hooks"][0]["command"] -def test_install_bakes_interpreter_and_vendors_runtime() -> None: +def test_install_writes_the_launcher_form_and_vendors_runtime() -> None: repo = _bare_repo() assert install_sessionstart_hook(repo) is True command = _entry_command(_settings(repo)) - assert INTERPRETER_PLACEHOLDER not in command - assert sys.executable in command + assert command == hook_command(".chock/bin/claude_code.py") + assert sys.executable not in command, "no interpreter path may be committed" + assert "${" not in command vendored = repo / ".chock" / "bin" / "claude_code.py" assert vendored.read_text(encoding="utf-8") == runtime_bundle.render("claude_code"), ( "vendored copy must match the render exactly" ) + assert (repo / LAUNCHER_REL).is_file(), "the arm command runs through the launcher" def test_reinstall_is_a_no_op() -> None: @@ -78,43 +80,36 @@ def test_adopter_sessionstart_entries_survive() -> None: assert len(entries) == 2 -def _fake_but_real_interpreter(tmp_path: Path) -> str: - """A path that is not `sys.executable` but genuinely resolves on this machine.""" - import shutil - - fake = tmp_path / "another-machine-python3" - shutil.copy(sys.executable, fake) - fake.chmod(0o755) - return str(fake) - - -def test_committed_entry_from_another_machine_is_kept_byte_for_byte(tmp_path: Path) -> None: +def test_reinstall_on_another_machine_is_a_no_op(monkeypatch) -> None: + """Nothing machine-specific is written, so a different interpreter changes nothing.""" repo = _bare_repo() install_sessionstart_hook(repo) - settings_path = repo / ".claude" / "settings.json" - settings = _settings(repo) - hook = settings["hooks"]["SessionStart"][0]["hooks"][0] - other = _fake_but_real_interpreter(tmp_path) - hook["command"] = f'"{other}"' + hook["command"][hook["command"].index(' "') :] - settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") - before = settings_path.read_text(encoding="utf-8") + before = (repo / ".claude" / "settings.json").read_bytes() + monkeypatch.setattr(sys, "executable", "/opt/elsewhere/bin/python3") assert install_sessionstart_hook(repo) is False - assert settings_path.read_text(encoding="utf-8") == before + assert (repo / ".claude" / "settings.json").read_bytes() == before -def test_reinstalls_when_the_committed_interpreter_no_longer_resolves(tmp_path: Path) -> None: +def test_an_old_baked_entry_is_replaced_and_foreign_entries_kept() -> None: repo = _bare_repo() - install_sessionstart_hook(repo) settings_path = repo / ".claude" / "settings.json" - settings = _settings(repo) - hook = settings["hooks"]["SessionStart"][0]["hooks"][0] - hook["command"] = ( - '"/usr/local/bin/definitely-not-a-real-interpreter3"' + hook["command"][hook["command"].index(' "') :] - ) - settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + settings_path.parent.mkdir(parents=True) + theirs = {"hooks": [{"type": "command", "command": "echo hello"}]} + old = { + "hooks": [ + { + "type": "command", + "command": '"/usr/bin/python3" "${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py"', + "timeout": 300, + } + ] + } + settings_path.write_text(json.dumps({"hooks": {"SessionStart": [theirs, old]}}), encoding="utf-8") assert install_sessionstart_hook(repo) is True - command = _entry_command(_settings(repo)) - assert sys.executable in command, "a dead interpreter path must be rebaked to one that runs here" + entries = _settings(repo)["hooks"]["SessionStart"] + assert entries[0] == theirs + assert len(entries) == 2, "the old entry is recognised as ours and replaced, not duplicated" + assert entries[1]["hooks"][0]["command"] == hook_command(".chock/bin/claude_code.py") def _load_runtime(path: Path): diff --git a/tests/test_sync_wires_only_supported_agents.py b/tests/test_sync_wires_only_supported_agents.py index 3c32818..2a0d689 100644 --- a/tests/test_sync_wires_only_supported_agents.py +++ b/tests/test_sync_wires_only_supported_agents.py @@ -8,6 +8,7 @@ from conftest import baseline_policy, init_repo from chock.hooks.in_agent_install import WIRED_VENDORS +from chock.hooks.launch import LAUNCHER_REL from chock.scaffold.recompile import recompile, wired_vendors from chock.vendors import CHOCK_AGENT @@ -38,8 +39,9 @@ def test_a_claude_only_repo_gets_no_other_vendors_config(tmp_path: Path) -> None assert (repo / ".chock" / "bin" / "claude_code.py").exists() for stray in (".cursor", ".codex", ".windsurf", ".devin", ".gemini", ".github/hooks", ".agents/hooks.json"): assert not (repo / stray).exists(), stray - runtimes = sorted(p.name for p in (repo / ".chock" / "bin").iterdir() if p.name != "gate.py") + runtimes = sorted(p.name for p in (repo / ".chock" / "bin").iterdir() if p.name not in {"gate.py", "launch.sh"}) assert runtimes == ["claude_code.py"], "no other vendor's runtime is vendored either" + assert (repo / LAUNCHER_REL).is_file(), "the launcher every hook command runs" def test_a_cursor_only_repo_gets_no_claude_settings(tmp_path: Path) -> None: diff --git a/tests/test_template_tokens.py b/tests/test_template_tokens.py index fdf22fd..41f867e 100644 --- a/tests/test_template_tokens.py +++ b/tests/test_template_tokens.py @@ -6,9 +6,9 @@ import re -from chock.compile.emitters import ci, git_hook, in_agent +from chock.compile.emitters import ci, git_hook from chock.gate import runtime_bundle -from chock.hooks import installers +from chock.hooks import installers, launch _TOKEN_RE = re.compile(r"__[A-Z0-9_]+__") @@ -47,20 +47,11 @@ def test_git_hook_script_shim_template_tokens() -> None: assert "demo-pre-commit.py" in rendered -def test_in_agent_bash_template_tokens() -> None: - rendered = _assert_round_trips( - in_agent._BASH_TEMPLATE, {"__ADAPTER__": ".chock/bin/x.py", "__GUARD__": "impl/x.sh"} - ) - assert ".chock/bin/x.py" in rendered - assert "impl/x.sh" in rendered - - -def test_in_agent_powershell_template_tokens() -> None: - rendered = _assert_round_trips( - in_agent._POWERSHELL_TEMPLATE, {"__ADAPTER__": ".chock/bin/x.py", "__GUARD__": "impl/x.sh"} - ) - assert ".chock/bin/x.py" in rendered - assert "impl/x.sh" in rendered +def test_launcher_template_tokens() -> None: + rendered = _assert_round_trips(launch._TEMPLATE, {"__MIN_PYTHON__": "3, 11", "__MIN_PYTHON_TEXT__": "3.11"}) + assert rendered == launch.launcher_text(), "launcher_text() substitutes every token" + assert "sys.version_info < (3, 11,)" in rendered + assert "Python 3.11+" in rendered def test_dispatcher_template_tokens() -> None: diff --git a/tests/test_vendor_wire_facts.py b/tests/test_vendor_wire_facts.py index 87d40bb..93b635e 100644 --- a/tests/test_vendor_wire_facts.py +++ b/tests/test_vendor_wire_facts.py @@ -12,11 +12,12 @@ import json from agentseam import adapters -from agentseam.vendor_config import SCHEMA, VENDOR_CONFIG +from agentseam.vendor_config import VENDOR_CONFIG from chock import evidence, vendors from chock.compile.emitters import in_agent, in_agent_hooks from chock.hooks.in_agent_install import WIRED_VENDORS, agent_hooks_rel +from chock.hooks.launch import hook_command def test_derived_wire_facts_still_produce_todays_bytes() -> None: @@ -86,29 +87,32 @@ def test_agent_hooks_shape_is_a_witnessed_override_until_upstream_ingests_it() - ) -def test_repo_root_token_is_derived_from_vendor_config() -> None: - """The `${CLAUDE_PROJECT_DIR}` wire token used to live in chock as a hardcoded copy. +def test_no_emitted_hook_command_uses_a_repo_root_token() -> None: + """Hook commands no longer anchor on `${CLAUDE_PROJECT_DIR}`: git runs the launcher from the top level. - agentseam's vendor-config schema now carries `repo_root_token`; PROJECT_DIR_TOKEN must - read it via vendors.repo_root_token instead. If a future release drops the field again, - this fails and says to hardcode the token back. + The token only ever existed for claude_code (the `${CLAUDE_PROJECT_DIR}` gap), so every + other vendor's relative path broke in a subdirectory; the launcher form has no such gap. """ - assert "repo_root_token" in SCHEMA["properties"], "agentseam's vendor-config schema lost repo_root_token" - assert VENDOR_CONFIG["claude_code"]["repo_root_token"] == "${CLAUDE_PROJECT_DIR}" - assert vendors.repo_root_token("claude_code") == in_agent.PROJECT_DIR_TOKEN == "${CLAUDE_PROJECT_DIR}" + tokens = {vendors.repo_root_token(v) for v in VENDOR_CONFIG} - {None} + assert "${CLAUDE_PROJECT_DIR}" in tokens, "agentseam's vendor config lost repo_root_token" + assert not hasattr(in_agent, "PROJECT_DIR_TOKEN") + command = hook_command(".chock/bin/claude_code.py", "--guard", "p/implementations/g.sh") + assert not any(token in command for token in tokens) -def test_cursor_fail_closed_stays_unset_pending_the_owner_decision() -> None: - """M.5(5): setting failClosed is an enforcement-behaviour change (owner decision plus a +def test_cursor_fail_closed_is_set_only_where_a_gate_must_refuse() -> None: + """Cursor allows when a hook cannot start unless the entry says failClosed. - witnessed run), never a silent flag-flip. chock's cursor wire bytes carry no failClosed; - the flag's one source, when decided, is agentseam's public fail_closed accessor. + chock's pre-tool and shell entries set it (a hook that cannot start must block); stop + entries do not. The key chock writes is the one agentseam's renderer writes for the flag. """ assert "failClosed" not in json.dumps(in_agent_hooks.cursor_hooks_file("CMD")) assert "failClosed" not in json.dumps(in_agent_hooks.cursor_entry("CMD")) + ours = in_agent_hooks.cursor_entry("CMD", fail_closed=True) rendered = adapters.get("cursor").hook_config(("pre_tool",), "CMD", fail_closed=True) (entry,) = rendered["hooks"]["preToolUse"] assert entry.get("failClosed") is True + assert ours.get("failClosed") is True rendered = adapters.get("cursor").hook_config(("pre_tool",), "CMD", fail_closed=None) (entry,) = rendered["hooks"]["preToolUse"] assert "failClosed" not in entry From 9e54596eff5af98e5d641b301ebe2740740b41e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:49:27 -0400 Subject: [PATCH 12/25] Report a missing or ignored hook launcher as a dangling target Every agent hook now runs .chock/bin/launch.sh before the runtime, but the dangling-target check only recognised `.chock/bin/.py` and compiled gates. A clone without the launcher (deleted, or dropped by a global ignore) failed every hook while `chock check` stayed silent. The check now matches the launcher path too. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- src/chock/validation/checks_hook_targets.py | 11 ++++++----- tests/test_dangling_hook_target_check.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/chock/validation/checks_hook_targets.py b/src/chock/validation/checks_hook_targets.py index 2840ab4..5054044 100644 --- a/src/chock/validation/checks_hook_targets.py +++ b/src/chock/validation/checks_hook_targets.py @@ -11,11 +11,12 @@ from chock.hooks.in_agent_install import WIRED_VENDORS, agent_hooks_rel from chock.validation.report import Finding, Report -#: A file an agent hook command runs or hands the gate: the runtime (`.chock/bin/.py`) -#: and the compiled gate it reads. chock is the only writer of both directories, so naming a -#: path under them identifies an entry as chock's own, independent of the vendor-specific shapes -#: `in_agent_merged.py`/`in_agent_generic.py` merge it through. -_BIN_TARGET_RE = re.compile(r"\.chock/(?:bin/[\w.-]+\.py|compiled/[\w./-]+\.json)") +#: A file an agent hook command runs or hands the gate: the launcher (`.chock/bin/launch.sh`), +#: the runtime (`.chock/bin/.py`) and the compiled gate it reads. chock is the only +#: writer of both directories, so naming a path under them identifies an entry as chock's +#: own, independent of the vendor-specific shapes `in_agent_merged.py`/`in_agent_generic.py` +#: merge it through. +_BIN_TARGET_RE = re.compile(r"\.chock/(?:bin/[\w.-]+\.py|bin/launch\.sh|compiled/[\w./-]+\.json)") def _ignore_rule(root: Path, rel: str) -> str | None: diff --git a/tests/test_dangling_hook_target_check.py b/tests/test_dangling_hook_target_check.py index 713ca2e..8cc2df0 100644 --- a/tests/test_dangling_hook_target_check.py +++ b/tests/test_dangling_hook_target_check.py @@ -39,6 +39,19 @@ def test_a_hand_planted_dangling_entry_fails(tmp_path: Path) -> None: assert "chock sync" in report.errors[0].message +def test_a_missing_launcher_fails(tmp_path: Path) -> None: + """Every hook command runs `.chock/bin/launch.sh` first: without it no gate starts.""" + repo = _repo(tmp_path) + recompile(repo, ["cursor"], skip_hooks=False) + + (repo / ".chock" / "bin" / "launch.sh").unlink() + + report = Report() + check_dangling_hook_targets(repo, report) + assert [f.check for f in report.errors] == ["dangling_hook_target"] + assert ".chock/bin/launch.sh" in report.errors[0].message + + def test_a_clean_tree_is_silent(tmp_path: Path) -> None: repo = _repo(tmp_path) recompile(repo, ["cursor", "claude"], skip_hooks=False) From ca1112e5af56f98ba4357fdfd12790cb837f237e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:49:27 -0400 Subject: [PATCH 13/25] Document the launcher hook form and Cursor's failClosed entries The enforcement-surfaces and assurance-case pages still described an install that bakes an absolute interpreter and Cursor entries that ship fail-open. Describe the launcher command, how it picks a Python, its exit-2 refusal, and which Cursor entries set failClosed; add the changelog entry. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- CHANGELOG.md | 13 +++++++++++++ docs/assurance-case.md | 9 +++++---- docs/enforcement-surfaces.md | 31 ++++++++++++++++++++++--------- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70aa5e..5d25746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- **Agent hooks run on every machine, not just the one that last ran `chock sync`.** Hook + commands in committed agent configs named that machine's absolute Python (even a deleted + temporary one), so on any other clone every hook failed to start -- and Claude Code and Codex + treat that as non-blocking, so the gate silently did nothing. Every in-agent entry is now + `git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook .chock/bin/.py ...`, + read identically by bash, PowerShell and cmd.exe; git runs it from the repository root, so a + session started in a subdirectory is guarded too. The committed `.chock/bin/launch.sh` runs + `git config chock.python` (written to the clone's local config by `chock sync`) or the first + `python3`/`python`/`py` that actually runs Python 3.11+, and with none refuses (exit 2) with a + fix-it message. Cursor's shell and pre-tool entries now set `failClosed`. Re-running + `chock sync` elsewhere is a zero diff; entries in the old form are replaced at the next sync. + `chock check` reports a missing or git-ignored launcher as a dangling hook target. + - **An edit is judged before it lands, not only at the turn's end.** Claude Code changes an existing file with `Edit` or `MultiEdit`, whose payload carries only the text replaced and the text put in. The write gate judged that fragment alone, with no imports, class or neighbours diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 6168382..6440f7c 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -50,8 +50,8 @@ Threat classes defended against: |---|---|---| | Untrusted input is validated by allowlist (T1, T2) | Policy ids: anchored `fullmatch` against a fixed pattern, id must equal folder name; manifests: JSON Schema; agent selections: fixed allowlist; URLs: https-only; catalog paths: confinement to the catalog root | `tests/test_manifest_id_safety.py`, property-based suite (`tests/test_properties.py`), weekly atheris fuzzing of the same parsers | | Installed content is tamper-evident (T2, T3) | `chock.lock` pins sha256 of every pack and compiled artifact; `--verify-sha` refuses mismatched content before it touches disk; `chock check --only verify` re-derives hashes | lockfile test suite; verify exercised in CI on every PR | -| Gates fail closed, not open (T4) | CI range mode exits non-zero on an unresolvable base; hook installer bakes an absolute interpreter path (a missing `python` once meant exit 127 = allow); diff filters include renames and merge commits; `core.quotePath` handled for non-ASCII paths | `tests/test_gate_bypasses.py`, `tests/test_pretooluse*.py` — each closed bypass carries its regression test | -| Enforcement claims are computed, never asserted (T5) | `coverage_level()` credits a surface only when its install is witnessed (hook present, workflow written, settings baked); packaging formats cannot raise coverage (no plugin Surface exists); empty shims report nothing | `tests/test_coverage_honesty*.py`, `tests/test_agent_plugin.py::test_packaging_raises_no_coverage_claim` | +| Gates fail closed, not open (T4) | CI range mode exits non-zero on an unresolvable base; agent hooks run through a committed launcher that refuses (exit 2) when no Python runs, never exit 127 = allow; Cursor gate entries set `failClosed`; diff filters include renames and merge commits; `core.quotePath` handled for non-ASCII paths | `tests/test_gate_bypasses.py`, `tests/test_pretooluse*.py`, `tests/test_hook_launcher.py` — each closed bypass carries its regression test | +| Enforcement claims are computed, never asserted (T5) | `coverage_level()` credits a surface only when its install is witnessed (hook present, workflow written, settings installed); packaging formats cannot raise coverage (no plugin Surface exists); empty shims report nothing | `tests/test_coverage_honesty*.py`, `tests/test_agent_plugin.py::test_packaging_raises_no_coverage_claim` | | Compiled output is deterministic (T3, T5) | All generated artifacts written LF-normalized via one writer; PATCH releases are byte-identical by contract | golden-file suite (`tests/test_emitter_stability.py`) run in CI; `tests/test_windows_line_endings.py` | | An agent cannot widen its own permissions (T1) | `protect-agent-config` pre-tool-use guard blocks shell writes to agent config, hooks, and compiled trees without human approval | guard evals; exercised daily in this repo's own sessions | | Chock's own supply chain is pinned (T6) | Every GitHub Action pinned to a commit SHA; workflow tokens least-privilege; pip installs hash-pinned (`--require-hashes`); publishing via OIDC Trusted Publishing (no stored secrets); releases carry Sigstore build-provenance attestations | Scorecard (public), CodeQL on every PR, the release workflow itself | @@ -61,8 +61,9 @@ Threat classes defended against: - **Least privilege**: workflow tokens are read-only by default, write scopes granted per job; the tool itself requests no credentials and holds no keys. - **Fail-safe defaults**: gates fail closed — an unresolvable CI base ref exits - non-zero rather than passing; the hook installer bakes an absolute interpreter path, - because a missing interpreter meant exit 127, which a host reads as *allow*; an + non-zero rather than passing; agent hooks run through a committed launcher that exits 2 + when no Python runs, because a missing interpreter meant exit 127, which a host reads + as *allow*; an uninstalled surface claims nothing rather than assuming success. - **Complete mediation**: the CI gate re-runs the same compiled gates server-side, where a local `--no-verify` cannot reach; `check --only verify` re-derives hashes diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index b64df3c..0d43202 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -42,9 +42,23 @@ each guarantee holds. > bash-syntax commands but not PowerShell-native destructive syntax. 0.0.6 closes that gap > with a PowerShell/cmd guard matched against the raw command (`CHOCK_RAW_COMMAND`); other > guards remain pattern filters, so the "non-standard shell" bypass class they document -> still applies to them. The hook's interpreter is resolved at run time (skipping the -> Windows Store `python3` alias stub) and the repo root via `git rev-parse`, so the -> committed file is portable with no baked path. +> still applies to them. The hook runs through the launcher below, so the committed file is +> portable with no baked path. + +> **Every in-agent hook runs through one committed launcher.** Each entry chock writes, on +> every vendor, is the same string -- read identically by bash, PowerShell and cmd.exe: +> +> ``` +> git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook .chock/bin/.py [--guard|--gate ""] +> ``` +> +> git runs the alias from the repository's top level, so relative paths resolve even when a +> session starts in a subdirectory. `.chock/bin/launch.sh` runs `git config chock.python` +> (written to the clone's local `.git/config` by `chock sync`, never committed) if it +> exists, else the first of `python3`/`python`/`py` that actually runs Python 3.11+ (the +> Windows Store `python3` stub is skipped). With none it exits 2 with a fix-it message -- +> never allow. Installed entries equal compiled ones exactly, so `chock sync` on any machine +> is a zero diff; entries in the old baked-interpreter form are recognised and replaced. **`git-hook` + `ci-gate` are the universal hard floor** every agent shares. `pre-tool-use` and `agent-hooks` are the premium tier on agents that expose native controls; membership @@ -84,12 +98,11 @@ per gateway process; wrap N servers with N entries. > hook returning exit 2 alone was **witnessed NOT blocking** on a real install > (2026-08-24). The vendored adapter therefore also emits Cursor's stdout > `{"permission": "deny"}` response, which is what actually blocks (witnessed). Cursor -> **fails open** on any other non-zero exit unless the hook entry sets `failClosed` — -> and `failClosed: true` would brick -> every shell command on a clone whose baked interpreter path does not resolve yet. Chock -> ships fail-open entries and mitigates the gap the same way as Claude's exit-127 case: -> install bakes an interpreter that provably runs. The guard covers shell commands -> (`beforeShellExecution`); other tool classes are not intercepted. +> **fails open** on any other non-zero exit unless the hook entry sets `failClosed`. Chock's +> `beforeShellExecution` and `preToolUse` entries set `failClosed: true` (a hook that cannot +> start blocks); `stop` entries do not. With no interpreter baked into the entry, a fresh +> clone is not bricked: the launcher finds one or refuses with a fix-it message. The guard +> covers shell commands (`beforeShellExecution`); other tool classes are not intercepted. > **`managed-setting` is compiled but not installed.** The compiler writes > `.chock/compiled//managed-setting/managed-settings.json` and nothing reads it — there is From b4b9e06e312e7904cee48eb85221be6e276e4a52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:51:32 -0400 Subject: [PATCH 14/25] Hoist chock's runtime imports after `import sys`, however agentseam orders its block render() anchored on agentseam's exact hoisted-import block. agentseam 0.3.4 hoists contextlib, io and os around json and sys, the anchor matched nothing, and every vendor runtime failed to render -- sync then wired no agent hooks at all while printing only a warning. The anchor is now the `import sys` line of the block after `from __future__`; with agentseam 0.3.3 the output is byte-identical (runtime goldens unchanged). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- src/chock/gate/runtime_bundle.py | 25 ++++++++++++++++++++++--- tests/test_runtime_goldens.py | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/chock/gate/runtime_bundle.py b/src/chock/gate/runtime_bundle.py index aead26c..4a7d26e 100644 --- a/src/chock/gate/runtime_bundle.py +++ b/src/chock/gate/runtime_bundle.py @@ -84,7 +84,25 @@ def _handler_source(agent: str) -> str: return "".join(parts) -_TOP_IMPORTS_ANCHOR = "from __future__ import annotations\n\nimport json\n_json = json\nimport sys\n" +_FUTURE = "from __future__ import annotations\n\n" + +#: chock's imports go right after this line of agentseam's hoisted block. Anchored on the one +#: line, not the whole block: agentseam 0.3.4 hoists contextlib/io/os around json and sys, and +#: an exact-block anchor then matched nothing -- every runtime failed to render and sync wired +#: no hooks at all. Where the block is unchanged the output is byte-identical to before. +_SYS_IMPORT = "import sys\n" + + +def _hoist_point(source: str) -> int: + """Offset just past `import sys` in the import block after `from __future__`, or -1.""" + start = source.find(_FUTURE) + if start < 0: + return -1 + block_start = start + len(_FUTURE) + block_end = source.find("\n\n", block_start) + block = source[block_start : block_end + 1 if block_end >= 0 else len(source)] + at = ("\n" + block).find("\n" + _SYS_IMPORT) + return -1 if at < 0 else block_start + at + len(_SYS_IMPORT) def _needed_imports(handler_source: str) -> str: @@ -104,10 +122,11 @@ def _needed_imports(handler_source: str) -> str: def render(agent: str) -> str: """Render `agent`'s self-contained vendored runtime: agentseam's bundle, chock's""" source = bundler.bundle(agent) - if _TOP_IMPORTS_ANCHOR not in source: + at = _hoist_point(source) + if at < 0: raise ValueError("%s: bundle() output has no top-imports anchor to hoist onto" % agent) handler = _handler_source(agent) - source = source.replace(_TOP_IMPORTS_ANCHOR, _TOP_IMPORTS_ANCHOR + "\n" + _needed_imports(handler), 1) + source = source[:at] + "\n" + _needed_imports(handler) + source[at:] head, sep, rest = source.partition(BEGIN) if not sep: raise ValueError("%s: bundle() output has no %r marker" % (agent, BEGIN)) diff --git a/tests/test_runtime_goldens.py b/tests/test_runtime_goldens.py index 8adb82a..ccf4dcb 100644 --- a/tests/test_runtime_goldens.py +++ b/tests/test_runtime_goldens.py @@ -36,3 +36,25 @@ def test_every_runtime_matches_its_frozen_fixture() -> None: f"runtime bytes moved for {differing}: every adopter's next sync rewrites .chock/bin. " "Intentional (pin bump, handler change)? Regenerate with CHOCK_REGEN_GOLDENS=1." ) + + +_OLD_HEADER = ( + "from __future__ import annotations\n\nimport json\n_json = json\nimport sys\nimport traceback\n\n# body\n" +) +_REORDERED = ( + "from __future__ import annotations\n\nimport contextlib\nimport io\nimport json\n_json = json\n" + "import os\nimport sys\nimport traceback\n\n# body\n" +) + + +@pytest.mark.parametrize("header", [_OLD_HEADER, _REORDERED], ids=["agentseam-0.3.3", "hoisted-around-sys"]) +def test_chock_imports_land_after_import_sys_however_agentseam_orders_its_block(header: str) -> None: + """agentseam 0.3.4 hoists contextlib/io/os; an exact-block anchor then matched nothing.""" + at = runtime_bundle._hoist_point(header) + assert header[:at].endswith("import sys\n") + assert "# body" not in header[:at] + + +def test_a_bundle_without_the_import_block_is_refused() -> None: + assert runtime_bundle._hoist_point("from __future__ import annotations\n\nimport json\n\nimport sys\n") == -1 + assert runtime_bundle._hoist_point("import sys\n") == -1 From 8ebfd9ecd9ea40dd84c11f592aac78bec93d851d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:52:43 -0400 Subject: [PATCH 15/25] Fail sync when an agent's hooks could not be wired A vendor whose config could not be merged, or whose runtime failed to render, was reported with one [WARN] line and sync still exited 0 -- the agent then ran with no gate at all. Sync now wires everything it can and then fails (HookWiringError, a BookkeepingError every caller already turns into exit 1), naming each vendor and why. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- src/chock/scaffold/recompile.py | 10 +++++++++ .../test_sync_wires_only_supported_agents.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/chock/scaffold/recompile.py b/src/chock/scaffold/recompile.py index 91202b3..5d2861c 100644 --- a/src/chock/scaffold/recompile.py +++ b/src/chock/scaffold/recompile.py @@ -37,6 +37,10 @@ class BookkeepingError(RuntimeError): """Bookkeeping the attestation chain depends on failed after a successful compile.""" +class HookWiringError(BookkeepingError): + """An agent's hooks could not be wired; everything else sync does has already been done.""" + + def _compile_all(repo_root: Path, agents: list[str], compiled_root: Path) -> dict[str, dict[str, dict[str, object]]]: """Compile every enabled policy into `compiled_root`. Returns the coverage map.""" config = load_config(repo_root) @@ -231,11 +235,13 @@ def _witness() -> tuple[set[str], ...]: return tuple(installed_policy_ids(repo_root, vendor) for vendor in wired) before = _witness() + unwired: list[str] = [] for vendor in wired: try: installed = install_hooks(repo_root, vendor) except ValueError as exc: warn(str(exc)) + unwired.append(f"{vendor}: {exc}") else: if installed: print(f"Registered {len(installed)} {install_label(vendor)}") @@ -246,4 +252,8 @@ def _witness() -> tuple[set[str], ...]: _refresh_bookkeeping(repo_root) + if not skip_hooks and unwired: + # A vendor left unwired runs no gate at all; a warning scrolled past is how that went unseen. + msg = "hooks were NOT wired for: " + "; ".join(unwired) + raise HookWiringError(msg) return coverage diff --git a/tests/test_sync_wires_only_supported_agents.py b/tests/test_sync_wires_only_supported_agents.py index 2a0d689..2adebc1 100644 --- a/tests/test_sync_wires_only_supported_agents.py +++ b/tests/test_sync_wires_only_supported_agents.py @@ -102,3 +102,25 @@ def test_a_repo_already_broken_by_0_9_1_self_heals_on_the_next_sync(tmp_path: Pa recompile(repo, ["claude"], skip_hooks=False) assert not cursor_hooks.exists() or ".chock/bin/cursor.py" not in cursor_hooks.read_text(encoding="utf-8") + + +def test_a_vendor_that_cannot_be_wired_fails_sync_after_wiring_the_rest(tmp_path: Path) -> None: + """A warning scrolled past was the only sign a vendor's gate was not installed at all.""" + import json + + import pytest + + from chock.scaffold.recompile import HookWiringError + + repo = _repo(tmp_path) + (repo / ".cursor").mkdir() + (repo / ".cursor" / "hooks.json").write_text("{ not json", encoding="utf-8") + + with pytest.raises(HookWiringError, match="cursor"): + recompile(repo, ["claude", "cursor"], skip_hooks=False) + + settings = json.loads((repo / ".claude" / "settings.json").read_text(encoding="utf-8")) + assert settings["hooks"]["PreToolUse"], "the vendor that could be wired still was" + assert (repo / ".cursor" / "hooks.json").read_text(encoding="utf-8") == "{ not json", ( + "a foreign file is never clobbered" + ) From 3482083161fe8f38d48cf43c5c407dc4d4903be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:53:56 -0400 Subject: [PATCH 16/25] Exempt only sync's generated tree from gates, not the adopter's own .chock files own_paths() skipped all of `.chock/`, which also hid config.yaml and the dependency allowlist -- files a person writes -- from every content gate. Only `.chock/compiled/` and `.chock/bin/` are generated; the rest is judged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/gate.py | 8 +++++--- src/chock/gate/runner.py | 8 +++++--- tests/test_gate_own_policy.py | 8 ++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index e151c00..8e130ab 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -472,7 +472,9 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> SCRIPT_BASE_GATE = "gate" #: chock's generated tree: compiled gates and vendored runtimes, never an author's content. -GENERATED_PREFIX = ".chock/" +#: What sync writes and a person never edits. Not all of `.chock/`: config.yaml and the +#: dependency allowlist are the adopter's own text and stay in scope for every gate. +GENERATED_PREFIXES = (".chock/compiled/", ".chock/bin/") POLICIES_PREFIX = ".agents/policies/" @@ -480,8 +482,8 @@ def own_paths(gate_path: Path) -> tuple[str, ...]: """Prefixes a gate never judges: the generated tree, and its own policy's shipped files.""" parents = gate_path.resolve().parents if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": - return (GENERATED_PREFIX,) - return (GENERATED_PREFIX, f"{POLICIES_PREFIX}{parents[1].name}/") + return GENERATED_PREFIXES + return (*GENERATED_PREFIXES, f"{POLICIES_PREFIX}{parents[1].name}/") def _params(gate_path: Path, spec: dict) -> dict: diff --git a/src/chock/gate/runner.py b/src/chock/gate/runner.py index e151c00..8e130ab 100644 --- a/src/chock/gate/runner.py +++ b/src/chock/gate/runner.py @@ -472,7 +472,9 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> SCRIPT_BASE_GATE = "gate" #: chock's generated tree: compiled gates and vendored runtimes, never an author's content. -GENERATED_PREFIX = ".chock/" +#: What sync writes and a person never edits. Not all of `.chock/`: config.yaml and the +#: dependency allowlist are the adopter's own text and stay in scope for every gate. +GENERATED_PREFIXES = (".chock/compiled/", ".chock/bin/") POLICIES_PREFIX = ".agents/policies/" @@ -480,8 +482,8 @@ def own_paths(gate_path: Path) -> tuple[str, ...]: """Prefixes a gate never judges: the generated tree, and its own policy's shipped files.""" parents = gate_path.resolve().parents if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": - return (GENERATED_PREFIX,) - return (GENERATED_PREFIX, f"{POLICIES_PREFIX}{parents[1].name}/") + return GENERATED_PREFIXES + return (*GENERATED_PREFIXES, f"{POLICIES_PREFIX}{parents[1].name}/") def _params(gate_path: Path, spec: dict) -> dict: diff --git a/tests/test_gate_own_policy.py b/tests/test_gate_own_policy.py index 6b029ff..14718aa 100644 --- a/tests/test_gate_own_policy.py +++ b/tests/test_gate_own_policy.py @@ -66,6 +66,14 @@ def test_another_policys_folder_and_ordinary_files_are_still_judged(tmp_path: Pa assert run(gate, "stop", None, tmp_path, writes={"App.java": MARKER}) == 1 +@pytest.mark.parametrize("path", [".chock/config.yaml", ".chock/dependency-allowlist.txt"]) +def test_the_adopters_own_chock_files_are_still_judged(tmp_path: Path, path: str) -> None: + """Only what sync generates is exempt; config the adopter writes by hand is ordinary text.""" + init_repo(tmp_path) + gate = _script_gate(tmp_path) + assert run(gate, "stop", None, tmp_path, writes={path: MARKER}) == 1 + + def test_a_content_gate_follows_the_same_rule(tmp_path: Path) -> None: """Declarative kinds had the same self-trigger; the catalog dodged it per policy with self-safe patterns.""" init_repo(tmp_path) From 43ae2bc95dd593ef89ded64567afecbf444dae93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:58:39 -0400 Subject: [PATCH 17/25] Install the write gate for every vendor that records a write vocabulary The write fragment for a generic vendor was a bare hooks-map entry named -write-hooks.json, but the generic installer merges whole config documents and only globbed -hooks.json -- so gemini_cli (write_file, replace) was compiled a write gate and never had it installed; only its turn-end check caught a bad write. The fragment is now agentseam's full pre-tool config document and the installer merges it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .../pre-tool-use/gemini_cli-write-hooks.json | 21 +++++++++------- .gemini/settings.json | 9 +++++++ chock.lock | 2 +- src/chock/compile/emitters/in_agent.py | 4 ++++ src/chock/hooks/in_agent_generic.py | 1 + .../pre-tool-use/gemini_cli-write-hooks.json | 21 +++++++++------- tests/test_generic_hooks_install.py | 24 +++++++++++++++++++ 7 files changed, 65 insertions(+), 17 deletions(-) diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json index 1b99e33..a816b37 100644 --- a/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json +++ b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json @@ -1,10 +1,15 @@ { - "matcher": "write_file|replace", - "hooks": [ - { - "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", - "timeout": 30 - } - ] + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"" + } + ], + "matcher": "write_file|replace" + } + ] + } } \ No newline at end of file diff --git a/.gemini/settings.json b/.gemini/settings.json index 17835c8..4116913 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -19,6 +19,15 @@ ], "matcher": "run_shell_command" }, + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"" + } + ], + "matcher": "write_file|replace" + }, { "hooks": [ { diff --git a/chock.lock b/chock.lock index 8908551..a2a3737 100644 --- a/chock.lock +++ b/chock.lock @@ -96,7 +96,7 @@ "managed": false, "sha256": "e73dbc039cdcbf137049a1f5035e4b595d12afe74a12a0f78e2e102d20d29479", "source": "local", - "artifacts_sha256": "2b7866b0674eaec540538b8d65091eadfa6ba8959fec734527b26ebffcc5d422" + "artifacts_sha256": "cd4e097ea6a53c077cec25e0fd7116d73c26c51edf9f54d9fb4a9eb17f849f32" }, { "id": "pre-generated-scripts", diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 5da22bd..3d4b6e6 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -140,6 +140,10 @@ def _gate_fragments(policy_id: str, spec: dict[str, Any], output_dir: Path) -> l # A flat entry carries no matcher: the runtime answers every tool under the event # and judges only a write it recognises; an unmatched tool is allowed unremarked. doc: dict[str, Any] = {vendors.pre_tool_event(vendor): [cursor_entry(command, fail_closed=True)]} + elif vendor in GENERIC_VENDORS: + # The generic installer merges whole config documents; a bare entry was never merged, + # so a vendor with a recorded write vocabulary (gemini_cli) got no write gate at all. + doc = vendors.pre_tool_hook_config(vendor, command, matcher=matcher) else: doc = hook_entry(command, matcher=matcher) write_generated_json(dest, doc) diff --git a/src/chock/hooks/in_agent_generic.py b/src/chock/hooks/in_agent_generic.py index ecc022e..557fc78 100644 --- a/src/chock/hooks/in_agent_generic.py +++ b/src/chock/hooks/in_agent_generic.py @@ -84,6 +84,7 @@ def _merge(settings: dict, fragment: dict) -> None: def _fragments(repo_root: Path, vendor: str) -> list[tuple[str, dict]]: compiled = repo_root / ".chock" / "compiled" globs = [f"*/{surface}/{vendor}-hooks.json" for surface in FRAGMENT_SURFACES] + globs.append(f"*/pre-tool-use/{vendor}-write-hooks.json") found: list[tuple[str, dict]] = [] for path in sorted(path for glob in globs for path in compiled.glob(glob)): try: diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json index 10723c7..036a393 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json @@ -1,10 +1,15 @@ { - "matcher": "write_file|replace", - "hooks": [ - { - "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", - "timeout": 30 - } - ] + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"" + } + ], + "matcher": "write_file|replace" + } + ] + } } \ No newline at end of file diff --git a/tests/test_generic_hooks_install.py b/tests/test_generic_hooks_install.py index 8e35db0..49f769c 100644 --- a/tests/test_generic_hooks_install.py +++ b/tests/test_generic_hooks_install.py @@ -151,3 +151,27 @@ def test_an_old_baked_entry_is_replaced_by_the_launcher_form(tmp_path: Path) -> install_hooks(repo, "devin") assert config_path.read_bytes() == current, "the old entry is ours: replaced, not kept beside the new one" + + +def test_a_generic_vendor_with_a_write_vocabulary_gets_its_write_gate_installed(tmp_path: Path) -> None: + """gemini_cli's write fragment was compiled as a bare entry and never merged: no write gate.""" + vendor = "gemini_cli" + assert vendors.write_matcher(vendor), "the premise: gemini_cli records write tools" + repo = tmp_path / "r" + repo.mkdir() + compile_policy( + baseline_policy("pin-github-actions"), + targets=[Surface.PRE_TOOL_USE.value], + output_root=repo / ".chock" / "compiled", + agents=["gemini"], + repo_root=repo, + ) + install_hooks(repo, vendor) + + entries = _config(repo, vendor)["hooks"][vendors.pre_tool_event(vendor)] + write = [e for e in entries if e.get("matcher") == vendors.write_matcher(vendor)] + assert write, "the write gate is wired under the vendor's own pre-tool event" + assert write[0]["hooks"][0]["command"] == hook_command( + f".chock/bin/{vendor}.py", "--gate", ".chock/compiled/pin-github-actions/pre-tool-use/gate.json" + ) + assert "pin-github-actions" in installed_policy_ids(repo, vendor) From 5f2fe4414ae91dbaa47b4b2826848e05dc8b006d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:01:31 -0400 Subject: [PATCH 18/25] Tell the user when a wired agent will skip the hooks until trusted Codex (and grok) run a project's hooks only after the user trusts them, and skip an untrusted hook silently -- wired looked exactly like enforced. Sync now prints an ACTION NEEDED line with agentseam's own trust steps for every wired vendor that records needs_trust. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- src/chock/scaffold/recompile.py | 3 +++ src/chock/vendors.py | 12 +++++++++++ .../test_sync_wires_only_supported_agents.py | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/chock/scaffold/recompile.py b/src/chock/scaffold/recompile.py index 5d2861c..03b9ca7 100644 --- a/src/chock/scaffold/recompile.py +++ b/src/chock/scaffold/recompile.py @@ -245,6 +245,9 @@ def _witness() -> tuple[set[str], ...]: else: if installed: print(f"Registered {len(installed)} {install_label(vendor)}") + if hint := vendors.trust_hint(vendor): + # Codex skips an untrusted project hook without a word: wired is not live. + print(f" ACTION NEEDED ({vendor}): hooks are not live until trusted -- {hint}") if _witness() != before: with tempfile.TemporaryDirectory(prefix="chock-coverage-", dir=chock_dir) as tmp2: coverage = _compile_all(repo_root, agents, Path(tmp2) / "compiled") diff --git a/src/chock/vendors.py b/src/chock/vendors.py index 874b30d..9d63a1d 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -89,6 +89,18 @@ def repo_root_token(vendor: str) -> str | None: return str(token) if token else None +#: Said when agentseam records that the agent needs trust but gives no vendor-specific steps. +_TRUST_FALLBACK = "this agent runs a project's hooks only after you trust them in the agent itself" + + +def trust_hint(vendor: str) -> str | None: + """How to make `vendor` run the repo's hooks, where it skips untrusted ones silently; else None.""" + cfg = entry(vendor) + if not cfg.get("needs_trust"): + return None + return str(cfg.get("trust_hint") or _TRUST_FALLBACK) + + def wire_event(vendor: str, canonical: str) -> str: """The vendor's wire spelling of one of agentseam's canonical events.""" return str(_adapters.get(vendor).REVERSE_EVENT_MAP[canonical]) diff --git a/tests/test_sync_wires_only_supported_agents.py b/tests/test_sync_wires_only_supported_agents.py index 2adebc1..bca3fcb 100644 --- a/tests/test_sync_wires_only_supported_agents.py +++ b/tests/test_sync_wires_only_supported_agents.py @@ -124,3 +124,24 @@ def test_a_vendor_that_cannot_be_wired_fails_sync_after_wiring_the_rest(tmp_path assert (repo / ".cursor" / "hooks.json").read_text(encoding="utf-8") == "{ not json", ( "a foreign file is never clobbered" ) + + +def test_sync_says_when_a_wired_agent_skips_untrusted_hooks(tmp_path: Path, capsys) -> None: + """Wired is not live for an agent that runs a project's hooks only once trusted.""" + from chock import vendors + + trusting = [v for v in WIRED_VENDORS if vendors.trust_hint(v)] + assert trusting, "agentseam records at least one wired agent that needs trust" + agents = [a for a, v in CHOCK_AGENT.items() if v in trusting] + recompile(_repo(tmp_path), agents, skip_hooks=False) + out = capsys.readouterr().out + for vendor in trusting: + assert f"ACTION NEEDED ({vendor})" in out + + +def test_no_trust_notice_for_an_agent_that_runs_hooks_untrusted(tmp_path: Path, capsys) -> None: + from chock import vendors + + assert vendors.trust_hint("claude_code") is None + recompile(_repo(tmp_path), ["claude"], skip_hooks=False) + assert "ACTION NEEDED" not in capsys.readouterr().out From 8a2cc1e2c154e678b3790f0f74204b31b1a09558 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:08:49 -0400 Subject: [PATCH 19/25] Record this branch's changes under Unreleased, not the shipped 0.11.4 Merging main folded the launcher entry into 0.11.4's section, which shipped without it. Every change on this branch is now listed under Unreleased. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- CHANGELOG.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 720d8b7..e8a8458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Chock changelog -## 0.11.4 — An edit is judged as the file it would leave, and bytecode no longer fails a pack +## Unreleased — 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 commands in committed agent configs named that machine's absolute Python (even a deleted @@ -15,6 +15,37 @@ `chock sync` elsewhere is a zero diff; entries in the old form are replaced at the next sync. `chock check` reports a missing or git-ignored launcher as a dangling hook target. +- **A gate judges an absolute path as the repository file it names.** Claude Code and Cursor send + `file_path` absolute; scoped gates matched repo-relative globs against it, so `pin-github-actions` + allowed `actions/checkout@v4` written by the agent. Paths are made repo-relative first (drive + letters, backslashes and case folded on Windows; a path outside the repo stays out of scope). +- **A guard that cannot run asks instead of allowing.** No usable bash, or a command `shlex` cannot + parse (`rm -rf / #'`), used to allow. Both now ask, naming what to install. Bash is found from + `git` (Git for Windows' `bin\bash.exe` first, never System32's WSL launcher or a WindowsApps + stub), found once per process, and runs with Git's `usr\bin` on PATH. +- **A hook naming a missing gate or guard refuses** and says to run `chock sync --repo .`, instead of + allowing silently. A re-entered Stop is still let through so a refusal cannot trap the turn. +- **A gate never judges its own policy's files or the generated tree.** java-security refused the + commit that adopted it (its own eval suite and setup page) and blocked every Stop until then. Each + gate now skips `.agents/policies//`, `.chock/compiled/` and `.chock/bin/`; `.chock/config.yaml` + and the dependency allowlist are still judged. +- **Git output is decoded as UTF-8 on every console**, so the Stop gate no longer skips non-ASCII + paths on Windows, and a match printed to a cp1252 console no longer crashes the gate. +- **The PowerShell pre-commit probe no longer blocks the commit** when a candidate interpreter is + missing or is the Store stub (PowerShell 5.1 made that a terminating error). +- **Sync fails when an agent's hooks could not be wired**, after wiring the rest, naming each agent + and why. It used to print one warning and exit 0 with that agent ungated. +- **Sync says when an agent needs you to trust its hooks.** Codex (and grok) skip an untrusted + project hook without a word; sync now prints an ACTION NEEDED line with the agent's own steps. +- **gemini_cli gets the write gate it was compiled.** Its fragment was never merged into + `.gemini/settings.json`; only the turn-end check caught a bad `write_file` or `replace`. +- **Runtime bundling tolerates a reordered agentseam import block.** An exact-block anchor stopped + matching agentseam 0.3.4's hoisted imports, and every runtime failed to render. +- **`chock check` is about 2.5x faster**: each YAML text is parsed once with the C loader, each bundled + module is split once, and bash is probed once per process. + +## 0.11.4 — An edit is judged as the file it would leave, and bytecode no longer fails a pack + - **An edit is judged before it lands, not only at the turn's end.** Claude Code changes an existing file with `Edit` or `MultiEdit`, whose payload carries only the text replaced and the text put in. The write gate judged that fragment alone, with no imports, class or neighbours From fbefa30c8955354de7c4e9d59cb059be2e06afd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:35:53 +0000 Subject: [PATCH 20/25] Run the launcher hook command under every shell an agent uses bash always; pwsh, Windows PowerShell and cmd.exe where present, so the Windows CI matrix runs the committed command form as each agent does: from a subdirectory, with its arguments and (bash, cmd) its exit 2 intact. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- tests/test_hook_launcher.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_hook_launcher.py b/tests/test_hook_launcher.py index aa051ba..f9fb867 100644 --- a/tests/test_hook_launcher.py +++ b/tests/test_hook_launcher.py @@ -159,6 +159,34 @@ def test_hook_command_reads_the_same_under_every_shell(args: tuple[str, ...]) -> assert char not in command, f"{char!r} is read differently by bash, PowerShell or cmd.exe" +def _shells() -> list[tuple[str, list[str], bool]]: + """(name, argv prefix, keeps exit 2) for every shell an agent may hand a hook command to.""" + found = [("bash", [bash_executable(), "-c"], True)] + for ps in ("pwsh", "powershell"): + if exe := shutil.which(ps): + found.append((ps, [exe, "-NoProfile", "-NonInteractive", "-Command"], False)) + if sys.platform == "win32" and (cmd := shutil.which("cmd")): + found.append(("cmd", [cmd, "/d", "/c"], True)) + return found + + +@pytest.mark.parametrize(("shell", "argv", "keeps_exit"), _shells(), ids=[s[0] for s in _shells()]) +def test_hook_command_runs_under_every_available_shell( + tmp_path: Path, shell: str, argv: list[str], *, keeps_exit: bool +) -> None: + repo = _repo(tmp_path) + (repo / _PROBE_REL).write_text(_PROBE + "sys.exit(2)\n", encoding="utf-8") + nested = repo / "a" + nested.mkdir() + command = hook_command(_PROBE_REL, "--gate", "p/stop/gate.json") + proc = subprocess.run([*argv, command], cwd=nested, capture_output=True, text=True, check=False) + out = json.loads(proc.stdout) + assert Path(out["cwd"]).resolve() == repo.resolve(), shell + assert out["args"] == ["--gate", "p/stop/gate.json"], shell + if keeps_exit: + assert proc.returncode == 2, f"{shell} lost the blocking exit code: {proc.stderr}" + + def _commands(node) -> list[str]: if isinstance(node, dict): return [v for k, v in node.items() if k in {"command", "bash", "powershell"} and isinstance(v, str)] + [ From f9e057e99b0ee98bdd27acb536c5bcc55c44792a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:41:26 +0000 Subject: [PATCH 21/25] Exempt a gate from its own policy's folders only, not the generated tree A gate skipped every path under .chock/bin/ and .chock/compiled/, so a secret or a Trojan-source edit committed there passed every content gate at commit and in CI. Keep the exemption the self-trigger needed (the policy's own source and compiled folders) and judge the rest. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/gate.py | 17 +++++++++-------- CHANGELOG.md | 4 ++-- src/chock/gate/runner.py | 17 +++++++++-------- tests/test_gate_own_policy.py | 19 +++++++++++++++++-- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 8e130ab..b78e196 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -45,7 +45,7 @@ def __init__( self.head_ref = head_ref #: The policy's applies_to.paths. Empty means every changed file is in scope. self.scope = tuple(scope or ()) - #: Path prefixes this gate never judges: chock's generated tree and its own policy folder. + #: Path prefixes this gate never judges: its own policy's source and compiled folders. self.own = tuple(own) def in_scope(self, path: str) -> bool: @@ -471,19 +471,20 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> #: `script_base` value naming the gate file's own directory as where `params.script` lives. SCRIPT_BASE_GATE = "gate" -#: chock's generated tree: compiled gates and vendored runtimes, never an author's content. -#: What sync writes and a person never edits. Not all of `.chock/`: config.yaml and the -#: dependency allowlist are the adopter's own text and stay in scope for every gate. -GENERATED_PREFIXES = (".chock/compiled/", ".chock/bin/") +#: A gate skips only its own policy's folders, so a policy's evals (which carry the very +#: content its gate refuses) never trip it. The rest of `.chock/`, including other policies' +#: compiled output and the vendored runtimes, stays in scope: a file planted there is judged. +COMPILED_PREFIX = ".chock/compiled/" POLICIES_PREFIX = ".agents/policies/" def own_paths(gate_path: Path) -> tuple[str, ...]: - """Prefixes a gate never judges: the generated tree, and its own policy's shipped files.""" + """Prefixes a gate never judges: its own policy's shipped and compiled files.""" parents = gate_path.resolve().parents if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": - return GENERATED_PREFIXES - return (*GENERATED_PREFIXES, f"{POLICIES_PREFIX}{parents[1].name}/") + return () + policy = parents[1].name + return (f"{COMPILED_PREFIX}{policy}/", f"{POLICIES_PREFIX}{policy}/") def _params(gate_path: Path, spec: dict) -> dict: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2411250..8542b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,8 @@ allowing silently. A re-entered Stop is still let through so a refusal cannot trap the turn. - **A gate never judges its own policy's files or the generated tree.** java-security refused the commit that adopted it (its own eval suite and setup page) and blocked every Stop until then. Each - gate now skips `.agents/policies//`, `.chock/compiled/` and `.chock/bin/`; `.chock/config.yaml` - and the dependency allowlist are still judged. + gate now skips `.agents/policies//` and `.chock/compiled//`, and nothing else: a file + planted anywhere else under `.chock/` is judged like any other. - **Git output is decoded as UTF-8 on every console**, so the Stop gate no longer skips non-ASCII paths on Windows, and a match printed to a cp1252 console no longer crashes the gate. - **The PowerShell pre-commit probe no longer blocks the commit** when a candidate interpreter is diff --git a/src/chock/gate/runner.py b/src/chock/gate/runner.py index 8e130ab..b78e196 100644 --- a/src/chock/gate/runner.py +++ b/src/chock/gate/runner.py @@ -45,7 +45,7 @@ def __init__( self.head_ref = head_ref #: The policy's applies_to.paths. Empty means every changed file is in scope. self.scope = tuple(scope or ()) - #: Path prefixes this gate never judges: chock's generated tree and its own policy folder. + #: Path prefixes this gate never judges: its own policy's source and compiled folders. self.own = tuple(own) def in_scope(self, path: str) -> bool: @@ -471,19 +471,20 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> #: `script_base` value naming the gate file's own directory as where `params.script` lives. SCRIPT_BASE_GATE = "gate" -#: chock's generated tree: compiled gates and vendored runtimes, never an author's content. -#: What sync writes and a person never edits. Not all of `.chock/`: config.yaml and the -#: dependency allowlist are the adopter's own text and stay in scope for every gate. -GENERATED_PREFIXES = (".chock/compiled/", ".chock/bin/") +#: A gate skips only its own policy's folders, so a policy's evals (which carry the very +#: content its gate refuses) never trip it. The rest of `.chock/`, including other policies' +#: compiled output and the vendored runtimes, stays in scope: a file planted there is judged. +COMPILED_PREFIX = ".chock/compiled/" POLICIES_PREFIX = ".agents/policies/" def own_paths(gate_path: Path) -> tuple[str, ...]: - """Prefixes a gate never judges: the generated tree, and its own policy's shipped files.""" + """Prefixes a gate never judges: its own policy's shipped and compiled files.""" parents = gate_path.resolve().parents if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": - return GENERATED_PREFIXES - return (*GENERATED_PREFIXES, f"{POLICIES_PREFIX}{parents[1].name}/") + return () + policy = parents[1].name + return (f"{COMPILED_PREFIX}{policy}/", f"{POLICIES_PREFIX}{policy}/") def _params(gate_path: Path, spec: dict) -> dict: diff --git a/tests/test_gate_own_policy.py b/tests/test_gate_own_policy.py index 14718aa..8853c2c 100644 --- a/tests/test_gate_own_policy.py +++ b/tests/test_gate_own_policy.py @@ -1,4 +1,4 @@ -"""A gate never judges its own policy's shipped files, nor chock's generated tree. +"""A gate never judges its own policy's shipped or compiled files, and nothing else. java-security refused its own adoption commit on evals/suite.yaml and skill/setup.html, and every Stop after it: a policy's evals carry the very content its gate refuses (#32). @@ -43,7 +43,7 @@ def _script_gate(repo: Path) -> Path: @pytest.mark.parametrize("event", ["pre-commit", "pre-tool-use", "stop"]) -def test_a_script_gate_does_not_refuse_its_own_evals_or_the_generated_tree(tmp_path: Path, event: str) -> None: +def test_a_script_gate_does_not_refuse_its_own_evals_or_compiled_output(tmp_path: Path, event: str) -> None: init_repo(tmp_path) gate = _script_gate(tmp_path) own = { @@ -66,6 +66,21 @@ def test_another_policys_folder_and_ordinary_files_are_still_judged(tmp_path: Pa assert run(gate, "stop", None, tmp_path, writes={"App.java": MARKER}) == 1 +@pytest.mark.parametrize( + "path", [".chock/bin/notes.txt", ".chock/bin/claude_code.py", ".chock/compiled/other/stop/leak.env"] +) +@pytest.mark.parametrize("event", ["pre-commit", "stop"]) +def test_a_file_planted_in_the_generated_tree_is_still_judged(tmp_path: Path, event: str, path: str) -> None: + """A secret committed under .chock/ must not ride past every content gate.""" + init_repo(tmp_path) + gate = _script_gate(tmp_path) + if event == "pre-commit": + stage(tmp_path, path, MARKER) + assert run(gate, event, None, tmp_path) == 1 + else: + assert run(gate, event, None, tmp_path, writes={path: MARKER}) == 1 + + @pytest.mark.parametrize("path", [".chock/config.yaml", ".chock/dependency-allowlist.txt"]) def test_the_adopters_own_chock_files_are_still_judged(tmp_path: Path, path: str) -> None: """Only what sync generates is exempt; config the adopter writes by hand is ordinary text.""" From edb436a3b9fe522db49465be6231ef0a062315af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:47:11 +0000 Subject: [PATCH 22/25] Judge a pre-tool write under its resolved path as well as the one named With `wf -> .github/workflows` in the repo, a write to wf/ci.yml was matched as wf/ci.yml and missed the .github/workflows/* scope, so the pre-tool gate allowed it. The write is now judged under both names when they differ; on Windows the resolved name also carries the real case. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/claude_code.py | 15 +++++++++++++-- .chock/bin/codex_cli.py | 15 +++++++++++++-- .chock/bin/cursor.py | 15 +++++++++++++-- .chock/bin/devin.py | 15 +++++++++++++-- .chock/bin/gemini_cli.py | 15 +++++++++++++-- .chock/bin/grok.py | 15 +++++++++++++-- .chock/bin/tabnine.py | 15 +++++++++++++-- .chock/bin/vscode_copilot.py | 15 +++++++++++++-- .chock/bin/windsurf.py | 15 +++++++++++++-- src/chock/gate/write_gate.py | 16 ++++++++++++++-- tests/fixtures/runtime_goldens/antigravity.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/claude_code.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/codex_cli.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/cursor.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/devin.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/gemini_cli.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/grok.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/tabnine.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/vscode_copilot.py | 15 +++++++++++++-- tests/fixtures/runtime_goldens/windsurf.py | 15 +++++++++++++-- tests/test_write_gate_handler.py | 11 +++++++++++ 21 files changed, 272 insertions(+), 40 deletions(-) diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 18f0e01..6f61546 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1192,6 +1192,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1298,11 +1309,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 4fea531..901a187 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1179,6 +1179,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1285,11 +1296,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 86867cb..0f92770 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1107,6 +1107,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1213,11 +1224,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index f2c3fca..588369c 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1161,6 +1161,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1267,11 +1278,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index bd64575..ad40aa4 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1161,6 +1161,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1267,11 +1278,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index d07f8d0..30f2d89 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1144,6 +1144,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1250,11 +1261,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 5bb6f8d..5042525 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1144,6 +1144,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1250,11 +1261,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 5f35eb1..51f6f53 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -968,6 +968,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1074,11 +1085,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index bd3fa66..aa68437 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -1058,6 +1058,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1164,11 +1175,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index 295c90a..4b79eff 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -126,6 +126,18 @@ def repo_relative(path, root): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (os.name == "nt"): + return (lexical,) + try: + resolved = Path(root, str(path)).resolve().relative_to(Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -267,11 +279,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 13e739f..58773b0 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -1127,6 +1127,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1233,11 +1244,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 18f0e01..6f61546 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -1192,6 +1192,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1298,11 +1309,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 4fea531..901a187 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -1179,6 +1179,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1285,11 +1296,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 86867cb..0f92770 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -1107,6 +1107,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1213,11 +1224,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index f2c3fca..588369c 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -1161,6 +1161,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1267,11 +1278,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index bd64575..ad40aa4 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -1161,6 +1161,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1267,11 +1278,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index d07f8d0..30f2d89 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -1144,6 +1144,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1250,11 +1261,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 5bb6f8d..5042525 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -1144,6 +1144,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1250,11 +1261,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 5f35eb1..51f6f53 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -968,6 +968,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1074,11 +1085,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index bd3fa66..aa68437 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -1058,6 +1058,17 @@ def repo_relative(path, root): except (OSError, ValueError, RuntimeError): return text +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + def changed_paths(repo_root): """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" try: @@ -1164,11 +1175,11 @@ def evaluate_gate(argv, event): if not gate.exists(): return _missing_gate(gate, event) root = repo_root_for(event, gate) - writes = {repo_relative(path, root): text for path, text in writes_for(event, gate).items()} + writes = {rel: text for path, text in writes_for(event, gate).items() for rel in repo_paths(path, root)} if not writes: return None added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} - added = {repo_relative(path, root): text for path, text in added.items()} + added = {rel: text for path, text in added.items() for rel in repo_paths(path, root)} added = {path: text for path, text in added.items() if path in writes} outcome, message = run_gate(gate, writes, name, root, added) if outcome == GATE_BLOCKED: diff --git a/tests/test_write_gate_handler.py b/tests/test_write_gate_handler.py index b75a4b2..a857029 100644 --- a/tests/test_write_gate_handler.py +++ b/tests/test_write_gate_handler.py @@ -185,6 +185,17 @@ def test_an_absolute_path_reached_through_a_symlink_is_still_inside(tmp_path: Pa assert verdict is not None and verdict[0] == write_gate.VERDICT_DENY +def test_a_write_through_a_symlinked_folder_inside_the_repo_is_judged_as_its_target(tmp_path: Path) -> None: + """`wf -> .github/workflows`: the lexical `wf/ci.yml` misses the scope glob; the target does not.""" + gate = _installed_pin(tmp_path) + (tmp_path / ".github" / "workflows").mkdir(parents=True) + (tmp_path / "wf").symlink_to(tmp_path / ".github" / "workflows", target_is_directory=True) + for path in ("wf/ci.yml", str(tmp_path / "wf" / "ci.yml")): + verdict = write_gate.evaluate_gate(["--gate", str(gate)], _event(path=path, content=UNPINNED)) + assert verdict is not None and verdict[0] == write_gate.VERDICT_DENY, path + assert write_gate.repo_paths("wf/ci.yml", str(tmp_path)) == ("wf/ci.yml", ".github/workflows/ci.yml") + + def test_paths_are_made_repository_relative_posix() -> None: assert write_gate.repo_relative("/r/.github/workflows/ci.yml", "/r") == ".github/workflows/ci.yml" assert write_gate.repo_relative("./a/../.github/x.yml", "/r") == ".github/x.yml" From bddd915b77945c68345d4c021272f49af9f6ec67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:47:23 +0000 Subject: [PATCH 23/25] Send the Cursor plugin test's payload as UTF-8 on Windows The payload opens with a BOM, which the default cp1252 stdin encoding cannot write, so the test errored on every Windows run before the hook was reached. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- tests/test_plugin_gate_stores.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_plugin_gate_stores.py b/tests/test_plugin_gate_stores.py index ab5fd3e..735028b 100644 --- a/tests/test_plugin_gate_stores.py +++ b/tests/test_plugin_gate_stores.py @@ -106,6 +106,7 @@ def _run_cursor_hook(out: Path, repo: Path, payload: str) -> subprocess.Complete env={**os.environ, "CURSOR_PLUGIN_ROOT": str(out)}, capture_output=True, text=True, + encoding="utf-8", input=payload, check=False, ) From 8970f940d4ca55cf18c23db1c749d2edc3089d1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:47:24 +0000 Subject: [PATCH 24/25] Make the launcher refuse whenever it cannot start a verdict - Probe the recorded chock.python like every PATH candidate: a venv whose base Python was removed still exists, and exec'ing it exited non-zero with no verdict, which Claude Code and Codex let through. - Refuse (exit 2, naming chock sync) when git's top level holds no launcher: a nested repository, no repository, or an unsynced clone. bash-as-sh exited 127 there, again non-blocking. - Record chock.python only when --repo is a repository's top level, so sync never writes an enclosing repository's config. - Run the cmd.exe case of the every-shell test with the raw command line an agent passes, not Python's backslash-quoted argv. Regenerate chock's own hook wiring and the emitter goldens. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/launch.sh | 11 +++-- .../agent-hooks/agent-hooks.json | 8 ++-- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +- .../pre-tool-use/cursor-hooks.json | 2 +- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +- .../agent-hooks/agent-hooks.json | 8 ++-- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +- .../pre-tool-use/cursor-hooks.json | 2 +- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +- .../pre-tool-use/cursor-write-hooks.json | 2 +- .../pre-tool-use/gemini_cli-write-hooks.json | 2 +- .../pre-tool-use/pretooluse-write.json | 2 +- .../stop/antigravity-hooks.json | 2 +- .../stop/codex_cli-hooks.json | 4 +- .../pin-github-actions/stop/cursor-hooks.json | 2 +- .../pin-github-actions/stop/devin-hooks.json | 2 +- .../stop/gemini_cli-hooks.json | 2 +- .../pin-github-actions/stop/stop.json | 2 +- .../stop/tabnine-hooks.json | 2 +- .../agent-hooks/agent-hooks.json | 8 ++-- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +- .../pre-tool-use/cursor-hooks.json | 2 +- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +- .../agent-hooks/agent-hooks.json | 8 ++-- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +- .../pre-tool-use/cursor-hooks.json | 2 +- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +- .claude/settings.json | 14 +++---- .codex/hooks.json | 20 ++++----- .cursor/hooks.json | 12 +++--- .devin/hooks.v1.json | 10 ++--- .gemini/settings.json | 12 +++--- .github/hooks/chock.json | 32 +++++++-------- .grok/hooks/agentseam.json | 8 ++-- .tabnine/agent/settings.json | 10 ++--- .windsurf/hooks.json | 16 ++++---- CHANGELOG.md | 10 +++-- acceptance/test_features.py | 4 +- chock.lock | 10 ++--- docs/enforcement-surfaces.md | 13 +++--- src/chock/hooks/data/launch.sh | 11 +++-- src/chock/hooks/launch.py | 30 ++++++++++---- .../agent-hooks/agent-hooks.json | 8 ++-- .../pre-tool-use/antigravity-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 4 +- .../pre-tool-use/cursor-hooks.json | 2 +- .../pre-tool-use/devin-hooks.json | 2 +- .../pre-tool-use/gemini_cli-hooks.json | 2 +- .../pre-tool-use/grok-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/tabnine-hooks.json | 2 +- .../pre-tool-use/windsurf-hooks.json | 4 +- .../pre-tool-use/cursor-write-hooks.json | 2 +- .../pre-tool-use/gemini_cli-write-hooks.json | 2 +- .../pre-tool-use/pretooluse-write.json | 2 +- .../stop/antigravity-hooks.json | 2 +- .../stop/codex_cli-hooks.json | 4 +- .../stability-script/stop/cursor-hooks.json | 2 +- .../stability-script/stop/devin-hooks.json | 2 +- .../stop/gemini_cli-hooks.json | 2 +- .../golden/stability-script/stop/stop.json | 2 +- .../stability-script/stop/tabnine-hooks.json | 2 +- tests/test_hook_launcher.py | 41 ++++++++++++++++++- tests/test_pretooluse.py | 4 +- 88 files changed, 260 insertions(+), 202 deletions(-) diff --git a/.chock/bin/launch.sh b/.chock/bin/launch.sh index f2e033a..5db2694 100755 --- a/.chock/bin/launch.sh +++ b/.chock/bin/launch.sh @@ -1,6 +1,6 @@ #!/bin/sh # chock hook launcher. Every agent hook chock writes runs: -# git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook [args...] +# git -c "alias.chock-hook=!test -f || { ...; exit 2; }; sh " chock-hook [args...] # git runs the alias from the repository's top level under its own sh (bash, PowerShell # and cmd.exe all pass that string through unchanged), so relative paths resolve and no # absolute interpreter path is ever committed. This script picks the first Python that @@ -10,11 +10,10 @@ runtime="$1" shift configured="$(git config --get chock.python 2>/dev/null)" -if [ -n "$configured" ] && [ -f "$configured" ]; then - exec "$configured" -X utf8 "$runtime" "$@" -fi -for candidate in python3 python py; do - if "$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 11,))' /dev/null 2>&1; then +# The recorded interpreter is probed like the rest: a venv whose base Python was removed +# still exists, and exec'ing it exits non-zero without a verdict, which agents let through. +for candidate in "$configured" python3 python py; do + if [ -n "$candidate" ] && "$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 11,))' /dev/null 2>&1; then exec "$candidate" -X utf8 "$runtime" "$@" fi done diff --git a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json index 0faa317..728b6cb 100644 --- a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json index 9a98034..5f2c1ed 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json index 4e37e74..3acb065 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json index 9f61148..a0c359f 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/cursor-hooks.json @@ -1,7 +1,7 @@ { "beforeShellExecution": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "timeout": 30, "failClosed": true } diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json index 0b2de74..1775d5b 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json index 288ef7b..0a54257 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json index fba31f4..ead119b 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json b/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json index 7ca0611..61bdbef 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json index d9f1626..6cab166 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json index db62906..a6a6649 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json index bc45cef..b448955 100644 --- a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json index 7acaa28..0948ee9 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json index 6760f15..4550569 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json index 05fd1fd..f11f178 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json @@ -1,7 +1,7 @@ { "beforeShellExecution": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "timeout": 30, "failClosed": true } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json index c5ad0fa..ca1bfea 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json index 8a1d798..dded834 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json index d643f31..fb9d9e5 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json index 5eb9e59..cc4e582 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json index 705f920..2e46676 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json index 6f1ce90..39b7e64 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json index c57408b..2e099c6 100644 --- a/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json +++ b/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json @@ -1,7 +1,7 @@ { "preToolUse": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30, "failClosed": true } diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json index a816b37..491b388 100644 --- a/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json +++ b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"" } ], "matcher": "write_file|replace" diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json b/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json index cf60de4..4cc4d44 100644 --- a/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json +++ b/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30 } ] diff --git a/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json b/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json index 6d9dd32..add8c67 100644 --- a/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json index 5d533fe..6f39c1e 100644 --- a/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/cursor-hooks.json b/.chock/compiled/pin-github-actions/stop/cursor-hooks.json index a5d96c8..3b26ac2 100644 --- a/.chock/compiled/pin-github-actions/stop/cursor-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/cursor-hooks.json @@ -1,7 +1,7 @@ { "stop": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.chock/compiled/pin-github-actions/stop/devin-hooks.json b/.chock/compiled/pin-github-actions/stop/devin-hooks.json index 4c6828d..4151099 100644 --- a/.chock/compiled/pin-github-actions/stop/devin-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json b/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json index c8d8f1b..c30a487 100644 --- a/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.chock/compiled/pin-github-actions/stop/stop.json b/.chock/compiled/pin-github-actions/stop/stop.json index 3364572..e078af8 100644 --- a/.chock/compiled/pin-github-actions/stop/stop.json +++ b/.chock/compiled/pin-github-actions/stop/stop.json @@ -2,7 +2,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json b/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json index b45c5c2..455ea57 100644 --- a/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "name": "agentseam" } ] diff --git a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json index 82ce044..ef10568 100644 --- a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json index de3e641..10b8243 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json index f75d4c3..ec92171 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json index 82468d1..6c6c443 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/cursor-hooks.json @@ -1,7 +1,7 @@ { "beforeShellExecution": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "timeout": 30, "failClosed": true } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json index 0b89d17..b2391c2 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json index e07ec83..bfddb31 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json index 2377427..6dac236 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json b/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json index 7b04d6a..752b21f 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json index 9fb76d7..0e442e5 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json index 0dde434..d2d2aa6 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json index 4fb4242..794a11e 100644 --- a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json index 4be3fff..9c47852 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json index f231dd9..bbd5488 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "Bash" diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json index d1b74da..53e68c8 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/cursor-hooks.json @@ -1,7 +1,7 @@ { "beforeShellExecution": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "timeout": 30, "failClosed": true } diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json index f9e0a0a..75156eb 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json index 9e40d1e..1b299d5 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json index b65d808..a55acd4 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json index d87a7e6..ccbaba8 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "timeout": 30 } ] diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json index 03d4547..2d7abb8 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "name": "agentseam" } ] diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json index c0f91cf..e4d2131 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.claude/settings.json b/.claude/settings.json index 87d403c..cb0a148 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "timeout": 30 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "timeout": 30 } ] @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30 } ] @@ -36,7 +36,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "timeout": 30 } ] @@ -46,7 +46,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "timeout": 30 } ] @@ -57,7 +57,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py", "timeout": 300 } ] @@ -68,7 +68,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.codex/hooks.json b/.codex/hooks.json index 39117d0..ef496ae 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "Bash" @@ -15,8 +15,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "Bash" @@ -25,8 +25,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "Bash" @@ -35,8 +35,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "Bash" @@ -47,8 +47,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 27b6d24..8d430fc 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -2,36 +2,36 @@ "hooks": { "beforeShellExecution": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "timeout": 30, "failClosed": true }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "timeout": 30, "failClosed": true }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "timeout": 30, "failClosed": true }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "timeout": 30, "failClosed": true } ], "preToolUse": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30, "failClosed": true } ], "stop": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.devin/hooks.v1.json b/.devin/hooks.v1.json index 1a2db43..c2ed67b 100644 --- a/.devin/hooks.v1.json +++ b/.devin/hooks.v1.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] }, @@ -12,7 +12,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] }, @@ -20,7 +20,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] }, @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.gemini/settings.json b/.gemini/settings.json index 4116913..56a523a 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ], "matcher": "run_shell_command" @@ -14,7 +14,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "matcher": "run_shell_command" @@ -23,7 +23,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"" } ], "matcher": "write_file|replace" @@ -32,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ], "matcher": "run_shell_command" @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "matcher": "run_shell_command" @@ -52,7 +52,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.github/hooks/chock.json b/.github/hooks/chock.json index ee93deb..0530805 100644 --- a/.github/hooks/chock.json +++ b/.github/hooks/chock.json @@ -7,40 +7,40 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" }, { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" }, { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" }, { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.grok/hooks/agentseam.json b/.grok/hooks/agentseam.json index d38beea..e42ca2e 100644 --- a/.grok/hooks/agentseam.json +++ b/.grok/hooks/agentseam.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } ] }, @@ -13,7 +13,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] }, @@ -21,7 +21,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } ] }, @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/.tabnine/agent/settings.json b/.tabnine/agent/settings.json index 2a1683f..915d027 100644 --- a/.tabnine/agent/settings.json +++ b/.tabnine/agent/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "name": "agentseam" } ] @@ -14,7 +14,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "name": "agentseam" } ] @@ -23,7 +23,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "name": "agentseam" } ] @@ -32,7 +32,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "name": "agentseam" } ] @@ -43,7 +43,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "name": "agentseam" } ] diff --git a/.windsurf/hooks.json b/.windsurf/hooks.json index 0e20d67..c6ad107 100644 --- a/.windsurf/hooks.json +++ b/.windsurf/hooks.json @@ -2,30 +2,30 @@ "hooks": { "pre_run_command": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" }, { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8542b81..9d5599a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,19 @@ read identically by bash, PowerShell and cmd.exe; git runs it from the repository root, so a session started in a subdirectory is guarded too. The committed `.chock/bin/launch.sh` runs `git config chock.python` (written to the clone's local config by `chock sync`) or the first - `python3`/`python`/`py` that actually runs Python 3.11+, and with none refuses (exit 2) with a - fix-it message. Cursor's shell and pre-tool entries now set `failClosed`. Re-running + `python3`/`python`/`py` that actually runs Python 3.11+ (the recorded one is probed too: a venv + whose base Python is gone still exists), and with none refuses (exit 2) with a fix-it message. + With no launcher at git's top level (a nested repository, an unsynced clone) the command + refuses the same way; bash-as-`sh` used to exit 127 there, which agents let through. Sync + records its interpreter only when `--repo` is a repository's top level. Cursor's shell and pre-tool entries now set `failClosed`. Re-running `chock sync` elsewhere is a zero diff; entries in the old form are replaced at the next sync. `chock check` reports a missing or git-ignored launcher as a dangling hook target. - **A gate judges an absolute path as the repository file it names.** Claude Code and Cursor send `file_path` absolute; scoped gates matched repo-relative globs against it, so `pin-github-actions` allowed `actions/checkout@v4` written by the agent. Paths are made repo-relative first (drive - letters, backslashes and case folded on Windows; a path outside the repo stays out of scope). + letters, backslashes and case folded on Windows; a path outside the repo stays out of scope). A + write through a symlinked folder is judged under its target's path as well as the one named. - **A guard that cannot run asks instead of allowing.** No usable bash, or a command `shlex` cannot parse (`rm -rf / #'`), used to allow. Both now ask, naming what to install. Bash is found from `git` (Git for Windows' `bin\bash.exe` first, never System32's WSL launcher or a WindowsApps diff --git a/acceptance/test_features.py b/acceptance/test_features.py index cd67ca2..d468c53 100644 --- a/acceptance/test_features.py +++ b/acceptance/test_features.py @@ -160,7 +160,9 @@ def _hook_schema(repo: Adopter) -> None: for hook in entry["hooks"]: assert hook["type"] == "command" # Repo-relative, run by git from the top level: survives a repo move, names no interpreter. - assert hook["command"].startswith('git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook ') + assert hook["command"].startswith( + 'git -c "alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh" chock-hook ' + ) assert "${" not in hook["command"], hook["command"] diff --git a/chock.lock b/chock.lock index a2a3737..df9720b 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "f621c131b3c183d70b729be8360212ff3b896af35d8f8c26899dc961d47e7a35" + "artifacts_sha256": "819eea7a56cb1be3ab58229132509a98be3c4ca54c9de0b258b61be48dd47543" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "2382a8a41fc1d0920311ae9cf8b5498c54ef3931b25f330db1f6244a73800f22" + "artifacts_sha256": "2916a7f196917545c7cdc36b49aa8bf10b91622d17790158124417696ba9b9db" }, { "id": "block-wildcard-agent-permissions", @@ -96,7 +96,7 @@ "managed": false, "sha256": "e73dbc039cdcbf137049a1f5035e4b595d12afe74a12a0f78e2e102d20d29479", "source": "local", - "artifacts_sha256": "cd4e097ea6a53c077cec25e0fd7116d73c26c51edf9f54d9fb4a9eb17f849f32" + "artifacts_sha256": "ee361e81033a87fa3d3c4b514108fb2dc850239d5e7563eddc80be8fff097081" }, { "id": "pre-generated-scripts", @@ -112,7 +112,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "f9087daed9092fb0ab4f41b3f478a1d79e9e004d892f90b2cd18793f008aba5d" + "artifacts_sha256": "31cde9edcf9065e64d2a0f172fb706e6221a25e5804480fd5e608335f5680ca3" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "59517e11f8226a731f7371a48682aa6f3d0741ce6c9497f74e29beb0a85a1223" + "artifacts_sha256": "24609dd393f340ebaaaa68be652432f536e778a7069656032d2a00e10f7c3739" }, { "id": "protect-main-branch", diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 3dd2dd0..5e77eaf 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -49,15 +49,16 @@ each guarantee holds. > every vendor, is the same string -- read identically by bash, PowerShell and cmd.exe: > > ``` -> git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook .chock/bin/.py [--guard|--gate ""] +> git -c "alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh" chock-hook .chock/bin/.py [--guard|--gate ""] > ``` > > git runs the alias from the repository's top level, so relative paths resolve even when a -> session starts in a subdirectory. `.chock/bin/launch.sh` runs `git config chock.python` -> (written to the clone's local `.git/config` by `chock sync`, never committed) if it -> exists, else the first of `python3`/`python`/`py` that actually runs Python 3.11+ (the -> Windows Store `python3` stub is skipped). With none it exits 2 with a fix-it message -- -> never allow. Installed entries equal compiled ones exactly, so `chock sync` on any machine +> session starts in a subdirectory. `.chock/bin/launch.sh` runs the first of +> `git config chock.python` (written to the clone's local `.git/config` by `chock sync`, +> never committed), `python3`, `python` and `py` that actually runs Python 3.11+ (the +> Windows Store `python3` stub, or a venv whose base Python is gone, is skipped). With none +> it exits 2 with a fix-it message -- never allow. With no launcher at git's top level (a +> nested repository, or a clone never synced) the command exits 2 the same way. Installed entries equal compiled ones exactly, so `chock sync` on any machine > is a zero diff; entries in the old baked-interpreter form are recognised and replaced. **`git-hook` + `ci-gate` are the universal hard floor** every agent shares. `pre-tool-use` and diff --git a/src/chock/hooks/data/launch.sh b/src/chock/hooks/data/launch.sh index 6f72207..15422c1 100644 --- a/src/chock/hooks/data/launch.sh +++ b/src/chock/hooks/data/launch.sh @@ -1,6 +1,6 @@ #!/bin/sh # chock hook launcher. Every agent hook chock writes runs: -# git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook [args...] +# git -c "alias.chock-hook=!test -f || { ...; exit 2; }; sh " chock-hook [args...] # git runs the alias from the repository's top level under its own sh (bash, PowerShell # and cmd.exe all pass that string through unchanged), so relative paths resolve and no # absolute interpreter path is ever committed. This script picks the first Python that @@ -10,11 +10,10 @@ runtime="$1" shift configured="$(git config --get chock.python 2>/dev/null)" -if [ -n "$configured" ] && [ -f "$configured" ]; then - exec "$configured" -X utf8 "$runtime" "$@" -fi -for candidate in python3 python py; do - if "$candidate" -c 'import sys; sys.exit(sys.version_info < (__MIN_PYTHON__,))' /dev/null 2>&1; then +# The recorded interpreter is probed like the rest: a venv whose base Python was removed +# still exists, and exec'ing it exits non-zero without a verdict, which agents let through. +for candidate in "$configured" python3 python py; do + if [ -n "$candidate" ] && "$candidate" -c 'import sys; sys.exit(sys.version_info < (__MIN_PYTHON__,))' /dev/null 2>&1; then exec "$candidate" -X utf8 "$runtime" "$@" fi done diff --git a/src/chock/hooks/launch.py b/src/chock/hooks/launch.py index d1a1f2c..9de2504 100644 --- a/src/chock/hooks/launch.py +++ b/src/chock/hooks/launch.py @@ -21,8 +21,12 @@ ALIAS = "chock-hook" +#: No launcher at git's top level (a nested repo, no repo, not synced) refuses with exit 2: +#: bash-as-sh exits 127 on a missing script, which agents treat as non-blocking. +_MISSING = f"test -f {LAUNCHER_REL} || {{ echo chock: no {LAUNCHER_REL} here, run chock sync --repo . >&2; exit 2; }}" + #: No `$`, no backslash, no single quote: bash, PowerShell and cmd.exe read it identically. -_PREFIX = f'git -c "alias.{ALIAS}=!sh {LAUNCHER_REL}" {ALIAS}' +_PREFIX = f'git -c "alias.{ALIAS}=!{_MISSING}; sh {LAUNCHER_REL}" {ALIAS}' _TEMPLATE = package_data_dir("chock", "hooks", "data").joinpath("launch.sh").read_text(encoding="utf-8") @@ -50,15 +54,25 @@ def write_launcher(repo_root: Path) -> Path: return dest +def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( # noqa: S603 -- fixed argv: git reading or setting this clone's own config + ["git", *args], # noqa: S607 -- git on PATH is the repo route's premise + cwd=repo_root, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + def record_interpreter(repo_root: Path) -> bool: """Name this interpreter in the clone's own .git/config; never committed. True when set.""" with contextlib.suppress(OSError, subprocess.SubprocessError): - proc = subprocess.run( # noqa: S603 -- fixed argv: git config with this process's own interpreter path - ["git", "config", "--local", PYTHON_CONFIG_KEY, PurePath(sys.executable).as_posix()], # noqa: S607 -- git on PATH is the repo route's premise - cwd=repo_root, - capture_output=True, - check=False, - timeout=30, + # Only when repo_root is the top level: inside another repo it is that repo's config. + top = _git(repo_root, "rev-parse", "--show-toplevel").stdout.strip() + if not top or Path(top).resolve() != Path(repo_root).resolve(): + return False + return ( + _git(repo_root, "config", "--local", PYTHON_CONFIG_KEY, PurePath(sys.executable).as_posix()).returncode == 0 ) - return proc.returncode == 0 return False diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json index 3f6da5a..5ea8977 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json @@ -3,8 +3,8 @@ "matcher": "bash|powershell|pwsh|sh|shell", "timeout": 30, "timeoutSec": 30, - "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "powershell": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "windows": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json index 4b836d4..ab8da04 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json index 4e5969e..d64b3da 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ], "matcher": "Bash" diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json index a12c657..907b8b4 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/cursor-hooks.json @@ -1,7 +1,7 @@ { "beforeShellExecution": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "timeout": 30, "failClosed": true } diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json index 292bda4..ec3c56c 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json index 6581bbd..368fb64 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ], "matcher": "run_shell_command" diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json index fbe82ab..bca48eb 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json index 471317c..c9d48b1 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json index b1dbba2..2b1c725 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "name": "agentseam" } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json index 83a20ba..23f7a87 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json index 6f0a822..db8117f 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json @@ -1,7 +1,7 @@ { "preToolUse": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", "timeout": 30, "failClosed": true } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json index 036a393..f791619 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/gemini_cli-write-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"" } ], "matcher": "write_file|replace" diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json index dad3b65..c799920 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/pretooluse-write.json @@ -3,7 +3,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json index 1f6e5f6..511c178 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/antigravity-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json index 0514481..1599a6e 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json index da93fb3..99d37b0 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json @@ -1,7 +1,7 @@ { "stop": [ { - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --gate \".chock/compiled/stability-script/stop/gate.json\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json index 2672271..ea7b372 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/devin-hooks.json @@ -4,7 +4,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json index e868a7e..2889223 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/gemini_cli-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" } ] } diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json index 5ba9942..78b0594 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/stop.json @@ -2,7 +2,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --gate \".chock/compiled/stability-script/stop/gate.json\"", "timeout": 30 } ] diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json index 3efe650..438f485 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/tabnine-hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --gate \".chock/compiled/stability-script/stop/gate.json\"", "name": "agentseam" } ] diff --git a/tests/test_hook_launcher.py b/tests/test_hook_launcher.py index f9fb867..026664a 100644 --- a/tests/test_hook_launcher.py +++ b/tests/test_hook_launcher.py @@ -113,6 +113,29 @@ def test_a_stale_recorded_interpreter_falls_back_to_path(tmp_path: Path) -> None assert out["exe"] == str(bin_dir / "python3") +@posix_only +def test_a_recorded_interpreter_that_exists_but_does_not_run_falls_back_to_path(tmp_path: Path) -> None: + """A venv whose base Python was removed still exists; exec'ing it would exit without a verdict.""" + repo = _repo(tmp_path) + broken = tmp_path / "venv-python" + broken.write_text("#!/bin/sh\nexit 103\n", encoding="utf-8") + broken.chmod(0o755) + bin_dir = _bin_dir(tmp_path, "bin", python3=sys.executable) + subprocess.run(["git", "config", "--local", PYTHON_CONFIG_KEY, str(broken)], cwd=repo, check=True) + out = _probe(_run(repo, repo, path=str(bin_dir))) + assert out["exe"] == str(bin_dir / "python3") + + +@pytest.mark.parametrize("where", ["unsynced", "outside"]) +def test_no_launcher_at_the_top_level_refuses(tmp_path: Path, where: str) -> None: + """bash-as-sh exits 127 on a missing script, which agents let through; the command says 2.""" + cwd = _git_repo(tmp_path) if where == "unsynced" else tmp_path + command = hook_command(_PROBE_REL) + proc = subprocess.run([bash_executable(), "-c", command], cwd=cwd, capture_output=True, text=True, check=False) + assert proc.returncode == 2, proc.stderr + assert "chock sync --repo ." in proc.stderr + + @posix_only def test_no_python_anywhere_refuses_with_an_actionable_message(tmp_path: Path) -> None: repo = _repo(tmp_path) @@ -148,13 +171,25 @@ def test_record_interpreter_writes_local_config_only(tmp_path: Path, monkeypatch assert status.stdout == "", "nothing committable is written" +def test_record_interpreter_leaves_an_enclosing_repo_alone(tmp_path: Path) -> None: + """`--repo` below another repo's top level (a dotfiles home, say) is not that repo's clone.""" + outer = _git_repo(tmp_path) + inner = outer / "project" + inner.mkdir() + assert record_interpreter(inner) is False + got = subprocess.run(["git", "config", "--local", "--get", PYTHON_CONFIG_KEY], cwd=outer, check=False) + assert got.returncode == 1, "the enclosing repo's config was written" + + @pytest.mark.parametrize( "args", [(), ("--guard", ".agents/policies/p/implementations/g.sh"), ("--gate", ".chock/compiled/p/stop/gate.json")], ) def test_hook_command_reads_the_same_under_every_shell(args: tuple[str, ...]) -> None: command = hook_command(".chock/bin/cursor.py", *args) - assert command.startswith('git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook .chock/bin/cursor.py') + assert command.startswith( + 'git -c "alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh" chock-hook .chock/bin/cursor.py' + ) for char in ("$", "\\", "'"): assert char not in command, f"{char!r} is read differently by bash, PowerShell or cmd.exe" @@ -179,7 +214,9 @@ def test_hook_command_runs_under_every_available_shell( nested = repo / "a" nested.mkdir() command = hook_command(_PROBE_REL, "--gate", "p/stop/gate.json") - proc = subprocess.run([*argv, command], cwd=nested, capture_output=True, text=True, check=False) + # cmd.exe reads its command line raw (no `\"` unescaping), as an agent hands it over. + args = f"{subprocess.list2cmdline(argv)} {command}" if shell == "cmd" else [*argv, command] + proc = subprocess.run(args, cwd=nested, capture_output=True, text=True, check=False) out = json.loads(proc.stdout) assert Path(out["cwd"]).resolve() == repo.resolve(), shell assert out["args"] == ["--gate", "p/stop/gate.json"], shell diff --git a/tests/test_pretooluse.py b/tests/test_pretooluse.py index d5132da..6d4b6d8 100644 --- a/tests/test_pretooluse.py +++ b/tests/test_pretooluse.py @@ -166,7 +166,9 @@ def test_install_writes_claude_settings_schema() -> None: assert entry["matcher"] == "Bash" hook = entry["hooks"][0] assert hook["type"] == "command" - assert hook["command"].startswith('git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook ') + assert hook["command"].startswith( + 'git -c "alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh" chock-hook ' + ) assert "${" not in hook["command"], "repo-relative: git runs the launcher from the top level" assert sys.executable not in hook["command"], "no interpreter path may be committed" assert (repo / ".chock" / "bin" / "claude_code.py").exists() From 735a4cf6aee3ab042b558335a1f025fb2f61be87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:50:19 +0000 Subject: [PATCH 25/25] Say a gate is missing without echoing its path to stderr CodeQL traced the gate path into the message as sensitive data. The hook command already names the path; the message only needs the fix. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/gate.py | 2 +- src/chock/gate/runner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index b78e196..6730fe5 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -535,7 +535,7 @@ def run( gate_path = Path(gate_path) if not gate_path.exists(): print( - f"gate: {gate_path} is missing -- this hook names it, so the install is incomplete. " + "gate: the compiled gate this hook names is missing, so the install is incomplete. " "Run `chock sync --repo .` to rebuild the compiled gates.", file=sys.stderr, ) diff --git a/src/chock/gate/runner.py b/src/chock/gate/runner.py index b78e196..6730fe5 100644 --- a/src/chock/gate/runner.py +++ b/src/chock/gate/runner.py @@ -535,7 +535,7 @@ def run( gate_path = Path(gate_path) if not gate_path.exists(): print( - f"gate: {gate_path} is missing -- this hook names it, so the install is incomplete. " + "gate: the compiled gate this hook names is missing, so the install is incomplete. " "Run `chock sync --repo .` to rebuild the compiled gates.", file=sys.stderr, )