diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index e2f3b2c..6f61546 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 @@ -704,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' @@ -734,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 @@ -760,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) @@ -823,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: @@ -834,7 +883,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')) @@ -1052,6 +1102,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1106,10 +1164,49 @@ 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 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: - 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: @@ -1154,7 +1251,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: @@ -1188,23 +1285,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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: @@ -1222,7 +1333,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 99a4da8..901a187 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -690,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' @@ -720,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 @@ -746,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) @@ -809,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: @@ -820,7 +870,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')) @@ -1038,6 +1089,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1092,10 +1151,49 @@ 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 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: - 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: @@ -1140,7 +1238,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: @@ -1174,23 +1272,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..0f92770 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -618,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' @@ -648,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 @@ -674,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) @@ -737,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: @@ -748,7 +798,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')) @@ -966,6 +1017,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1020,10 +1079,49 @@ 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 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: - 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: @@ -1068,7 +1166,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: @@ -1102,23 +1200,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..588369c 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -672,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' @@ -702,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 @@ -728,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) @@ -791,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: @@ -802,7 +852,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')) @@ -1020,6 +1071,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,10 +1133,49 @@ 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 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: - 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: @@ -1122,7 +1220,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: @@ -1156,23 +1254,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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/gate.py b/.chock/bin/gate.py index b45e601..6730fe5 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: its own policy's source and compiled folders. + 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,21 @@ 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" +#: 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: 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 () + policy = parents[1].name + return (f"{COMPILED_PREFIX}{policy}/", f"{POLICIES_PREFIX}{policy}/") + 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 +504,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 +519,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( @@ -512,7 +534,12 @@ def run( ) -> int: gate_path = Path(gate_path) if not gate_path.exists(): - return 0 + print( + "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, + ) + return 2 try: spec = json.loads(gate_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: @@ -529,7 +556,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): @@ -576,7 +603,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 b602ae3..ad40aa4 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -672,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' @@ -702,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 @@ -728,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) @@ -791,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: @@ -802,7 +852,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')) @@ -1020,6 +1071,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,10 +1133,49 @@ 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 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: - 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: @@ -1122,7 +1220,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: @@ -1156,23 +1254,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..30f2d89 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -655,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' @@ -685,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 @@ -711,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) @@ -774,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: @@ -785,7 +835,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')) @@ -1003,6 +1054,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,10 +1116,49 @@ 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 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: - 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: @@ -1105,7 +1203,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: @@ -1139,23 +1237,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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/launch.sh b/.chock/bin/launch.sh new file mode 100755 index 0000000..5db2694 --- /dev/null +++ b/.chock/bin/launch.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# chock hook launcher. Every agent hook chock writes runs: +# 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 +# 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)" +# 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 +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/bin/tabnine.py b/.chock/bin/tabnine.py index fd3bfce..5042525 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -655,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' @@ -685,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 @@ -711,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) @@ -774,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: @@ -785,7 +835,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')) @@ -1003,6 +1054,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,10 +1116,49 @@ 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 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: - 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: @@ -1105,7 +1203,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: @@ -1139,23 +1237,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..51f6f53 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -479,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' @@ -509,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 @@ -535,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) @@ -598,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: @@ -609,7 +659,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')) @@ -827,6 +878,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -881,10 +940,49 @@ 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 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: - 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: @@ -929,7 +1027,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: @@ -963,23 +1061,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..aa68437 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -569,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' @@ -599,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 @@ -625,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) @@ -688,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: @@ -699,7 +749,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')) @@ -917,6 +968,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -971,10 +1030,49 @@ 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 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: - 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: @@ -1019,7 +1117,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: @@ -1053,23 +1151,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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/compiled/block-destructive-commands/agent-hooks/agent-hooks.json b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json index c2b6511..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": "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=!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 df7646d..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": "@CHOCK_PYTHON@ \".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 f0b695b..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": "@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=!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 c7fb60f..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,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=!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 } ] } \ 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..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": "@CHOCK_PYTHON@ \".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 1e0bb4f..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": "@CHOCK_PYTHON@ \".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 74adcdd..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": "@CHOCK_PYTHON@ \".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 2c966da..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": "@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=!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 fcf4b83..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": "@CHOCK_PYTHON@ \".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 5501b31..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": "@CHOCK_PYTHON@ \".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": "@CHOCK_PYTHON@ \".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 7ac1d33..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": "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=!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 5de15fe..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": "@CHOCK_PYTHON@ \".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 ef9b11d..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": "@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=!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 96bfccf..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,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=!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 } ] } \ 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..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": "@CHOCK_PYTHON@ \".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 f5e839d..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": "@CHOCK_PYTHON@ \".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 2126899..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": "@CHOCK_PYTHON@ \".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 c1d61be..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": "@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=!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 6a9fd70..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": "@CHOCK_PYTHON@ \".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 5e47270..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": "@CHOCK_PYTHON@ \".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": "@CHOCK_PYTHON@ \".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 9297163..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,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=!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 } ] } \ 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..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 @@ -1,10 +1,15 @@ { - "matcher": "write_file|replace", - "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\"", - "timeout": 30 - } - ] + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "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" + } + ] + } } \ No newline at end of file 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..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": "@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=!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 658e55c..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": "@CHOCK_PYTHON@ \".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 5ae4a01..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": "@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=!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 af2f34f..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": "@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=!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 e7770bd..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": "@CHOCK_PYTHON@ \".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 8c1a1bc..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": "@CHOCK_PYTHON@ \".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 20879d5..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": "@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=!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 7383256..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": "@CHOCK_PYTHON@ \".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 9dc0283..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": "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=!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 fd03142..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": "@CHOCK_PYTHON@ \".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 1c37a96..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": "@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=!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 1e72925..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,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=!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 } ] } \ 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..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": "@CHOCK_PYTHON@ \".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 26410ef..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": "@CHOCK_PYTHON@ \".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 a91eb2f..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": "@CHOCK_PYTHON@ \".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 e7e9236..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": "@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=!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 3449fbc..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": "@CHOCK_PYTHON@ \".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 2d6e6c4..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": "@CHOCK_PYTHON@ \".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": "@CHOCK_PYTHON@ \".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 93d4c29..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": "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=!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 3c9c9df..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": "@CHOCK_PYTHON@ \".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 463a0a4..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": "@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=!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 d66b4ee..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,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=!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 } ] } \ 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..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": "@CHOCK_PYTHON@ \".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 c424885..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": "@CHOCK_PYTHON@ \".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 c774838..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": "@CHOCK_PYTHON@ \".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 c794424..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": "@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=!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 ca4f949..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": "@CHOCK_PYTHON@ \".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 e66c257..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": "@CHOCK_PYTHON@ \".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": "@CHOCK_PYTHON@ \".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 5036b51..cb0a148 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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.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": "\"/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=!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 a6c16da..ef496ae 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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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 81942b3..8d430fc 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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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 a40fc7c..c2ed67b 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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/tmp/chock-cursor/bin/python3\" \".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 ff176da..56a523a 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=!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": "\"/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=!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,16 @@ "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=!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" + }, + { + "hooks": [ + { + "type": "command", + "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" @@ -32,7 +41,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=!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" @@ -43,7 +52,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=!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 af3054a..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": "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=!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": "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=!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": "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=!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": "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=!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/.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/.grok/hooks/agentseam.json b/.grok/hooks/agentseam.json index e56ef93..e42ca2e 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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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 63988e9..915d027 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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/tmp/chock-cursor/bin/python3\" \".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 442b4f3..c6ad107 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=!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": "\"/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=!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": "\"/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=!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": "\"/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=!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": "\"/usr/local/bin/python\" \".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": "\"/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=!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": "\"/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=!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": "\"/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=!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 c6362b3..9d5599a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,52 @@ # Chock changelog -## Unreleased - +## 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 + 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+ (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). 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 + 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//` 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 + 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. - **INDEX.md says where a gate runs.** The generated index headed its gates "enforced automatically at commit/push", so an agent reading it could expect nothing until a commit -- while a gate compiled for tool use refuses the write in the turn. The heading now says gates 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..d468c53 100644 --- a/acceptance/test_features.py +++ b/acceptance/test_features.py @@ -159,7 +159,11 @@ 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=!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"] @then("the hand-written hook survives") diff --git a/chock.lock b/chock.lock index d4ed938..df9720b 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "f5ce8291003c254099de2b7b0bcd30b3bd749ebfd97ed1071ad37e51237c10cd" + "artifacts_sha256": "819eea7a56cb1be3ab58229132509a98be3c4ca54c9de0b258b61be48dd47543" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "fc65a16e8d3d84f2735a86922e16fa21324fcb63553c17cdd32bb87ef5a6d906" + "artifacts_sha256": "2916a7f196917545c7cdc36b49aa8bf10b91622d17790158124417696ba9b9db" }, { "id": "block-wildcard-agent-permissions", @@ -96,7 +96,7 @@ "managed": false, "sha256": "e73dbc039cdcbf137049a1f5035e4b595d12afe74a12a0f78e2e102d20d29479", "source": "local", - "artifacts_sha256": "c352efb451fe61dc6aad8f799fa8628d3adb6305e22f3ae2efd1ab1a215b641f" + "artifacts_sha256": "ee361e81033a87fa3d3c4b514108fb2dc850239d5e7563eddc80be8fff097081" }, { "id": "pre-generated-scripts", @@ -112,7 +112,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "1d695d9651e43d3ea6aaea6d74d4d1fa9cf2661ed99b5438f1d25cbc56e08470" + "artifacts_sha256": "31cde9edcf9065e64d2a0f172fb706e6221a25e5804480fd5e608335f5680ca3" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "e27d75a558ff05e9c5a3858d68ecbbfa4ec7a9d3243b85fa8ce76aa0da7c72ba" + "artifacts_sha256": "24609dd393f340ebaaaa68be652432f536e778a7069656032d2a00e10f7c3739" }, { "id": "protect-main-branch", 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..5e77eaf 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -42,9 +42,24 @@ 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=!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 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 `agent-hooks` are the premium tier on agents that expose native controls; membership @@ -84,12 +99,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 @@ -162,20 +176,18 @@ 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 six 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 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. | -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 +221,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 @@ -221,6 +232,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/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..3d4b6e6 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,22 @@ 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)]} + 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) @@ -175,15 +173,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 +215,17 @@ 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/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/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/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/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/guard_runner.py b/src/chock/gate/guard_runner.py index 4ad3bab..ccd4058 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, @@ -174,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: @@ -192,9 +244,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/src/chock/gate/runner.py b/src/chock/gate/runner.py index b45e601..6730fe5 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: its own policy's source and compiled folders. + 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,21 @@ 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" +#: 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: 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 () + policy = parents[1].name + return (f"{COMPILED_PREFIX}{policy}/", f"{POLICIES_PREFIX}{policy}/") + 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 +504,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 +519,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( @@ -512,7 +534,12 @@ def run( ) -> int: gate_path = Path(gate_path) if not gate_path.exists(): - return 0 + print( + "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, + ) + return 2 try: spec = json.loads(gate_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: @@ -529,7 +556,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): @@ -576,7 +603,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/runtime_bundle.py b/src/chock/gate/runtime_bundle.py index aead26c..ea5372e 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 @@ -31,6 +32,8 @@ "datetime": "_chock_datetime", "timezone": "_chock_timezone", "Path": "_chock_Path", + "PurePosixPath": "_chock_PurePosixPath", + "PureWindowsPath": "_chock_PureWindowsPath", } @@ -45,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" @@ -84,7 +97,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: @@ -101,13 +132,15 @@ 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) - 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/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 9fceeee..4b79eff 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,12 @@ _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 = ":" #: git status codes: a deletion leaves no content to judge, a rename is followed by its old path. _DELETED = "D" _RENAMED = "R" @@ -89,6 +96,48 @@ 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 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: @@ -96,6 +145,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, ) @@ -150,6 +201,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, @@ -194,26 +247,45 @@ 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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/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/hooks/data/launch.sh b/src/chock/hooks/data/launch.sh new file mode 100644 index 0000000..15422c1 --- /dev/null +++ b/src/chock/hooks/data/launch.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# chock hook launcher. Every agent hook chock writes runs: +# 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 +# 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)" +# 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 +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/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/src/chock/hooks/in_agent_generic.py b/src/chock/hooks/in_agent_generic.py index 2ff10df..557fc78 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. @@ -136,6 +84,7 @@ def _install_form(entry: Any, prior: dict[str, dict]) -> Any: 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: @@ -176,7 +125,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..6da39b2 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,48 +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 +94,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 +114,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 +130,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 +226,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..9de2504 --- /dev/null +++ b/src/chock/hooks/launch.py @@ -0,0 +1,78 @@ +"""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 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}=!{_MISSING}; 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 _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): + # 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 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 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/scaffold/recompile.py b/src/chock/scaffold/recompile.py index 91202b3..03b9ca7 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,14 +235,19 @@ 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)}") + 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") @@ -246,4 +255,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/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_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/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/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/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/conftest.py b/tests/conftest.py index c5e0962..9cf41d1 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,17 @@ 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 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: 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..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": "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=!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 136e014..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": "@CHOCK_PYTHON@ \".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 73cab23..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": "@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=!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 aae9e1c..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,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=!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 } ] } \ 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..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": "@CHOCK_PYTHON@ \".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 64afae7..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": "@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=!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 28fef9f..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": "@CHOCK_PYTHON@ \".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 9303ae6..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": "@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=!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 d0dc7fe..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": "@CHOCK_PYTHON@ \".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 d40f39a..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": "@CHOCK_PYTHON@ \".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": "@CHOCK_PYTHON@ \".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 5f2a244..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,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=!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 } ] } \ 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..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 @@ -1,10 +1,15 @@ { - "matcher": "write_file|replace", - "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\"", - "timeout": 30 - } - ] + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "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" + } + ] + } } \ No newline at end of file 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..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": "@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=!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 c2cc02f..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": "@CHOCK_PYTHON@ \".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 02c51fb..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": "@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=!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 e040f0b..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": "@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=!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 2eb7295..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": "@CHOCK_PYTHON@ \".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 453dd4f..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": "@CHOCK_PYTHON@ \".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 592f036..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": "@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=!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 c26c700..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": "@CHOCK_PYTHON@ \".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/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index ee962a1..58773b0 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -638,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' @@ -668,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 @@ -694,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) @@ -757,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: @@ -768,7 +818,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')) @@ -986,6 +1037,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1040,10 +1099,49 @@ 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 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: - 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: @@ -1088,7 +1186,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: @@ -1122,23 +1220,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..6f61546 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 @@ -704,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' @@ -734,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 @@ -760,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) @@ -823,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: @@ -834,7 +883,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')) @@ -1052,6 +1102,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1106,10 +1164,49 @@ 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 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: - 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: @@ -1154,7 +1251,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: @@ -1188,23 +1285,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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: @@ -1222,7 +1333,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 99a4da8..901a187 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -690,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' @@ -720,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 @@ -746,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) @@ -809,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: @@ -820,7 +870,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')) @@ -1038,6 +1089,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1092,10 +1151,49 @@ 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 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: - 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: @@ -1140,7 +1238,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: @@ -1174,23 +1272,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..0f92770 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -618,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' @@ -648,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 @@ -674,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) @@ -737,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: @@ -748,7 +798,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')) @@ -966,6 +1017,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1020,10 +1079,49 @@ 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 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: - 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: @@ -1068,7 +1166,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: @@ -1102,23 +1200,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..588369c 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -672,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' @@ -702,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 @@ -728,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) @@ -791,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: @@ -802,7 +852,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')) @@ -1020,6 +1071,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,10 +1133,49 @@ 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 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: - 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: @@ -1122,7 +1220,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: @@ -1156,23 +1254,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..ad40aa4 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -672,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' @@ -702,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 @@ -728,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) @@ -791,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: @@ -802,7 +852,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')) @@ -1020,6 +1071,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1074,10 +1133,49 @@ 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 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: - 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: @@ -1122,7 +1220,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: @@ -1156,23 +1254,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..30f2d89 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -655,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' @@ -685,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 @@ -711,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) @@ -774,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: @@ -785,7 +835,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')) @@ -1003,6 +1054,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,10 +1116,49 @@ 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 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: - 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: @@ -1105,7 +1203,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: @@ -1139,23 +1237,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..5042525 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -655,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' @@ -685,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 @@ -711,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) @@ -774,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: @@ -785,7 +835,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')) @@ -1003,6 +1054,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -1057,10 +1116,49 @@ 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 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: - 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: @@ -1105,7 +1203,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: @@ -1139,23 +1237,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..51f6f53 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -479,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' @@ -509,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 @@ -535,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) @@ -598,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: @@ -609,7 +659,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')) @@ -827,6 +878,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -881,10 +940,49 @@ 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 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: - 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: @@ -929,7 +1027,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: @@ -963,23 +1061,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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..aa68437 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -15,9 +15,11 @@ 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 +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings @@ -569,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' @@ -599,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 @@ -625,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) @@ -688,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: @@ -699,7 +749,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')) @@ -917,6 +968,14 @@ def patch_added(event): _GIT = 'git' +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_PARENT = '..' + +_DRIVE_COLON = ':' + _DELETED = 'D' _RENAMED = 'R' @@ -971,10 +1030,49 @@ 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 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: - 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: @@ -1019,7 +1117,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: @@ -1053,23 +1151,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'): + 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 - writes = writes_for(event, gate) + if not gate.exists(): + return _missing_gate(gate, event) + root = repo_root_for(event, gate) + 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 = {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, 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_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_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_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_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_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) 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_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 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_gate_own_policy.py b/tests/test_gate_own_policy.py new file mode 100644 index 0000000..8853c2c --- /dev/null +++ b/tests/test_gate_own_policy.py @@ -0,0 +1,100 @@ +"""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). +""" + +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_compiled_output(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 + + +@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.""" + 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) + 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 diff --git a/tests/test_generic_hooks_install.py b/tests/test_generic_hooks_install.py index 97221de..49f769c 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,42 @@ 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" + + +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) 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_hook_launcher.py b/tests/test_hook_launcher.py new file mode 100644 index 0000000..026664a --- /dev/null +++ b/tests/test_hook_launcher.py @@ -0,0 +1,275 @@ +"""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_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) + 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" + + +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=!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" + + +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") + # 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 + 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)] + [ + 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_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() 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, ) diff --git a/tests/test_pretooluse.py b/tests/test_pretooluse.py index dc4daa3..6d4b6d8 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 @@ -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: @@ -164,7 +166,11 @@ 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=!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() @@ -247,20 +253,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_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) == [] 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 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..bca3fcb 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: @@ -100,3 +102,46 @@ 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" + ) + + +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 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_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 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 diff --git a/tests/test_write_gate_handler.py b/tests/test_write_gate_handler.py index 1bc3bae..a857029 100644 --- a/tests/test_write_gate_handler.py +++ b/tests/test_write_gate_handler.py @@ -146,3 +146,87 @@ 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_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" + 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" + + +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