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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions .chock/bin/_chock_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<stem>.1<suffix>` beside it."""
path = _chock_Path(path)
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/codex_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/devin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
76 changes: 65 additions & 11 deletions .chock/bin/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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: `<stem>.1<suffix>` beside it."""
Expand Down Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/gemini_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/grok.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
3 changes: 2 additions & 1 deletion .chock/bin/launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 >/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 2>/dev/null)" && [ "$probed" = "chock-python" ]; then
exec "$candidate" -X utf8 "$runtime" "$@"
fi
done
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/tabnine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/vscode_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion .chock/bin/windsurf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
31 changes: 22 additions & 9 deletions src/chock/gate/runner/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading