diff --git a/.chock/bin/_chock_runtime.py b/.chock/bin/_chock_runtime.py index 336e74a1..9f9920de 100644 --- a/.chock/bin/_chock_runtime.py +++ b/.chock/bin/_chock_runtime.py @@ -123,6 +123,12 @@ def judge_patterns(judge, jobs, seconds): _LOG_TAIL_BYTES = 65536 +LOG_MODE_MASK, LOG_MODE_FILE, LOG_MODE_DIR = (61440, 32768, 16384) + +LOG_UNSAFE = 'not a regular file in real directories; refusing to open it' + +LOG_OPEN_FLAGS = getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + def log_rotated(path): """Where `log_append` moves a full log: `.1` beside it.""" path = _chock_Path(path) @@ -165,6 +171,30 @@ def log_chain_after(last): return {LOG_CHAIN_SEQ: 0, LOG_CHAIN_PREV: LOG_CHAIN_GENESIS} return {LOG_CHAIN_SEQ: log_line_seq(last) + 1, LOG_CHAIN_PREV: log_line_hash(last)} +def log_path_safe(path): + """Whether the log, its lock and rotated file are regular files or absent, and its two directories real directories. + + lstat, so a symlink, FIFO, socket or device is refused before anything opens it: opening a FIFO blocks the hook + until its timeout, which a client may read as an allow. + """ + path = _chock_Path(path) + checks = ((path, LOG_MODE_FILE), (_chock_Path(str(path) + '.lock'), LOG_MODE_FILE), (log_rotated(path), LOG_MODE_FILE), (path.parent, LOG_MODE_DIR), (path.parent.parent, LOG_MODE_DIR)) + for target, kind in checks: + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & LOG_MODE_MASK != kind: + return False + return True + +def log_require_safe(path): + """Raise OSError unless `log_path_safe(path)`.""" + if not log_path_safe(path): + raise OSError(LOG_UNSAFE) + LOG_LEVEL_ENV = 'CHOCK_LOG_LEVEL' LOG_LEGACY_ENV = 'CHOCK_GATE_LOG' @@ -380,16 +410,17 @@ def log_append(path, record, max_bytes=LOG_DEFAULT_MAX_BYTES): rotation names the rotated file's last. A writer that cannot take the lock writes its line `unchained`. """ path = _chock_Path(path) + log_require_safe(path) path.parent.mkdir(parents=True, exist_ok=True) stamped = {'ts': log_now(), **{k: v for k, v in record.items() if k not in LOG_CHAIN_KEYS}} - lock = _chock_os.open(str(path) + '.lock', _chock_os.O_RDWR | _chock_os.O_CREAT, 384) + lock = _chock_os.open(str(path) + '.lock', _chock_os.O_RDWR | _chock_os.O_CREAT | LOG_OPEN_FLAGS, 384) try: held = _log_lock_wait(lock) try: if held: _log_rotate_held(path, max_bytes) line = _log_line(path, stamped, held=held) - fd = _chock_os.open(str(path), _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_BINARY', 0), 384) + fd = _chock_os.open(str(path), _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_BINARY', 0) | LOG_OPEN_FLAGS, 384) try: _chock_os.write(fd, line) finally: @@ -1628,6 +1659,8 @@ def judged_files(files, root, patterns, repo_names): SESSION_MASK = '***' +SESSION_MODE_MASK, SESSION_MODE_FILE, SESSION_MODE_DIR = (61440, 32768, 16384) + def tool_call_input(event): """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" raw = getattr(event, 'raw', None) @@ -1737,17 +1770,38 @@ def _prune_old(directory): except OSError: continue +def _state_safe(path): + """Whether `path` and its scratch file are regular files or absent, and `.chock/state` and `.chock` real directories. + + lstat, so a FIFO or symlink is refused before anything opens it: opening a FIFO blocks the hook until its timeout. + """ + for target, kind in ((path, SESSION_MODE_FILE), (path.with_suffix('.tmp'), SESSION_MODE_FILE), (path.parent, SESSION_MODE_DIR), (path.parent.parent, SESSION_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & SESSION_MODE_MASK != kind: + return False + return True + def _append_bounded(path, line, record): + if not _state_safe(path): + return lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): return if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): - with path.open('a', encoding='utf-8') as fh: + fd = _chock_os.open(path, _chock_os.O_WRONLY | _chock_os.O_APPEND | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'a', encoding='utf-8') as fh: fh.write(line + '\n') return kept = [*lines, line][-SESSION_MAX_ENTRIES:] scratch = path.with_suffix('.tmp') - scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + fd = _chock_os.open(scratch, _chock_os.O_WRONLY | _chock_os.O_CREAT | _chock_os.O_TRUNC | getattr(_chock_os, 'O_NOFOLLOW', 0), 384) + with _chock_os.fdopen(fd, 'w', encoding='utf-8') as fh: + fh.write('\n'.join(kept) + '\n') scratch.replace(path) def session_record(root, event, phase, outcome=None): diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index ee95433b..8f0db5d8 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 9f24d0d4..db81a2b7 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index ef075ec6..6dcdd66e 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 627c4116..13adbb6a 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 153e73dc..0fee190b 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -449,15 +449,22 @@ def head_blob(self, path: str) -> str: def _config_agent_env(repo_root: Path) -> list[str]: - """Names under `agent_commit_env:` in `.chock/config.yaml`, read without a YAML parser. + """Names under `agent_commit_env:` in `.chock/config.yaml`, in the working tree and as committed at HEAD. - Only an inline list (`[A, B]`), one scalar, or a block list of `- NAME` lines is read; the - runner is stdlib-only, and anything else yields no names rather than a guess. + Both, so an uncommitted edit that drops a name never hides the agent it named. Read without a YAML parser: + only an inline list (`[A, B]`), one scalar, or a block list of `- NAME` lines is read; the runner is + stdlib-only, and anything else yields no names rather than a guess. """ try: - lines = repo_root.joinpath(*_CONFIG_PATH).read_text(encoding="utf-8").splitlines() + working = repo_root.joinpath(*_CONFIG_PATH).read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): - return [] + working = "" + names = _env_names(working) + return names + [name for name in _env_names(_committed_config(repo_root, "HEAD") or "") if name not in names] + + +def _env_names(text: str) -> list[str]: + lines = text.splitlines() raw: list[str] = [] block = False for line in lines: @@ -543,8 +550,8 @@ def _config_rollout(repo_root: Path) -> str: return ROLLOUT_ENFORCE -def committed_rollout(repo_root: Path, ref: str) -> str: - """`rollout:` as committed at `ref`; a ref or file git cannot show is enforce.""" +def _committed_config(repo_root: Path, ref: str) -> str | None: + """`.chock/config.yaml` as committed at `ref`; None when git cannot show it.""" try: shown = subprocess.run( # noqa: S603 -- reading one committed file ["git", "-C", str(repo_root), "show", f"{ref}:{'/'.join(_CONFIG_PATH)}"], # noqa: S607 @@ -555,8 +562,14 @@ def committed_rollout(repo_root: Path, ref: str) -> str: check=False, ) except (OSError, subprocess.SubprocessError, UnicodeDecodeError): - return ROLLOUT_ENFORCE - return rollout_from_text(shown.stdout) if shown.returncode == 0 else ROLLOUT_ENFORCE + return None + return shown.stdout if shown.returncode == 0 else None + + +def committed_rollout(repo_root: Path, ref: str) -> str: + """`rollout:` as committed at `ref`; a ref or file git cannot show is enforce.""" + text = _committed_config(repo_root, ref) + return ROLLOUT_ENFORCE if text is None else rollout_from_text(text) def _stricter(*levels: str) -> str: @@ -1926,16 +1939,19 @@ def log_append(path, record, max_bytes=LOG_DEFAULT_MAX_BYTES): rotation names the rotated file's last. A writer that cannot take the lock writes its line `unchained`. """ path = Path(path) + log_require_safe(path) path.parent.mkdir(parents=True, exist_ok=True) stamped = {"ts": log_now(), **{k: v for k, v in record.items() if k not in LOG_CHAIN_KEYS}} - lock = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT, 0o600) + lock = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT | LOG_OPEN_FLAGS, 0o600) try: held = _log_lock_wait(lock) try: if held: _log_rotate_held(path, max_bytes) line = _log_line(path, stamped, held=held) - fd = os.open(str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o600) + fd = os.open( + str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_BINARY", 0) | LOG_OPEN_FLAGS, 0o600 + ) try: os.write(fd, line) finally: @@ -2293,6 +2309,12 @@ def _nul_paths(out: bytes) -> list[str]: #: How far back from a log's end its last line is looked for before the whole file is read. _LOG_TAIL_BYTES = 65_536 +#: `st_mode`'s file-type bits and the two types a log's files and directories may be (`stat.S_IFMT`, `S_IFREG`, `S_IFDIR`). +LOG_MODE_MASK, LOG_MODE_FILE, LOG_MODE_DIR = 0o170000, 0o100000, 0o040000 +LOG_UNSAFE = "not a regular file in real directories; refusing to open it" +#: Opening a log never follows a symlink and never waits on a FIFO. +LOG_OPEN_FLAGS = getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + def log_rotated(path): """Where `log_append` moves a full log: `.1` beside it.""" @@ -2342,6 +2364,38 @@ def log_chain_after(last): return {LOG_CHAIN_SEQ: log_line_seq(last) + 1, LOG_CHAIN_PREV: log_line_hash(last)} +def log_path_safe(path): + """Whether the log, its lock and rotated file are regular files or absent, and its two directories real directories. + + lstat, so a symlink, FIFO, socket or device is refused before anything opens it: opening a FIFO blocks the hook + until its timeout, which a client may read as an allow. + """ + path = Path(path) + checks = ( + (path, LOG_MODE_FILE), + (Path(str(path) + ".lock"), LOG_MODE_FILE), + (log_rotated(path), LOG_MODE_FILE), + (path.parent, LOG_MODE_DIR), + (path.parent.parent, LOG_MODE_DIR), + ) + for target, kind in checks: + try: + mode = os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & LOG_MODE_MASK != kind: + return False + return True + + +def log_require_safe(path): + """Raise OSError unless `log_path_safe(path)`.""" + if not log_path_safe(path): + raise OSError(LOG_UNSAFE) + + #: The git hook a merge's own commit runs: judged as a commit. _MERGE_COMMIT = "pre-merge-commit" diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 2990ff7e..ba5cfe2a 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 16b86489..bf9661ea 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/launch.sh b/.chock/bin/launch.sh index 5db26949..d0ec00e6 100755 --- a/.chock/bin/launch.sh +++ b/.chock/bin/launch.sh @@ -12,8 +12,9 @@ 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. +# A candidate must also answer the probe's token, so a program that merely exits 0 (`true`) is not taken for Python. 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 + if [ -n "$candidate" ] && probed="$("$candidate" -c 'import sys; print("chock-python"); sys.exit(sys.version_info < (3, 11,))' /dev/null)" && [ "$probed" = "chock-python" ]; then exec "$candidate" -X utf8 "$runtime" "$@" fi done diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index d513535a..fd5c5ef4 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index ab04974d..cd848eae 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index ee68c3b5..561e1e82 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/src/chock/gate/runner/actor.py b/src/chock/gate/runner/actor.py index 2e088993..d0d4bca9 100644 --- a/src/chock/gate/runner/actor.py +++ b/src/chock/gate/runner/actor.py @@ -33,15 +33,22 @@ # >>> gate.py 03 def _config_agent_env(repo_root: Path) -> list[str]: - """Names under `agent_commit_env:` in `.chock/config.yaml`, read without a YAML parser. + """Names under `agent_commit_env:` in `.chock/config.yaml`, in the working tree and as committed at HEAD. - Only an inline list (`[A, B]`), one scalar, or a block list of `- NAME` lines is read; the - runner is stdlib-only, and anything else yields no names rather than a guess. + Both, so an uncommitted edit that drops a name never hides the agent it named. Read without a YAML parser: + only an inline list (`[A, B]`), one scalar, or a block list of `- NAME` lines is read; the runner is + stdlib-only, and anything else yields no names rather than a guess. """ try: - lines = repo_root.joinpath(*_CONFIG_PATH).read_text(encoding="utf-8").splitlines() + working = repo_root.joinpath(*_CONFIG_PATH).read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): - return [] + working = "" + names = _env_names(working) + return names + [name for name in _env_names(_committed_config(repo_root, "HEAD") or "") if name not in names] + + +def _env_names(text: str) -> list[str]: + lines = text.splitlines() raw: list[str] = [] block = False for line in lines: @@ -121,8 +128,8 @@ def _config_rollout(repo_root: Path) -> str: return ROLLOUT_ENFORCE -def committed_rollout(repo_root: Path, ref: str) -> str: - """`rollout:` as committed at `ref`; a ref or file git cannot show is enforce.""" +def _committed_config(repo_root: Path, ref: str) -> str | None: + """`.chock/config.yaml` as committed at `ref`; None when git cannot show it.""" try: shown = subprocess.run( # noqa: S603 -- reading one committed file ["git", "-C", str(repo_root), "show", f"{ref}:{'/'.join(_CONFIG_PATH)}"], # noqa: S607 @@ -133,8 +140,14 @@ def committed_rollout(repo_root: Path, ref: str) -> str: check=False, ) except (OSError, subprocess.SubprocessError, UnicodeDecodeError): - return ROLLOUT_ENFORCE - return rollout_from_text(shown.stdout) if shown.returncode == 0 else ROLLOUT_ENFORCE + return None + return shown.stdout if shown.returncode == 0 else None + + +def committed_rollout(repo_root: Path, ref: str) -> str: + """`rollout:` as committed at `ref`; a ref or file git cannot show is enforce.""" + text = _committed_config(repo_root, ref) + return ROLLOUT_ENFORCE if text is None else rollout_from_text(text) def _stricter(*levels: str) -> str: diff --git a/src/chock/gate/runner/chain.py b/src/chock/gate/runner/chain.py index fb650347..3afe2549 100644 --- a/src/chock/gate/runner/chain.py +++ b/src/chock/gate/runner/chain.py @@ -18,6 +18,12 @@ #: How far back from a log's end its last line is looked for before the whole file is read. _LOG_TAIL_BYTES = 65_536 +#: `st_mode`'s file-type bits and the two types a log's files and directories may be (`stat.S_IFMT`, `S_IFREG`, `S_IFDIR`). +LOG_MODE_MASK, LOG_MODE_FILE, LOG_MODE_DIR = 0o170000, 0o100000, 0o040000 +LOG_UNSAFE = "not a regular file in real directories; refusing to open it" +#: Opening a log never follows a symlink and never waits on a FIFO. +LOG_OPEN_FLAGS = getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + def log_rotated(path): """Where `log_append` moves a full log: `.1` beside it.""" @@ -65,3 +71,35 @@ def log_chain_after(last): if last is None: return {LOG_CHAIN_SEQ: 0, LOG_CHAIN_PREV: LOG_CHAIN_GENESIS} return {LOG_CHAIN_SEQ: log_line_seq(last) + 1, LOG_CHAIN_PREV: log_line_hash(last)} + + +def log_path_safe(path): + """Whether the log, its lock and rotated file are regular files or absent, and its two directories real directories. + + lstat, so a symlink, FIFO, socket or device is refused before anything opens it: opening a FIFO blocks the hook + until its timeout, which a client may read as an allow. + """ + path = Path(path) + checks = ( + (path, LOG_MODE_FILE), + (Path(str(path) + ".lock"), LOG_MODE_FILE), + (log_rotated(path), LOG_MODE_FILE), + (path.parent, LOG_MODE_DIR), + (path.parent.parent, LOG_MODE_DIR), + ) + for target, kind in checks: + try: + mode = os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & LOG_MODE_MASK != kind: + return False + return True + + +def log_require_safe(path): + """Raise OSError unless `log_path_safe(path)`.""" + if not log_path_safe(path): + raise OSError(LOG_UNSAFE) diff --git a/src/chock/gate/runner/journal.py b/src/chock/gate/runner/journal.py index c6098ea1..60de978f 100644 --- a/src/chock/gate/runner/journal.py +++ b/src/chock/gate/runner/journal.py @@ -10,7 +10,16 @@ from datetime import datetime, timezone from pathlib import Path -from .chain import LOG_CHAIN_KEYS, LOG_CHAIN_UNCHAINED, log_chain_after, log_last_line, log_rotated, log_tail +from .chain import ( + LOG_CHAIN_KEYS, + LOG_CHAIN_UNCHAINED, + LOG_OPEN_FLAGS, + log_chain_after, + log_last_line, + log_require_safe, + log_rotated, + log_tail, +) # >>> gate.py 20 #: The level a person set for this shell's hooks; an agent's tool input never reaches a hook's environment. @@ -243,16 +252,19 @@ def log_append(path, record, max_bytes=LOG_DEFAULT_MAX_BYTES): rotation names the rotated file's last. A writer that cannot take the lock writes its line `unchained`. """ path = Path(path) + log_require_safe(path) path.parent.mkdir(parents=True, exist_ok=True) stamped = {"ts": log_now(), **{k: v for k, v in record.items() if k not in LOG_CHAIN_KEYS}} - lock = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT, 0o600) + lock = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT | LOG_OPEN_FLAGS, 0o600) try: held = _log_lock_wait(lock) try: if held: _log_rotate_held(path, max_bytes) line = _log_line(path, stamped, held=held) - fd = os.open(str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o600) + fd = os.open( + str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_BINARY", 0) | LOG_OPEN_FLAGS, 0o600 + ) try: os.write(fd, line) finally: diff --git a/src/chock/gate/session_log.py b/src/chock/gate/session_log.py index 25e80092..8ddc7e7b 100644 --- a/src/chock/gate/session_log.py +++ b/src/chock/gate/session_log.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import os from datetime import datetime, timezone from pathlib import Path @@ -27,6 +28,8 @@ SESSION_UNKNOWN_ID = "unknown" SESSION_ID_MAX = 128 SESSION_MASK = "***" +#: `st_mode`'s file-type bits and the two types the log's files and directories may be. +SESSION_MODE_MASK, SESSION_MODE_FILE, SESSION_MODE_DIR = 0o170000, 0o100000, 0o040000 def tool_call_input(event): @@ -156,17 +159,44 @@ def _prune_old(directory): continue +def _state_safe(path): + """Whether `path` and its scratch file are regular files or absent, and `.chock/state` and `.chock` real directories. + + lstat, so a FIFO or symlink is refused before anything opens it: opening a FIFO blocks the hook until its timeout. + """ + for target, kind in ( + (path, SESSION_MODE_FILE), + (path.with_suffix(".tmp"), SESSION_MODE_FILE), + (path.parent, SESSION_MODE_DIR), + (path.parent.parent, SESSION_MODE_DIR), + ): + try: + mode = os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & SESSION_MODE_MASK != kind: + return False + return True + + def _append_bounded(path, line, record): + if not _state_safe(path): + return lines = path.read_text(encoding="utf-8", errors="replace").splitlines() if path.exists() else [] if any(_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:]): return if len(lines) < SESSION_MAX_ENTRIES and path.exists() and path.stat().st_size < SESSION_TRIM_BYTES: - with path.open("a", encoding="utf-8") as fh: + fd = os.open(path, os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(fd, "a", encoding="utf-8") as fh: fh.write(line + "\n") return kept = [*lines, line][-SESSION_MAX_ENTRIES:] scratch = path.with_suffix(".tmp") - scratch.write_text("\n".join(kept) + "\n", encoding="utf-8") + fd = os.open(scratch, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write("\n".join(kept) + "\n") scratch.replace(path) diff --git a/src/chock/guardrails/cli.py b/src/chock/guardrails/cli.py index 88dfd62a..93747e53 100644 --- a/src/chock/guardrails/cli.py +++ b/src/chock/guardrails/cli.py @@ -13,6 +13,7 @@ from chock.guardrails import toggle from chock.guardrails.plugin import MEMBERS, PROTECT_ID from chock.install import package, place +from chock.stop_common import person_only_refused EXIT_USAGE = 2 RESET = "chock bundle status --adopt --reset" @@ -245,6 +246,10 @@ def main(argv: list[str] | None = None) -> int: ) shown.add_argument("--reset", action="store_true", help="with --adopt: rewrite an invalid toggle file all on") args = parser.parse_args(argv) + if (args.action != "status" or args.adopt) and person_only_refused( + toggle.repo_root(Path.cwd()) or Path.cwd(), "bundle" + ): + return 1 try: if args.action == "status": if args.reset and not args.adopt: diff --git a/src/chock/hooks/data/launch.sh b/src/chock/hooks/data/launch.sh index 15422c1e..7a653b27 100644 --- a/src/chock/hooks/data/launch.sh +++ b/src/chock/hooks/data/launch.sh @@ -12,8 +12,9 @@ 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. +# A candidate must also answer the probe's token, so a program that merely exits 0 (`true`) is not taken for Python. 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 + if [ -n "$candidate" ] && probed="$("$candidate" -c 'import sys; print("chock-python"); sys.exit(sys.version_info < (__MIN_PYTHON__,))' /dev/null)" && [ "$probed" = "chock-python" ]; then exec "$candidate" -X utf8 "$runtime" "$@" fi done diff --git a/tests/fixtures/runtime_goldens/_chock_runtime.py b/tests/fixtures/runtime_goldens/_chock_runtime.py index 336e74a1..9f9920de 100644 --- a/tests/fixtures/runtime_goldens/_chock_runtime.py +++ b/tests/fixtures/runtime_goldens/_chock_runtime.py @@ -123,6 +123,12 @@ def judge_patterns(judge, jobs, seconds): _LOG_TAIL_BYTES = 65536 +LOG_MODE_MASK, LOG_MODE_FILE, LOG_MODE_DIR = (61440, 32768, 16384) + +LOG_UNSAFE = 'not a regular file in real directories; refusing to open it' + +LOG_OPEN_FLAGS = getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + def log_rotated(path): """Where `log_append` moves a full log: `.1` beside it.""" path = _chock_Path(path) @@ -165,6 +171,30 @@ def log_chain_after(last): return {LOG_CHAIN_SEQ: 0, LOG_CHAIN_PREV: LOG_CHAIN_GENESIS} return {LOG_CHAIN_SEQ: log_line_seq(last) + 1, LOG_CHAIN_PREV: log_line_hash(last)} +def log_path_safe(path): + """Whether the log, its lock and rotated file are regular files or absent, and its two directories real directories. + + lstat, so a symlink, FIFO, socket or device is refused before anything opens it: opening a FIFO blocks the hook + until its timeout, which a client may read as an allow. + """ + path = _chock_Path(path) + checks = ((path, LOG_MODE_FILE), (_chock_Path(str(path) + '.lock'), LOG_MODE_FILE), (log_rotated(path), LOG_MODE_FILE), (path.parent, LOG_MODE_DIR), (path.parent.parent, LOG_MODE_DIR)) + for target, kind in checks: + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & LOG_MODE_MASK != kind: + return False + return True + +def log_require_safe(path): + """Raise OSError unless `log_path_safe(path)`.""" + if not log_path_safe(path): + raise OSError(LOG_UNSAFE) + LOG_LEVEL_ENV = 'CHOCK_LOG_LEVEL' LOG_LEGACY_ENV = 'CHOCK_GATE_LOG' @@ -380,16 +410,17 @@ def log_append(path, record, max_bytes=LOG_DEFAULT_MAX_BYTES): rotation names the rotated file's last. A writer that cannot take the lock writes its line `unchained`. """ path = _chock_Path(path) + log_require_safe(path) path.parent.mkdir(parents=True, exist_ok=True) stamped = {'ts': log_now(), **{k: v for k, v in record.items() if k not in LOG_CHAIN_KEYS}} - lock = _chock_os.open(str(path) + '.lock', _chock_os.O_RDWR | _chock_os.O_CREAT, 384) + lock = _chock_os.open(str(path) + '.lock', _chock_os.O_RDWR | _chock_os.O_CREAT | LOG_OPEN_FLAGS, 384) try: held = _log_lock_wait(lock) try: if held: _log_rotate_held(path, max_bytes) line = _log_line(path, stamped, held=held) - fd = _chock_os.open(str(path), _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_BINARY', 0), 384) + fd = _chock_os.open(str(path), _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_BINARY', 0) | LOG_OPEN_FLAGS, 384) try: _chock_os.write(fd, line) finally: @@ -1628,6 +1659,8 @@ def judged_files(files, root, patterns, repo_names): SESSION_MASK = '***' +SESSION_MODE_MASK, SESSION_MODE_FILE, SESSION_MODE_DIR = (61440, 32768, 16384) + def tool_call_input(event): """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" raw = getattr(event, 'raw', None) @@ -1737,17 +1770,38 @@ def _prune_old(directory): except OSError: continue +def _state_safe(path): + """Whether `path` and its scratch file are regular files or absent, and `.chock/state` and `.chock` real directories. + + lstat, so a FIFO or symlink is refused before anything opens it: opening a FIFO blocks the hook until its timeout. + """ + for target, kind in ((path, SESSION_MODE_FILE), (path.with_suffix('.tmp'), SESSION_MODE_FILE), (path.parent, SESSION_MODE_DIR), (path.parent.parent, SESSION_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & SESSION_MODE_MASK != kind: + return False + return True + def _append_bounded(path, line, record): + if not _state_safe(path): + return lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): return if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): - with path.open('a', encoding='utf-8') as fh: + fd = _chock_os.open(path, _chock_os.O_WRONLY | _chock_os.O_APPEND | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'a', encoding='utf-8') as fh: fh.write(line + '\n') return kept = [*lines, line][-SESSION_MAX_ENTRIES:] scratch = path.with_suffix('.tmp') - scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + fd = _chock_os.open(scratch, _chock_os.O_WRONLY | _chock_os.O_CREAT | _chock_os.O_TRUNC | getattr(_chock_os, 'O_NOFOLLOW', 0), 384) + with _chock_os.fdopen(fd, 'w', encoding='utf-8') as fh: + fh.write('\n'.join(kept) + '\n') scratch.replace(path) def session_record(root, event, phase, outcome=None): diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 8ec79325..66674da0 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -698,7 +698,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index ee95433b..8f0db5d8 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 9f24d0d4..db81a2b7 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index ef075ec6..6dcdd66e 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 627c4116..13adbb6a 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py index 3af4298f..f20bf5fd 100644 --- a/tests/fixtures/runtime_goldens/factory_droid.py +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -763,7 +763,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 2990ff7e..ba5cfe2a 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py index c1b03e5a..e377a763 100644 --- a/tests/fixtures/runtime_goldens/goose.py +++ b/tests/fixtures/runtime_goldens/goose.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 16b86489..bf9661ea 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py index 8a6bcaa7..c2a094a7 100644 --- a/tests/fixtures/runtime_goldens/mistral_vibe.py +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -759,7 +759,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py index 4343f5b2..4f4908de 100644 --- a/tests/fixtures/runtime_goldens/openhands.py +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index d513535a..fd5c5ef4 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index ab04974d..cd848eae 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index ee68c3b5..561e1e82 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'c53dd236626640eccee7d77201a2aba1a167106cf484191a72c8622ecbc6001e' def _chock_load_runtime(): diff --git a/tests/test_hook_launcher.py b/tests/test_hook_launcher.py index f0f0c833..71ecf789 100644 --- a/tests/test_hook_launcher.py +++ b/tests/test_hook_launcher.py @@ -287,3 +287,13 @@ def test_cursor_fail_closed_on_gates_and_not_on_stop(tmp_path: Path) -> None: 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"]) + + +@posix_only +def test_a_recorded_program_that_exits_zero_without_being_python_is_passed_over(tmp_path: Path) -> None: + """A program that merely exits 0 would run no hook at all, so only one that answers the probe is taken.""" + repo = _repo(tmp_path) + bin_dir = _bin_dir(tmp_path, "bin", python3=sys.executable) + subprocess.run(["git", "config", "--local", PYTHON_CONFIG_KEY, shutil.which("true")], cwd=repo, check=True) + out = _probe(_run(repo, repo, path=str(bin_dir))) + assert out["exe"] == str(bin_dir / "python3") diff --git a/tests/test_log_path_safety.py b/tests/test_log_path_safety.py new file mode 100644 index 00000000..ae1355d0 --- /dev/null +++ b/tests/test_log_path_safety.py @@ -0,0 +1,97 @@ +"""A FIFO or symlink planted where chock logs never blocks a hook: the writers refuse it before opening anything.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from chock.gate import session_log +from chock.gate.runner import journal + +pytestmark = pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="FIFOs are a POSIX construct") + +_TIMEOUT = 20 + + +def _call(code: str, *args: str) -> subprocess.CompletedProcess: + """Run `code` in a fresh interpreter, so a blocked open is a timeout and not a hung test.""" + return subprocess.run( + [sys.executable, "-c", code, *args], capture_output=True, text=True, timeout=_TIMEOUT, check=False + ) + + +_APPEND = ( + "import sys\nfrom chock.gate.runner import journal\n" + "try:\n journal.log_append(sys.argv[1], {'policy_id': 'p'})\nexcept OSError:\n print('refused')\n" +) +_SESSION = ( + "import sys\nfrom pathlib import Path\nfrom chock.gate import session_log\n" + "session_log._append_bounded(Path(sys.argv[1]), '{}', {'ts': 'x', 'phase': 'pre', 'tool_use_id': 'a'})\nprint('returned')\n" +) + + +@pytest.mark.parametrize("name", ["gate-events.jsonl", "gate-events.jsonl.lock", "gate-events.1.jsonl"]) +def test_log_append_refuses_a_fifo_where_chock_writes(tmp_path: Path, name: str) -> None: + log = tmp_path / ".chock" / "log" + log.mkdir(parents=True) + os.mkfifo(log / name) + done = _call(_APPEND, str(log / "gate-events.jsonl")) + assert done.stdout.strip() == "refused" + + +def test_log_append_refuses_a_fifo_in_place_of_the_log_directory(tmp_path: Path) -> None: + (tmp_path / ".chock").mkdir() + os.mkfifo(tmp_path / ".chock" / "log") + assert _call(_APPEND, str(tmp_path / ".chock" / "log" / "gate-events.jsonl")).stdout.strip() == "refused" + + +def test_log_append_refuses_a_symlinked_log(tmp_path: Path) -> None: + log = tmp_path / ".chock" / "log" + log.mkdir(parents=True) + target = tmp_path / "elsewhere" + target.write_text("", encoding="utf-8") + (log / "gate-events.jsonl").symlink_to(target) + assert _call(_APPEND, str(log / "gate-events.jsonl")).stdout.strip() == "refused" + assert target.read_text(encoding="utf-8") == "" + + +def test_log_append_still_writes_a_regular_log(tmp_path: Path) -> None: + path = tmp_path / ".chock" / "log" / "gate-events.jsonl" + journal.log_append(path, {"policy_id": "p"}) + assert '"policy_id": "p"' in path.read_text(encoding="utf-8") + assert oct(path.stat().st_mode & 0o777) == "0o600" + + +@pytest.mark.parametrize("name", ["s1.jsonl", "s1.tmp"]) +def test_the_session_log_skips_a_fifo(tmp_path: Path, name: str) -> None: + state = tmp_path / ".chock" / "state" + state.mkdir(parents=True) + os.mkfifo(state / name) + assert _call(_SESSION, str(state / "s1.jsonl")).stdout.strip() == "returned" + + +def test_the_session_log_skips_a_symlinked_log(tmp_path: Path) -> None: + state = tmp_path / ".chock" / "state" + state.mkdir(parents=True) + target = tmp_path / "elsewhere" + target.write_text("", encoding="utf-8") + (state / "s1.jsonl").symlink_to(target) + session_log._append_bounded(state / "s1.jsonl", "{}", {"ts": "x", "phase": "pre", "tool_use_id": "a"}) + assert target.read_text(encoding="utf-8") == "" + + +def test_the_session_log_trim_writes_a_private_file(tmp_path: Path) -> None: + path = session_log.session_log_path(tmp_path, "s1") + path.parent.mkdir(parents=True) + path.write_text("".join(f'{{"n": {n}}}\n' for n in range(session_log.SESSION_MAX_ENTRIES)), encoding="utf-8") + event = SimpleNamespace( + event="pre_tool", tool="Bash", command=None, path=None, session_id="s1", tool_use_id="c9", raw={}, output=None + ) + session_log.session_record(tmp_path, event, "pre") + assert oct(path.stat().st_mode & 0o777) == "0o600" + assert len(path.read_text(encoding="utf-8").splitlines()) == session_log.SESSION_MAX_ENTRIES diff --git a/tests/test_person_only_commands.py b/tests/test_person_only_commands.py index e5866346..e52ad74c 100644 --- a/tests/test_person_only_commands.py +++ b/tests/test_person_only_commands.py @@ -13,6 +13,7 @@ from chock import cli from chock.config import policy_status +from chock.gate.runner.actor import agent_signal from chock.stop_common import PERSON_ONLY_POLICY FRAMEWORK_ROOT = Path(__file__).resolve().parents[1] @@ -219,3 +220,60 @@ def test_without_a_marker_sync_committed_is_a_plain_sync(committed: Path, monkey def test_the_agent_ok_flag_is_declared_in_commands_json_for_sync_alone() -> None: assert cli.AGENT_OK_FLAG == {"sync": "--committed"} + + +BUNDLE_WRITES = (["bundle", "off", "scan-secrets"], ["bundle", "on", "scan-secrets"], ["bundle", "status", "--adopt"]) + + +@pytest.mark.parametrize("argv", BUNDLE_WRITES, ids=["off", "on", "adopt"]) +def test_bundle_writes_refuse_an_agent( + repo: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], +) -> None: + monkeypatch.chdir(repo) + monkeypatch.setenv("CLAUDECODE", "1") + assert cli.main(argv) == 1 + assert REFUSED in capsys.readouterr().err + assert not (repo / ".chock" / "guardrails.json").exists() + assert not (repo / ".chock" / "state" / "guardrails.sha256").exists() + + +def test_bundle_status_stays_open_to_an_agent( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(repo) + monkeypatch.setenv("CLAUDECODE", "1") + cli.main(["bundle", "status"]) + assert REFUSED not in capsys.readouterr().err + + +def test_a_marker_named_only_in_the_committed_config_still_identifies_an_agent( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config = repo / ".chock" / "config.yaml" + config.write_text(config.read_text(encoding="utf-8") + "agent_commit_env: [CODEX_SANDBOX]\n", encoding="utf-8") + _commit_all(repo) + config.write_text( + config.read_text(encoding="utf-8").replace("agent_commit_env: [CODEX_SANDBOX]\n", ""), encoding="utf-8" + ) + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("AI_AGENT", raising=False) + monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") + assert agent_signal(repo) == "CODEX_SANDBOX" + + +def test_bundle_off_from_a_subdirectory_reads_the_repo_roots_config( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + config = repo / ".chock" / "config.yaml" + config.write_text(config.read_text(encoding="utf-8") + "agent_commit_env: [CODEX_SANDBOX]\n", encoding="utf-8") + nested = repo / "src" / "pkg" + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("AI_AGENT", raising=False) + monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") + assert cli.main(["bundle", "off", "scan-secrets"]) == 1 + assert "(CODEX_SANDBOX)" in capsys.readouterr().err