diff --git a/.chock/bin/_chock_runtime.py b/.chock/bin/_chock_runtime.py index 59e2f682..dbde6c72 100644 --- a/.chock/bin/_chock_runtime.py +++ b/.chock/bin/_chock_runtime.py @@ -286,7 +286,8 @@ def log_settings_read(path): problems.append(f'{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576') unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(''.join([format(path), ': unknown key(s) ', format(', '.join(map(str, unknown))), '; only level and max_bytes are read'])) + names = ', '.join(map(str, unknown)) + problems.append(f'{path}: unknown key(s) {names}; only level and max_bytes are read') return (found, problems) def log_settings_paths(repo_root): @@ -858,7 +859,7 @@ def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | Non verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST['outcome'] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}: {message}') 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: @@ -949,7 +950,8 @@ def dlog_target(event, repo): tool = str(getattr(event, 'tool', None) or '') mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(''.join([format(mcp['server']), '/', format(mcp['tool'])]))[:_DLOG_MCP_CAP] + server, name = (mcp['server'], mcp['tool']) + return log_redact(f'{server}/{name}')[:_DLOG_MCP_CAP] command, path = (getattr(event, 'command', None), getattr(event, 'path', None)) if command: return dlog_command_print(command) @@ -1075,8 +1077,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == 'guard' and 'outcome' in GUARD_LAST: - return ''.join(['guard-', format(GUARD_LAST['outcome'])]) - return source if source == 'fault' else ''.join([format(source), '-', format(_DLOG_SPOKEN.get(outcome, 'allow'))]) + guarded = GUARD_LAST['outcome'] + return f'guard-{guarded}' + if source == 'fault': + return source + spoken = _DLOG_SPOKEN.get(outcome, 'allow') + return f'{source}-{spoken}' def _dlog_paths(route, runtime, agent, event): """(log file, repository whose settings apply) for this call.""" @@ -1149,6 +1155,8 @@ def log_heartbeat(event, manifests): _CREATE_TEXT = 'file_text' +_CREATE_KEYS = ('content', _CREATE_TEXT) + def _edit_call_input(event): raw = getattr(event, 'raw', None) tool_input = raw.get('tool_input') if isinstance(raw, dict) else None @@ -1210,12 +1218,16 @@ def edited_text(event, root=None): return text def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, 'content', None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" @@ -1386,7 +1398,8 @@ def _patch_not_empty(state, text): if last is None or last['op'] != 'update': return if not last['body'] and state['mode'] == 'update': - _patch_refuse(state['number'], text, ''.join(['update of ', format(repr(last['path'])), ' has no hunk'])) + target = last['path'] + _patch_refuse(state['number'], text, f'update of {target!r} has no hunk') if last['body'] and (not (last['body'][-1]['old'] or last['body'][-1]['new'])): _patch_refuse(state['number'], text, 'a hunk has no lines') @@ -1516,7 +1529,11 @@ def _read(root, path): return None def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -1528,7 +1545,7 @@ def patched_files(event, root=None): disk[key] = files[key] = ''.join((line + '\n' for line in body)) elif op == 'delete': disk[key] = None - files.pop(key, None) + files[key] = '' else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -1536,7 +1553,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = '' disk[target] = files[target] = after return files @@ -2168,6 +2185,85 @@ def workspace_root(event): path = getattr(event, 'path', None) return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) +_CASE_BLIND = sys.platform in ('darwin', 'win32') + +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 on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = (_chock_Path(root), []) + for part in _chock_PurePosixPath(name).parts: + try: + stored = _chock_os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return _chock_PurePosixPath(*spelled).as_posix() + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """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,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) + +_MODE_LINK = 40960 + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return (_chock_os.readlink(target), None) + if kind != STOP_MODE_FILE: + return (None, f'{target} is neither a file nor a link') + fd = _chock_os.open(target, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'rb') as handle: + return (handle.read().decode('utf-8'), None) + except UnicodeDecodeError: + return (None, None) + except OSError as exc: + return (None, f'{target} could not be read ({exc})') + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = _chock_os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return ('', None) + except OSError as exc: + return (None, f'{target} could not be examined ({exc})') + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) + _REPO_STOP_PROJECT_ENV = ('CLAUDE_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'OPENHANDS_PROJECT_DIR') _REPO_STOP_PROJECT_KEYS = ('workspacePaths',) @@ -2294,18 +2390,29 @@ def repo_stop_refusal(event): _GIT_DIR = '.git' -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = _chock_Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = _chock_Path(named) + return repository_of(path) or path if path.is_absolute() else path + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, _chock_Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] def per_root(said): @@ -2383,37 +2490,8 @@ def writes_from_event(event, root=None): return {} return {str(path): content} -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, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] try: @@ -2437,8 +2515,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = (field[:2], field[3:]) if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, '')) + if path: paths.append(path) return paths @@ -2454,10 +2532,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(_chock_Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f'chock: {unsafe}, worktree not checked\n') + return None + if text is not None: + writes[path] = text return writes def run_gate(gate, writes, event, run_in, deadline): @@ -2496,7 +2578,7 @@ def root_for(gate): return parents[_GATE_DEPTH_TO_CHOCK].parent def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -2504,7 +2586,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, 'cwd', None) or workspace_root(event) - return _chock_Path(cwd) if cwd else _chock_Path.cwd() + return judged_root(cwd) if cwd else _chock_Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 34438330..f8363244 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 9b699d09..43dd8bda 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 3036ec85..3313be92 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index ba58c7be..4ced8037 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 3c7a864d..3dbb4565 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -220,14 +220,18 @@ def __init__( #: 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: + def is_own(self, path: str) -> bool: + """Whether `path` is in this policy's own source or compiled folder.""" + return bool(self.own) and path.startswith(self.own) + + def in_scope(self, path: str, *, judge_own: bool = False) -> bool: """Whether this policy may judge this file at all. fnmatch semantics, so `*` crosses `/` and `.github/workflows/*` covers nested files. 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): + if not judge_own and self.is_own(path): return False if _is_outside(path): return True @@ -277,9 +281,9 @@ def rev_exists(self, ref: str) -> bool: """True when `ref` resolves to a commit. Used to fail CI closed on a missing base.""" return bool(self._git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").strip()) - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: paths = self._listing("diff", *self._range(), "--name-only", f"--diff-filter={diff_filter}") - return [path for path in paths if self.in_scope(path)] + return [path for path in paths if self.in_scope(path, judge_own=judge_own)] def _diff_text(self, path: str) -> str: """`path`'s `-U0` diff as git writes it, unconverted; raises DiffError when git fails.""" @@ -382,11 +386,11 @@ def __init__( self._added = dict(added or {}) self._stop = stop - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: """Every write is a present file, so a filter asking only for deletions finds none.""" if not set(diff_filter) & set("ACMRT"): return [] - return [path for path in self._writes if self.in_scope(path)] + return [path for path in self._writes if self.in_scope(path, judge_own=judge_own)] def staged_blob(self, path: str) -> str: return self._writes.get(path, "") @@ -678,14 +682,15 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul pragma = _waiver_pattern(params, event) agent = event in HEAD_WAIVER_EVENTS whole = params.get("scan", "added_lines") == "staged_blob" - paths = ctx.staged_paths(params.get("diff_filter", "ACMRT")) + paths = ctx.staged_paths(params.get("diff_filter", "ACMRT"), judge_own=True) judge = pattern_judge() try: forbidden = sorted(_hits(judge, [(_path_pattern(params, ctx), paths)])[0]) heads = {path: ctx.committed_blob(path) if agent else "" for path in paths} blobs = [heads[paths[index]] if agent else ctx.staged_blob(paths[index]) for index in forbidden] spans, texts = _lines( - paths, lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path) + [path for path in paths if not ctx.is_own(path)], + lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path), ) jobs = [(pragma, blobs), (pragma, texts), (params["content_pattern"], texts)] blob_waivers, line_waivers, hits = _hits(judge, jobs) @@ -696,7 +701,7 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul if index in forbidden and forbidden.index(index) not in blob_waivers: matches.append(f"{path}: forbidden path") head = frozenset(heads[path].splitlines()) if agent else None - if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans[path]): + if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans.get(path, ())): matches.append(f"{path}: content pattern") return GateResult(allowed=not matches, matches=matches) @@ -1826,7 +1831,8 @@ def log_settings_read(path): problems.append(f"{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576") unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(f"{path}: unknown key(s) {', '.join(map(str, unknown))}; only level and max_bytes are read") + names = ", ".join(map(str, unknown)) + problems.append(f"{path}: unknown key(s) {names}; only level and max_bytes are read") return found, problems diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 2482da42..53676caa 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index d22df270..74f8315b 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 46a2965f..9621ba33 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 1b7bdeec..9a0e6aa1 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index c2523375..c719df2e 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/README.md b/README.md index d779e8bb..cfc98287 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,7 @@ pip install "chock @ git+https://github.com/open-coder-ai/chock@992711af4cf8d4fd ### Two ways to adopt it 1. **In your repository, for teams.** Run `chock init .`, then `chock add --ref --verify-sha --skip-compile` for each policy, then `chock sync --repo . --ci`. Commit the result. A fresh clone arms itself from what is committed; a person runs `chock sync` to change it. The commit gates are enforced at commit and in CI. -2. **In your coding agent, as plugins.** Best-effort, and they fail open. One repo per client: [Claude Code](https://github.com/open-coder-ai/chock-claude-plugins), [Cursor](https://github.com/open-coder-ai/chock-cursor-plugins), [Copilot](https://github.com/open-coder-ai/chock-copilot-plugins), [Codex](https://github.com/open-coder-ai/chock-codex-plugins), [Devin](https://github.com/open-coder-ai/chock-devin-plugins). Each README has the install line for its client. -3. **One plugin for your agent from a selection.** The chock.sh builder (launching soon) gives a `chock install --selection '…' --apply` command for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, Gemini CLI, OpenHands, Kimi Code or Factory Droid. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. +2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. No chock? Add a per-policy marketplace (`chock-*-plugins`; needs Python 3.11+, git, a bash: Git for Windows has one), never both. ## Application security for the code your agents write @@ -228,7 +227,7 @@ The 15 in the table above get a git hook, a CI gate and an `AGENTS.md` rule; mos ### How do I install it? -See [Install](#install): in your repo, or as plugins. +See [Install](#install): in your repo, or as one plugin from the builder. ### What does it cost? @@ -262,11 +261,11 @@ The 13 public repositories: | [chock-catalog](https://github.com/open-coder-ai/chock-catalog) | Policies: The policies, each labelled by what it enforces, with replayed evals. | | [context-report](https://github.com/open-coder-ai/context-report) | Evidence: A signed report of whether an agent artifact works. | | [chock-threat-intel](https://github.com/open-coder-ai/chock-threat-intel) | Evidence: A weekly threat ledger, each entry scored against the catalog. | -| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Plugins: The catalog as Claude Code plugins (generated). | -| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Plugins: The catalog as Copilot CLI and VS Code plugins (generated). | -| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Plugins: The catalog as Cursor plugins (generated). | -| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Plugins: The catalog as Codex plugins (generated). | -| [chock-devin-plugins](https://github.com/open-coder-ai/chock-devin-plugins) | Plugins: The catalog as Devin plugins (generated). | +| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-devin-plugins](https://github.com/open-coder-ai/chock-devin-plugins) | Plugins: per-policy marketplace, for machines without chock. | | [chock-quickstart](https://github.com/open-coder-ai/chock-quickstart) | Template: What chock init leaves behind. | | [chock-example](https://github.com/open-coder-ai/chock-example) | Template: A working adoption, one policy per layer. | | [.github](https://github.com/open-coder-ai/.github) | Community: Org profile and community health files. | diff --git a/changelog.d/deny-reason-and-quoted-selection.md b/changelog.d/deny-reason-and-quoted-selection.md new file mode 100644 index 00000000..b2431454 --- /dev/null +++ b/changelog.d/deny-reason-and-quoted-selection.md @@ -0,0 +1,5 @@ +- **A refusal says why, and `--selection` takes a quoted code.** A hook deny now reads `Blocked by chock policy: + : ` (for example `BLOCKED: destructive rm command targeting '.git' is not allowed + without approval.`), the way an ask already carried its reason; exit codes and each client's JSON are unchanged. + `chock install --selection` strips one surrounding pair of single or double quotes before decoding, because + cmd.exe passes them through, and a code that still fails to decode says so. diff --git a/changelog.d/engine-hardening-round-2.md b/changelog.d/engine-hardening-round-2.md new file mode 100644 index 00000000..86046b66 --- /dev/null +++ b/changelog.d/engine-hardening-round-2.md @@ -0,0 +1,7 @@ +- **Security: engine hardening, round 2.** Path handling and the person-only checks are stricter. + - The write gate, the turn-end check and the MCP gateway judge the same canonical spellings of a path. + - Every kind of file change, deletions and moves included, reaches the gates that judge paths. + - A policy's path gate also covers that policy's own folders. + - A plugin gate judges from the repository top level, and the turn-end read of the worktree is bounded to regular files. + - A guardrails member is off in a repository only when the committed toggle file says so. + - `--check` opens a person-only command to an agent only where the command defines it. diff --git a/changelog.d/install-marketplace-name.md b/changelog.d/install-marketplace-name.md new file mode 100644 index 00000000..d703b992 --- /dev/null +++ b/changelog.d/install-marketplace-name.md @@ -0,0 +1,7 @@ +- **`chock install --marketplace-name ` names the build's local marketplace.** Without it nothing changes + (`chock-local`). A team's own build, for example `--marketplace-name acme-chock`, installs as + `chock-guardrails@acme-chock` beside a personal `chock-guardrails@chock-local`, so adding the team folder no longer + re-points the shared `chock-local` registration and every plugin installed from it. The marketplace file, the next + steps, `--apply`, the removal steps and the Copilot CLI "already registered" note all use the chosen name. The name + is lowercase letters, digits and single hyphens (at most 64); anything else is refused, and so is rebuilding a + folder under a different name than it was built with. diff --git a/changelog.d/install-removal-steps.md b/changelog.d/install-removal-steps.md new file mode 100644 index 00000000..4fac134b --- /dev/null +++ b/changelog.d/install-removal-steps.md @@ -0,0 +1,5 @@ +- **`chock install` prints "To remove it:" after the install steps.** The agent's own plugin removal, then deleting the + build folder (`chock bundle status` lists a build for as long as its folder exists), then the shared `chock-local` + marketplace, only when no other chock build for that agent is still installed. Codex, Antigravity and Kimi Code print + none yet: their remove command is not confirmed. `chock bundle status` still reads the build folders only; it cannot + tell that an agent has uninstalled a build. diff --git a/changelog.d/off-status-matches-guard.md b/changelog.d/off-status-matches-guard.md new file mode 100644 index 00000000..0abd932f --- /dev/null +++ b/changelog.d/off-status-matches-guard.md @@ -0,0 +1,4 @@ +- **A repo-scope `off` the guard does not honour is no longer reported as off.** The session-start notice now lists an + `off` that HEAD's copy of the toggle file does not hold as staying on until that change is committed, as + `chock bundle status` already did, and `chock bundle off` at repo scope ends with a line saying the change applies + only once the file is committed and from the next session. User scope is unchanged. diff --git a/changelog.d/runtime-render-patch-level.md b/changelog.d/runtime-render-patch-level.md new file mode 100644 index 00000000..aae9698e --- /dev/null +++ b/changelog.d/runtime-render-patch-level.md @@ -0,0 +1,4 @@ +- The vendored runtime renders byte-identically on every supported Python. Five f-strings whose replacement fields held + quoted strings were printed with different quotes by `ast.unparse` on Python 3.12.3 than on 3.12.13, so `chock sync` + on one patch level failed `sync --check` on another. They are rewritten, so the next `chock sync` rewrites `.chock/bin` + once. diff --git a/changelog.d/stdout-unencodable-escape.md b/changelog.d/stdout-unencodable-escape.md new file mode 100644 index 00000000..6193a92f --- /dev/null +++ b/changelog.d/stdout-unencodable-escape.md @@ -0,0 +1,4 @@ +- **Command output no longer crashes on a character the stream cannot encode.** `chock check` on a + Windows runner (stdout cp1252) raised `UnicodeEncodeError` on U+FFFD while printing eval results. The + stdout wrapper now retries with `backslashreplace` in the stream's encoding; text the encoding can + represent prints unchanged. diff --git a/chock.lock b/chock.lock index 8aebbc80..22c0800b 100644 --- a/chock.lock +++ b/chock.lock @@ -1,189 +1,189 @@ { - "lockfile_version": "1", + "lockfile_version": "2", "engine": ">=0.1,<0.2", "packs": [ { "id": "agent-discipline", "version": "0.0.1", "managed": false, - "sha256": "ec930ee2abd4de6717bc0ff71ad22e9d54610970346a9e4ca6d617c1456da956", + "sha256": "e41a849bc2b80f5c3c10fa4ea9883795a3e2ef9a626322c97ce7db41bab71bda", "source": "local", - "artifacts_sha256": "df7dcb3f795b87fde8b0995eb1f84ed36620ccea56b5581be9deb40d7cb60e68" + "artifacts_sha256": "aa8906f9de0aa4a597077410094b8f3cb5cef5e0e50e438f45a1c689160cf3db" }, { "id": "block-destructive-commands", "version": "0.0.1", "managed": false, - "sha256": "ba1108ffdfce230bf66ee9d83a71c46cddb7d7c995759d4daee987439107afc6", + "sha256": "a5c10785be81bb4a391f4e629359454b2e6212772b8475a2ce2f08b03214bab6", "source": "local", - "artifacts_sha256": "b661782f8965ff30f0546b7c5ae442ff0bff89ff1e8ce89b8dc617b0067ed829" + "artifacts_sha256": "606e3ef7e43d9050b317103ae0a0652e2b8548ee8fc2d18aa390539077f8210a" }, { "id": "block-invisible-unicode", "version": "0.0.1", "managed": false, - "sha256": "bc91b8805d38ab92649b5eb69cf7a40c311ad86263ba343935ba4a5a4c3a8d3e", + "sha256": "e34c93313ca3a6352c1d13715c60e8191ad6cdf04542d9f9d6a78cb210e34594", "source": "local", - "artifacts_sha256": "e5853dddd2016f10743c4e28a38efd194ee09e52024bcd08d24d416f27d62bd7" + "artifacts_sha256": "33845353b14a3413b48dce1deeabecc9451858702adc4c12f3ad68dfe560be46" }, { "id": "block-no-verify", "version": "0.0.1", "managed": false, - "sha256": "67c7a06a6676eb0ecc8f929dbdf8fc181ead721c8aa7b6e0f29e1757fdfbb3ff", + "sha256": "4e4fe3bd5a2b1947851bb390f56701c8129619b5a74a745806684f95d2e2105a", "source": "local", - "artifacts_sha256": "b674a89ed872672837d8eac9225086c47530a3163f18e66c729d18df41545b8d" + "artifacts_sha256": "8d9fb089051fba248a366e851b77448117bdf54026fb3fdab1abe645bb479861" }, { "id": "block-wildcard-agent-permissions", "version": "0.0.1", "managed": false, - "sha256": "123c2f3a5c286c2677f00e0ac93cef75c4604e84a1af2464fb89c91b0575aed4", + "sha256": "5a6018554c19e90993f20e5d8a02fd1288c3ebb8431ddcee2967f009f6fab129", "source": "local", - "artifacts_sha256": "0609b0051635e601c2b7cb295849002048b10b3b5d9ae3aa6eee883147a345fc" + "artifacts_sha256": "395d89ad34af4bdb0ed4f0e7a0dce55ebf8ee3d9eada8e90196ffcb190bf1767" }, { "id": "code-safety", "version": "0.0.1", "managed": false, - "sha256": "e2f96ed16c4e714fe7c062418953335f357025eda12ffc651c74554ed27dbc8c", + "sha256": "07a34b6e60452d78f8df0f853d17097e931f5e2e5e4b3fc492277bb99fd651f7", "source": "local", - "artifacts_sha256": "e94db722b253b96a68a6629fb822abc19ec7d8b465fc4bc43967353921bf6ceb" + "artifacts_sha256": "2a3465baf6bc0da2f3ae44e01cf1ddcdd400135438c14836368c6bdc7a29a405" }, { "id": "context-hygiene", "version": "0.0.1", "managed": false, - "sha256": "b428fd0fa0927e343a28334268cfdd214602a7eba0ee27176ec3205e93ffb7ad", + "sha256": "7d77f634a5b13e199a4e6744d3fc2dadffce3891c7bc6a6a538639e37ae1505d", "source": "local", - "artifacts_sha256": "f85d4575557544366e98ca4d2a468511ae370064048a8fecb1a89d44b7ba4117" + "artifacts_sha256": "1cbaddfc2bd8e401ac218252c755d5ef9be607f0b88359f2af59c5323992b285" }, { "id": "git-safety", "version": "0.0.1", "managed": false, - "sha256": "d5c9f5052cb7cda87d42c03b4cfa13e7635be542745e4b183ef87220da9e6749", + "sha256": "d57e89caab2d9c0608beb4c6acad049c1dc76150fa2caae17f43daa2d77264c4", "source": "local", - "artifacts_sha256": "4560a72cb117582027f70a2b01130e89088e6fb4596c7230d5779940a5864deb" + "artifacts_sha256": "b9db0e16e1009cb85d051c939ff47093497f8540d648adc33b90b6d1a39f08d8" }, { "id": "injection-defense", "version": "0.0.1", "managed": false, - "sha256": "ff181c5171d6360b9add0ec8106189afc97675661899960512e708ea8496bc76", + "sha256": "722c6235b5366a0916625a2acb4f7383b07b04f2691a99b3d5cb8203d922fa45", "source": "local", - "artifacts_sha256": "c76a9bd12792ccecc411093054f16aac210953f3324b054586f3c8af3f7a0034" + "artifacts_sha256": "a6f82e74b77c04a051dbc1058b1e1124eb8b78d12427af3b947b6d42686331be" }, { "id": "memory-discipline", "version": "0.0.1", "managed": false, - "sha256": "8e410a79774cc33335242580a7acfae5164b043e596c560ed0ca4ec0abe88062", + "sha256": "e795ef722ee5eb0fdc9af872666d24b90a7a020dff50e56bd8f418a9a868a11a", "source": "local", - "artifacts_sha256": "1aafc65b5f94357a12c022f51ba34035444e28f85f5d5176cf3a78552565867b" + "artifacts_sha256": "906287d93337b6080f68621350512cbf8a73c381657c08faeeddab8a2ec45a6b" }, { "id": "minimal-content", "version": "0.0.1", "managed": false, - "sha256": "0be9365f661d0a63e3b565a434ca9bd865818baecef425fbfe320cc77a6a2ce7", + "sha256": "00bb15c96c603ff92fdb7c7fd9fcc610c36f05fdcd5147763931acbd96818853", "source": "local", - "artifacts_sha256": "318e534a8fbca46a300a7dc0d6ee0c3f2c5d9bbd5952999a188bf19ee092c29f" + "artifacts_sha256": "a6d131a5036887ab769dbdcbe01c761f5825b656a6cae30607f900639cb29ec7" }, { "id": "pin-github-actions", "version": "0.0.1", "managed": false, - "sha256": "53677765d84916220ac717424bbdf9dce4089257bfc873a3ed354cdb38f7865c", + "sha256": "a3afaef1791bf3b84cfd48b91c8dd5553a6a2fbd99d0e9df6423cb100a4e68d7", "source": "local", - "artifacts_sha256": "4c927b55c5e37e555dbec88628f0c47ea08a42bd08bd6f2cc911d97a24e8d57f" + "artifacts_sha256": "99d58c2e84918370cac3b75a6fa2cc8d530c63dfa8189c5a5e4835b06859dfdd" }, { "id": "pre-generated-scripts", "version": "0.0.1", "managed": false, - "sha256": "3605fe3806364cc16407d81c6522783ab855c950c630dd464b196067b7fce136", + "sha256": "17227f0f6e007ddd445f0ace6457b5d3ffc66ed23f2d52b75af9f9eb669191c5", "source": "local", - "artifacts_sha256": "36bb984d05a83a67c124d6c85c97de3fa35b362d3e3514dccb3a59f6f3436224" + "artifacts_sha256": "ef6d558f78d7f86bc66b7c755b3eac5324a44db9e240ef29dc24a7d6e27f5380" }, { "id": "protect-agent-config", "version": "0.0.1", "managed": false, - "sha256": "29c1a18fa1bd3ee2ab34a0ea1d1c07617a5841b8afbd65ad8704d31093c4fcc9", + "sha256": "dc20b58f82ae4c5c77759d32d15a51a431c76039c3cc34b1d4a892cb2f264aef", "source": "local", - "artifacts_sha256": "8d0aab010a51d39faba904aa4244b2acdff2280345e79e441e6011ee0dae4843" + "artifacts_sha256": "aa0f584a3da1309770c9fbda7d082970e7d279d298f06ca625520a80d85e278f" }, { "id": "protect-commit-privacy", "version": "0.0.1", "managed": false, - "sha256": "22045da796ba466d8043b7d4322b6dbfdcc46e3fd9709221c505acdf24d08119", + "sha256": "07d12989cb3a4a20a2d48579f2b51c5c5d6e14e4367412250d2d3fe682759fdd", "source": "local", - "artifacts_sha256": "db35858d04820994dc661f2bc437a980842be296a8fdd5c2a3ade921e293ce88" + "artifacts_sha256": "0aab76e19d6744ed014507bd15d6ed15e2db88d1777cd5bfe2295d557c7bf39b" }, { "id": "protect-main-branch", "version": "0.0.1", "managed": false, - "sha256": "a0c154b0e2fc31a90f518419d29177a487702f881e7f0158e566271d5a8a61a8", + "sha256": "d1709293d7ee4ef502457e2e65eb65516daebd6ba5f7172db744bfdd8db63002", "source": "local", - "artifacts_sha256": "18bb4772936ece0e374fb48e1a09fdbf946fb82381ed30687c0bcc8a10d4643c" + "artifacts_sha256": "dd31407d52e400f6f74e515cdacb8b3e188b97133db255f88bf73791bbee2eb4" }, { "id": "require-review-evidence", "version": "0.0.1", "managed": false, - "sha256": "6746768db59903cf017c66799b68ca4290870655ab6ede665ef2fde035c87875", + "sha256": "67b76ca30e7e9add2d284b62da210344a828e6de3d28e5f410ff81c5842beaa4", "source": "local", - "artifacts_sha256": "6bab271e1984302fd4c659739aac6d0029f5f2215af90182d3d7cbb59984f9c8" + "artifacts_sha256": "b16de533c1019379d5f5c7420bd0934f1430e6716f09faa8677763e20bab5a02" }, { "id": "scan-secrets", "version": "0.0.1", "managed": false, - "sha256": "ac98dc12772df80b1133d262996d7e52b279281c1f1df2095693aceba58952ec", + "sha256": "d04914f3b1ff3d4a2983ec8bc9fd3465556d5744ec41d03faca61ddd8c4917f0", "source": "local", - "artifacts_sha256": "e01cc23847398082eeb2bb7f9039c2b7f994f89a9404863afc3f67b3fa7a6c8f" + "artifacts_sha256": "5c9540a8b0ca8ca8bb88dde396adb71fe725eace2e932dc90c18d8c69d6b67f5" }, { "id": "test-integrity", "version": "0.0.1", "managed": false, - "sha256": "c140c5ca23c3e9dde10712b889840ee9a4a1976f7cb328d088f8f781e03c274e", + "sha256": "965b849688591738ddb1b6a7f5d20deaa0483e33c6daa117d37a592126512fca", "source": "local", - "artifacts_sha256": "c5d2e1f614b609c98c3da3c2e33beb9196beeacec4e3f7c7541f3bd1b08c1db4" + "artifacts_sha256": "b013983cfca823013ac954817d053c7ce8b7accdaa6b0484a2f7105a655550e8" }, { "id": "token-efficiency", "version": "0.0.1", "managed": false, - "sha256": "cea845541e17c74ce0ffe8fd269c1b00d649d96c61a0b504a185b7ed6e247b4e", + "sha256": "771cbb776e8b86602d330b878073046bd7becbc44827e47f745bb5970973ceb4", "source": "local", - "artifacts_sha256": "dbc26807e0d6ab796e909fd55a874546e6777bbdfb288edc7c3e7e92bd7cad5b" + "artifacts_sha256": "c14412edb8abd9e633198f2fc08bc09af74e682939e1a076da1fea32c3fab97a" }, { "id": "verify-dependency-exists", "version": "0.0.1", "managed": false, - "sha256": "005680c3df7ab7272edf4e45d3ea1f56dec2fedd8a8f78b75b1555c8ccbe762b", + "sha256": "495a8175b1dbef29dc7b741157f96d7da6c5e8cc207a2f1a1fa86ca06f2961f8", "source": "local" }, { "id": "verify-framework-conformance", "version": "0.0.1", "managed": false, - "sha256": "d8d03b846cef3bcfc66ed036e3d69b3abd6762c1ff2f60b8ecc7c3d7da3e9609", + "sha256": "8cf8ec272df2d224b264ca662e7ca6798130369a4d70a14d454566654ada9917", "source": "local", - "artifacts_sha256": "ea12f0f8a86bcab67484585b30d72226a6be554c4dc51a0e2f66d6c29d38d897" + "artifacts_sha256": "3c4f69d24ba8acbe41c9c7a1a334dc3fb9d722d39c85f601e6f214a92917210c" }, { "id": "yagni", "version": "0.0.1", "managed": false, - "sha256": "2546154d5eb24bbfa8510b69c809b19bb5546ae112bae29be2de26e492a42437", + "sha256": "58954cf1e9d3634f67b759d65fc16bb2f97782b1815a71ea558b4dfd47b66628", "source": "local", - "artifacts_sha256": "80297724a9306b6a8a3278780a02e7bbdcb8dafd7b197e66fed8ba301ef090ab" + "artifacts_sha256": "12038a1b15c829fc3326b2a9667e0ea7276278be51620a58b2efdb4b60a019b5" } ] } diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index b88905f5..26588dc5 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -189,20 +189,40 @@ mere presence of two files. The Agent Plugins 1.0 standard carries no hooks, so an `agent-plugins` package is advisory by construction. Enforcement travels in five vendor plugin formats built from the same -policies (`chock plugin build --format claude|copilot|cursor|codex|devin`). Four of them are -published in a generated distribution repo of their own, and each of those is **witnessed -denying a destructive command on a real install**: +policies (`chock plugin build --format claude|copilot|cursor|codex|devin`). A user gets them as +one merged plugin from a chock.sh selection, built on their machine: -| Vendor repo | Client(s) | Deny dialect | +``` +chock install --selection --client --apply +``` + +`--apply` runs the client's own commands where it has them; otherwise the step is yours. +Four formats are each **witnessed denying a destructive command on a real install**: + +| Format | Client(s) | Deny dialect | | :--- | :--- | :--- | -| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Claude Code | exit 2 | -| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Copilot CLI, VS Code | exit 2 | -| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Cursor | stdout `{"permission": "deny"}` | -| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Codex (after its per-hook trust review) | exit 0 + `permissionDecision` JSON | +| claude | Claude Code | exit 0 + `permissionDecision` deny JSON (measured 2026-10-21, Claude Code 2.1.305) | +| copilot | Copilot CLI, VS Code | exit 0 + `permissionDecision` deny JSON (from the adapter's copilot_respond template; unmeasured this session) | +| cursor | Cursor | stdout `{"permission": "deny"}` | +| codex | Codex (after its per-hook trust review) | exit 0 + `permissionDecision` JSON | One guard, one adapter, byte-identical across all four — only the envelope each client reads differs. +| `--client` | Client | `--apply` runs | You do | +| :--- | :--- | :--- | :--- | +| `claude-code` | Claude Code | `claude plugin marketplace add`, `claude plugin install` | restart the session or `/reload-plugins` | +| `copilot` | GitHub Copilot | `copilot plugin marketplace add`, `copilot plugin install` | VS Code without the CLI: add the plugin folder to `chat.pluginLocations` | +| `codex` | Codex | `codex plugin marketplace add` | install in `/plugins`, trust its hooks in `/hooks` | +| `cursor` | Cursor | nothing | Developer: Reload Window | +| `devin` | Devin | `devin plugins install --local` | nothing | +| `goose` | goose | nothing | start a new session | +| `grok` | Grok | `grok plugin install --trust` | nothing | +| `antigravity` | Antigravity | `agy plugin install` | nothing | +| `openhands` | OpenHands | nothing | `openhands --plugin `, or add it to `[plugins]` sources in `~/.openhands/config.toml`; keep the folder in place | +| `kimi-code` | Kimi Code | nothing | in its TUI, `/plugins install ` then `/reload`; again after a rebuild | +| `factory-droid` | Factory Droid | `droid plugin marketplace add`, `droid plugin install --scope project` | nothing | + Every hook-carrying format also carries a policy's **gate** when the gate declares `tool_use`: the compiled `scripts/gate.json`, the stdlib runner beside it as `scripts/gate.py`, and for `kind: script` the policy's whole `implementations/` under `scripts/`. The hooks file @@ -224,3 +244,57 @@ case there reproduces a witnessed failure), and the probe evidence is recorded i 0.4.0 CHANGELOG entry. Codex additionally installs every hook **untrusted** until a human approves it, and that trust is bound to a hash of the hook command, so a plugin update silently voids it until re-approved. + +## Host it in your own marketplace + +An organisation that does not want every developer running a build builds once and hosts the result. +An admin runs the build without `--apply`, into a folder of their choice: + +``` +chock install --selection --client --dest --marketplace-name -chock +``` + +Nothing is installed and no client command runs. For the marketplace-layout clients (`claude-code`, +`copilot`, `codex`, `factory-droid`) the folder is a complete local marketplace: + +```text +/ +├── .claude-plugin/marketplace.json # the index the client reads (codex: .agents/plugins/marketplace.json) +├── chock.marketplace.json # which client's marketplace this is +└── claude/chock-guardrails/ # the plugin (/) + ├── .claude-plugin/plugin.json + ├── hooks/hooks.json # every guard, gate and the turn-end check + ├── scripts/ # the guards and gates, the vendored runtime, launch.sh + └── chock.selection.json # the marker +``` + +The marker records the selection, catalog commit, engine version and each custom policy's sha256. The other clients +(`cursor`, `devin`, `goose`, `grok`, `antigravity`, `openhands`, `kimi-code`) build the plugin folder. + +**Name the marketplace.** Every build calls its marketplace `chock-local`; a second folder under that name re-points +the first. `--marketplace-name ` (lowercase, digits, hyphens; at most 64 characters) names it instead: +developers install `chock-guardrails@` beside a personal `chock-guardrails@chock-local`, and neither moves. + +1. **Review.** Read `chock.selection.json` and `hooks/hooks.json`; `chock bundle status` lists only + bundles installed in a client's default folder, so it does not see a `--dest` build. +2. **Commit** the folder to the organisation's own marketplace repository. +3. **Developers install** with the client's own commands, for example `claude plugin marketplace add + /` and `claude plugin install chock-guardrails@`. +4. **Update** by building on a newer catalog pin and committing again; the marker's catalog commit is the version. + +Hook commands reach their scripts through the client's plugin-root variable (`${CLAUDE_PLUGIN_ROOT}` for Claude Code), +so the folder works anywhere. **Antigravity and OpenHands document none** (`plugin_root: null`): their hooks use the +build folder's absolute path, so install a hosted copy at that path. + +Measured for Claude Code 2.1.305: a moved three-policy build's `hooks.json` commands, with `CLAUDE_PLUGIN_ROOT` set to +the new folder, refused `rm -rf /` and passed `ls -la`; `claude plugin marketplace add` and `install` succeeded. +Unmeasured: a `--marketplace-name` build (tests only), a live refusal, other clients. + +**Developers need no chock.** The plugin imports nothing from chock; it needs Python 3.11+, git and a bash (Git for +Windows includes one). Measured in a venv without chock (Python 3.11.15, 3.12.3), a fixture-catalog plugin refused a +guarded write and allowed `notes.txt`; Windows unmeasured. **No machine can run chock?** The +[`chock-*-plugins`](https://github.com/open-coder-ai/chock-claude-plugins) repos stay as a per-policy marketplace, +republished each release. One route per policy. + +Pre-installing a marketplace on every machine is the vendor's feature, not chock's: Claude Code's +[docs](https://code.claude.com/docs/en/plugin-marketplaces) say an administrator can require one (unmeasured). diff --git a/docs/assets/readme/data.json b/docs/assets/readme/data.json index 76165cd2..7faf55aa 100644 --- a/docs/assets/readme/data.json +++ b/docs/assets/readme/data.json @@ -141,27 +141,27 @@ { "name": "chock-claude-plugins", "kind": "Plugins", - "line": "The catalog as Claude Code plugins (generated)." + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-copilot-plugins", "kind": "Plugins", - "line": "The catalog as Copilot CLI and VS Code plugins (generated)." + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-cursor-plugins", "kind": "Plugins", - "line": "The catalog as Cursor plugins (generated)." + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-codex-plugins", "kind": "Plugins", - "line": "The catalog as Codex plugins (generated)." + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-devin-plugins", "kind": "Plugins", - "line": "The catalog as Devin plugins (generated)." + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-quickstart", diff --git a/docs/assets/readme/render.mjs b/docs/assets/readme/render.mjs index 2fd99bc0..a5cbc617 100644 --- a/docs/assets/readme/render.mjs +++ b/docs/assets/readme/render.mjs @@ -115,9 +115,9 @@ ${term([cmd('chock init .'), cmd('chock add <id> --ref <sha> \\'), '
Everyone and every agent on the repo. Enforced at commit and in CI. Travels with every clone.
Route 2no repo changes
-
In your coding agent, as plugins
-${term(['
Claude Code
', '
chock-claude-plugins
', '
Cursor · Copilot · Codex · Devin
', '
one plugin repo per client
'])} -
Your own sessions in that client. Best-effort hooks that fail open. Pair with route 1 for commit and CI.
+
In your coding agent, as one plugin
+${term([cmd('chock install --selection <code> --apply'), '
from the chock.sh builder
'])} +
Your own sessions in that client, any client chock install supports. Best-effort hooks that fail open. Pair with route 1 for commit and CI.
Or build a selection: the chock.sh builder (launching soon) gives one plugin for your agent from the policies you pick, your own included.
`); diff --git a/docs/assets/readme/repos.json b/docs/assets/readme/repos.json index b2a387ef..3fbde8bc 100644 --- a/docs/assets/readme/repos.json +++ b/docs/assets/readme/repos.json @@ -4,11 +4,11 @@ {"name": "chock-catalog", "kind": "Policies", "line": "The policies, each labelled by what it enforces, with replayed evals."}, {"name": "context-report", "kind": "Evidence", "line": "A signed report of whether an agent artifact works."}, {"name": "chock-threat-intel", "kind": "Evidence", "line": "A weekly threat ledger, each entry scored against the catalog."}, - {"name": "chock-claude-plugins", "kind": "Plugins", "line": "The catalog as Claude Code plugins (generated)."}, - {"name": "chock-copilot-plugins", "kind": "Plugins", "line": "The catalog as Copilot CLI and VS Code plugins (generated)."}, - {"name": "chock-cursor-plugins", "kind": "Plugins", "line": "The catalog as Cursor plugins (generated)."}, - {"name": "chock-codex-plugins", "kind": "Plugins", "line": "The catalog as Codex plugins (generated)."}, - {"name": "chock-devin-plugins", "kind": "Plugins", "line": "The catalog as Devin plugins (generated)."}, + {"name": "chock-claude-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-copilot-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-cursor-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-codex-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-devin-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, {"name": "chock-quickstart", "kind": "Template", "line": "What chock init leaves behind."}, {"name": "chock-example", "kind": "Template", "line": "A working adoption, one policy per layer."}, {"name": ".github", "kind": "Community", "line": "Org profile and community health files."} diff --git a/docs/custom-policies.md b/docs/custom-policies.md index d5d63f35..35929029 100644 --- a/docs/custom-policies.md +++ b/docs/custom-policies.md @@ -90,7 +90,11 @@ To propose a policy to the catalog instead, follow the catalog's CONTRIBUTING st A plugin honours an `off` in the repository's or the user's guardrails toggle file only in a session that started after that file's record (the sha256 `chock bundle` keeps beside it) last changed. The plugin binds each session to the files it first saw; if either file changes during the session, every member stays on for the rest of it, and the change takes -effect in the next session. A session start lists every member that is off, by scope, and the Stop-time check refuses a +effect in the next session. At repository scope an `off` also needs a commit: the guard honours it only when HEAD's +copy of the toggle file holds it too, so `chock bundle off` takes effect once the file is committed and a +new session starts. Until then the member stays on, and `chock bundle status` and the session-start notice say so +("is off in the working file but not in HEAD's, so it stays on until that change is committed"). User scope has no +commit rule. A session start lists every member that is off, by scope, and the Stop-time check refuses a turn's end while a file changed during the session switches a member off. The built-in self-protection is never switchable. A client that sends no session id honours an `off` only after a session-start event. diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 019cdcc6..0b17a3bc 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -9,7 +9,7 @@ claim must match a mechanism. This page says what each sibling is and how chock | [agentseam](https://github.com/open-coder-ai/agentseam) | The primitives layer for every coding agent: one handler API over their hooks, instruction files, plugin packaging and config, with a capability matrix that carries its provenance. chock is one thing built on it | | [context-report](https://github.com/open-coder-ai/context-report) | A signed report format for whether a plugin, hook, skill or `AGENTS.md` actually works; chock can export one | | [chock-threat-intel](https://github.com/open-coder-ai/chock-threat-intel) | A weekly, human-reviewed digest of agentic-AI threats scored against the catalog: enforced, advisory, or `policy wanted`. Feeds the roadmap | -| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) · [copilot](https://github.com/open-coder-ai/chock-copilot-plugins) · [cursor](https://github.com/open-coder-ai/chock-cursor-plugins) · [codex](https://github.com/open-coder-ai/chock-codex-plugins) | The catalog compiled by `chock plugin build` into each client's native plugin format. Generated only: CI rebuilds from source and fails on any difference | +| Plugins | Two routes. The chock.sh builder produces one plugin for any client from a selection, via `chock install --selection --apply` (see [agent plugins](agent-plugins.md#the-hook-carrying-vendor-formats)). For machines that cannot install chock, the five `chock-*-plugins` repos stay as a per-policy marketplace, republished with every chock release; use one route per policy, never both. | | [chock-quickstart](https://github.com/open-coder-ai/chock-quickstart) · [chock-example](https://github.com/open-coder-ai/chock-example) | Template repositories: exactly what `chock init` leaves behind, and a working adoption with one policy per layer | Where a contribution goes: diff --git a/src/chock/cli.py b/src/chock/cli.py index 53dc0c9b..a77eaf3d 100644 --- a/src/chock/cli.py +++ b/src/chock/cli.py @@ -17,6 +17,9 @@ #: A person-only command given this flag reports drift and writes nothing, so it stays open to an agent. _CHECK_FLAG = "--check" +#: The person-only commands whose parser defines `--check`; for any other it is a positional value, not a flag. +_CHECK_COMMANDS = ("sync", "recompile") +_END_OF_OPTIONS = "--" def _module_main(module_name: str): @@ -64,6 +67,12 @@ def _repo_root(argv: list[str]) -> Path: return Path() +def _is_check_form(command: str, argv: list[str]) -> bool: + """`--check` as the flag of a command that defines it: before any `--`, where argparse stops reading flags.""" + options = argv[: argv.index(_END_OF_OPTIONS)] if _END_OF_OPTIONS in argv else argv + return command in _CHECK_COMMANDS and _CHECK_FLAG in options + + def _is_sole_flag_form(command: str, argv: list[str]) -> bool: """`argv` is the command's agent-ok flag with at most a `--repo`, and nothing else.""" flag = AGENT_OK_FLAG.get(command) @@ -112,7 +121,7 @@ def _dispatch(argv: list[str]) -> int: print(f"Unknown command: {command!r}. Run 'chock --help'.", file=sys.stderr) return 2 fn, _, person_only = COMMANDS[command] - if person_only and _CHECK_FLAG not in rest: + if person_only and not _is_check_form(command, rest): repo = _repo_root(rest).resolve() if _is_sole_flag_form(command, rest): if committed_refused(repo, command): diff --git a/src/chock/gate/decision_log.py b/src/chock/gate/decision_log.py index d4bd8e5b..93b4f742 100644 --- a/src/chock/gate/decision_log.py +++ b/src/chock/gate/decision_log.py @@ -132,8 +132,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == "guard" and "outcome" in GUARD_LAST: - return f"guard-{GUARD_LAST['outcome']}" - return source if source == "fault" else f"{source}-{_DLOG_SPOKEN.get(outcome, 'allow')}" + guarded = GUARD_LAST["outcome"] + return f"guard-{guarded}" + if source == "fault": + return source + spoken = _DLOG_SPOKEN.get(outcome, "allow") + return f"{source}-{spoken}" def _dlog_paths(route, runtime, agent, event): diff --git a/src/chock/gate/decision_target.py b/src/chock/gate/decision_target.py index 53f6f54f..bee39af8 100644 --- a/src/chock/gate/decision_target.py +++ b/src/chock/gate/decision_target.py @@ -155,7 +155,8 @@ def dlog_target(event, repo): tool = str(getattr(event, "tool", None) or "") mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(f"{mcp['server']}/{mcp['tool']}")[:_DLOG_MCP_CAP] + server, name = mcp["server"], mcp["tool"] + return log_redact(f"{server}/{name}")[:_DLOG_MCP_CAP] command, path = getattr(event, "command", None), getattr(event, "path", None) if command: return dlog_command_print(command) diff --git a/src/chock/gate/edit_image.py b/src/chock/gate/edit_image.py index 75de559f..df7838c5 100644 --- a/src/chock/gate/edit_image.py +++ b/src/chock/gate/edit_image.py @@ -18,6 +18,7 @@ _CRLF = "\r\n" #: Copilot CLI's `create` carries the new file as `file_text`, a key no adapter reads as content. _CREATE_TEXT = "file_text" +_CREATE_KEYS = ("content", _CREATE_TEXT) def _edit_call_input(event): @@ -86,12 +87,16 @@ def edited_text(event, root=None): def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, "content", None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): diff --git a/src/chock/gate/guard_runner.py b/src/chock/gate/guard_runner.py index 0e2a77c6..fb0ca669 100644 --- a/src/chock/gate/guard_runner.py +++ b/src/chock/gate/guard_runner.py @@ -253,7 +253,7 @@ def evaluate(argv: list[str], command: str, tool: str = "", *, can_ask: bool | N verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST["outcome"] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f"Blocked by chock policy: {guard.stem}") + return (VERDICT_DENY, f"Blocked by chock policy: {guard.stem}: {message}") if verdict == GUARD_ASKED: # The guard's own line is the prompt: it names what needs confirming, never the # raw command, which would put a live tool argument into the client's UI. diff --git a/src/chock/gate/patch_image.py b/src/chock/gate/patch_image.py index 9a65005a..296ac893 100644 --- a/src/chock/gate/patch_image.py +++ b/src/chock/gate/patch_image.py @@ -88,7 +88,8 @@ def _patch_not_empty(state, text): if last is None or last["op"] != "update": return if not last["body"] and state["mode"] == "update": - _patch_refuse(state["number"], text, f"update of {last['path']!r} has no hunk") + target = last["path"] + _patch_refuse(state["number"], text, f"update of {target!r} has no hunk") if last["body"] and not (last["body"][-1]["old"] or last["body"][-1]["new"]): _patch_refuse(state["number"], text, "a hunk has no lines") @@ -229,7 +230,11 @@ def _read(root, path): def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -241,7 +246,7 @@ def patched_files(event, root=None): disk[key] = files[key] = "".join(line + "\n" for line in body) elif op == "delete": disk[key] = None - files.pop(key, None) + files[key] = "" else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -249,7 +254,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = "" disk[target] = files[target] = after return files diff --git a/src/chock/gate/repo_names.py b/src/chock/gate/repo_names.py new file mode 100644 index 00000000..6aa929e7 --- /dev/null +++ b/src/chock/gate/repo_names.py @@ -0,0 +1,64 @@ +"""Every repo-relative name a write is judged under: as written, through symlinks, and in the case the disk stores. + +Extracted into the vendored per-agent runtime beside write_gate, so it is stdlib-only and may not import chock. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path, PurePosixPath, PureWindowsPath + +from .outside_repo import is_outside +from .workspace_root import _DRIVE_COLON, _folded + +#: Filesystems that fold case: a differently-cased spelling of a protected path writes the protected file. +_CASE_BLIND = sys.platform in ("darwin", "win32") + + +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 on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = Path(root), [] + for part in PurePosixPath(name).parts: + try: + stored = os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return PurePosixPath(*spelled).as_posix() + + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """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,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) diff --git a/src/chock/gate/runner/context.py b/src/chock/gate/runner/context.py index f6d88b80..70f57bbf 100644 --- a/src/chock/gate/runner/context.py +++ b/src/chock/gate/runner/context.py @@ -67,14 +67,18 @@ def __init__( #: 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: + def is_own(self, path: str) -> bool: + """Whether `path` is in this policy's own source or compiled folder.""" + return bool(self.own) and path.startswith(self.own) + + def in_scope(self, path: str, *, judge_own: bool = False) -> bool: """Whether this policy may judge this file at all. fnmatch semantics, so `*` crosses `/` and `.github/workflows/*` covers nested files. 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): + if not judge_own and self.is_own(path): return False if _is_outside(path): return True @@ -124,9 +128,9 @@ def rev_exists(self, ref: str) -> bool: """True when `ref` resolves to a commit. Used to fail CI closed on a missing base.""" return bool(self._git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").strip()) - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: paths = self._listing("diff", *self._range(), "--name-only", f"--diff-filter={diff_filter}") - return [path for path in paths if self.in_scope(path)] + return [path for path in paths if self.in_scope(path, judge_own=judge_own)] def _diff_text(self, path: str) -> str: """`path`'s `-U0` diff as git writes it, unconverted; raises DiffError when git fails.""" @@ -229,11 +233,11 @@ def __init__( self._added = dict(added or {}) self._stop = stop - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: """Every write is a present file, so a filter asking only for deletions finds none.""" if not set(diff_filter) & set("ACMRT"): return [] - return [path for path in self._writes if self.in_scope(path)] + return [path for path in self._writes if self.in_scope(path, judge_own=judge_own)] def staged_blob(self, path: str) -> str: return self._writes.get(path, "") diff --git a/src/chock/gate/runner/journal.py b/src/chock/gate/runner/journal.py index 60de978f..d4138c97 100644 --- a/src/chock/gate/runner/journal.py +++ b/src/chock/gate/runner/journal.py @@ -86,7 +86,8 @@ def log_settings_read(path): problems.append(f"{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576") unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(f"{path}: unknown key(s) {', '.join(map(str, unknown))}; only level and max_bytes are read") + names = ", ".join(map(str, unknown)) + problems.append(f"{path}: unknown key(s) {names}; only level and max_bytes are read") return found, problems diff --git a/src/chock/gate/runner/kinds.py b/src/chock/gate/runner/kinds.py index 8b1c7dd9..1ea8aeb8 100644 --- a/src/chock/gate/runner/kinds.py +++ b/src/chock/gate/runner/kinds.py @@ -55,14 +55,15 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul pragma = _waiver_pattern(params, event) agent = event in HEAD_WAIVER_EVENTS whole = params.get("scan", "added_lines") == "staged_blob" - paths = ctx.staged_paths(params.get("diff_filter", "ACMRT")) + paths = ctx.staged_paths(params.get("diff_filter", "ACMRT"), judge_own=True) judge = pattern_judge() try: forbidden = sorted(_hits(judge, [(_path_pattern(params, ctx), paths)])[0]) heads = {path: ctx.committed_blob(path) if agent else "" for path in paths} blobs = [heads[paths[index]] if agent else ctx.staged_blob(paths[index]) for index in forbidden] spans, texts = _lines( - paths, lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path) + [path for path in paths if not ctx.is_own(path)], + lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path), ) jobs = [(pragma, blobs), (pragma, texts), (params["content_pattern"], texts)] blob_waivers, line_waivers, hits = _hits(judge, jobs) @@ -73,7 +74,7 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul if index in forbidden and forbidden.index(index) not in blob_waivers: matches.append(f"{path}: forbidden path") head = frozenset(heads[path].splitlines()) if agent else None - if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans[path]): + if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans.get(path, ())): matches.append(f"{path}: content pattern") return GateResult(allowed=not matches, matches=matches) diff --git a/src/chock/gate/runtime_shared.py b/src/chock/gate/runtime_shared.py index ea2597c1..6a2aca82 100644 --- a/src/chock/gate/runtime_shared.py +++ b/src/chock/gate/runtime_shared.py @@ -20,6 +20,7 @@ outside_repo, patch_apply, patch_image, + repo_names, repo_stop, session_log, stop_reentry, @@ -27,6 +28,7 @@ tool_call_gate, unreadable_stop, workspace_root, + worktree_read, write_gate, ) from .runner import chain, halt, journal @@ -148,6 +150,8 @@ def _extract(module) -> str: stop_reentry, unreadable_stop, workspace_root, + repo_names, + worktree_read, repo_stop, stop_roots, write_gate, diff --git a/src/chock/gate/stop_roots.py b/src/chock/gate/stop_roots.py index 0c5fc294..320cbe03 100644 --- a/src/chock/gate/stop_roots.py +++ b/src/chock/gate/stop_roots.py @@ -13,19 +13,31 @@ _GIT_DIR = ".git" -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = Path(named) + return (repository_of(path) or path) if path.is_absolute() else path + + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, "cwd", None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] diff --git a/src/chock/gate/worktree_read.py b/src/chock/gate/worktree_read.py new file mode 100644 index 00000000..6ba2baad --- /dev/null +++ b/src/chock/gate/worktree_read.py @@ -0,0 +1,45 @@ +"""Reading a changed worktree file without following a link or waiting on a pipe. + +Extracted into the vendored per-agent runtime beside write_gate, so it is stdlib-only and may not import chock. +""" + +from __future__ import annotations + +import os + +from .stop_reentry import STOP_MODE_DIR, STOP_MODE_FILE, STOP_MODE_MASK + +#: st_mode type bits (`stat` is not importable in the vendored runtime): a link is read as git stores it. +_MODE_LINK = 0o120000 + + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return os.readlink(target), None + if kind != STOP_MODE_FILE: + return None, f"{target} is neither a file nor a link" + fd = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(fd, "rb") as handle: + return handle.read().decode("utf-8"), None + except UnicodeDecodeError: + return None, None + except OSError as exc: + return None, f"{target} could not be read ({exc})" + + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return "", None + except OSError as exc: + return None, f"{target} could not be examined ({exc})" + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index e26ce1af..069a2663 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -12,10 +12,9 @@ from __future__ import annotations import json -import os import subprocess import sys -from pathlib import Path, PurePosixPath, PureWindowsPath +from pathlib import Path from .budget import allowed_seconds, engine_deadline, time_left from .decision_log import GATE_LAST @@ -24,10 +23,12 @@ from .gate_outcome import GATE_ERRORED, gate_decision, runner_outcome from .outside_repo import judged_files, outside_globs from .patch_image import patch_added, patched_files +from .repo_names import repo_paths from .session_log import session_for from .stop_reentry import settle_stop -from .stop_roots import per_root, stop_roots -from .workspace_root import _DRIVE_COLON, _folded, workspace_root +from .stop_roots import judged_root, per_root, stop_roots +from .workspace_root import workspace_root +from .worktree_read import worktree_text GATE_FLAG = "--gate" #: A plugin's Stop hook shares the pre-tool `gate.json`; this flag tells the runtime which event it serves. @@ -98,39 +99,8 @@ def writes_from_event(event, root=None): return {str(path): content} -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, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = engine_deadline() if deadline is None else deadline, [] if why is None else why argv = [_GIT, "-C", str(repo_root), "status", "--porcelain=v1", "--untracked-files=all", "-z"] try: @@ -156,8 +126,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = field[:2], field[3:] if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, "")) + if path: paths.append(path) return paths @@ -174,10 +144,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = Path(repo_root, path).read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f"chock: {unsafe}, worktree not checked\n") + return None + if text is not None: + writes[path] = text return writes @@ -233,7 +207,7 @@ def root_for(gate): def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -241,7 +215,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, "cwd", None) or workspace_root(event) - return Path(cwd) if cwd else Path.cwd() + return judged_root(cwd) if cwd else Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): diff --git a/src/chock/gateway/gates.py b/src/chock/gateway/gates.py index 50d2f9fe..668554db 100644 --- a/src/chock/gateway/gates.py +++ b/src/chock/gateway/gates.py @@ -12,6 +12,7 @@ from chock.gate.outside_repo import platform_case_blind from chock.gate.session_log import SESSION_PATH_KEYS +from chock.gate.write_gate import repo_paths GATEWAY_DIRNAME = "mcp-gateway" GATE_FILENAME = "gateway-gate.json" @@ -102,21 +103,22 @@ def _blind(spec: dict[str, Any]) -> bool: def _call_paths(spec: dict[str, Any], arguments: Any) -> list[str]: - """Write targets the call names, repo-relative: the same argument keys the session log reads.""" + """Write targets the call names, under every spelling `write_gate.repo_paths` judges (folded, resolved, cased).""" if not isinstance(arguments, dict): return [] - root = str(spec.get("repo_root") or "").replace("\\", "/").rstrip("/") + root = str(spec.get("repo_root") or "") or None paths: list[str] = [] for key in SESSION_PATH_KEYS: value = arguments.get(key) - if not isinstance(value, str) or not value: - continue - path = value.replace("\\", "/") - prefix = root + "/" - if root and (path.casefold().startswith(prefix.casefold()) if _blind(spec) else path.startswith(prefix)): - path = path[len(root) + 1 :] - paths.append(path.removeprefix("./")) - return paths + if isinstance(value, str) and value: + paths.extend(repo_paths(value.replace("\\", "/"), root, case_blind=_blind(spec))) + return list(dict.fromkeys(paths)) + + +def _in_scope(path: str, scope: list[str], *, blind: bool) -> bool: + """A path is in a gate's bound when a glob matches it, or when it cannot be placed inside the repository.""" + fold = str.casefold if blind else str + return ".." in path.split("/") or any(fnmatch.fnmatchcase(fold(path), fold(g)) for g in scope) def _eval_content_regex(spec: dict[str, Any], arguments: Any) -> str | None: @@ -129,8 +131,8 @@ def _eval_content_regex(spec: dict[str, Any], arguments: Any) -> str | None: paths = _call_paths(spec, arguments) if scope: # A path-bounded gate judges only what it can place inside its bound. - fold = str.casefold if _blind(spec) else str - paths = [p for p in paths if any(fnmatch.fnmatchcase(fold(p), fold(g)) for g in scope)] + blind = _blind(spec) + paths = [p for p in paths if _in_scope(p, scope, blind=blind)] if not paths: return None forbidden = params.get("forbidden_path_regex") diff --git a/src/chock/guardrails/chock_bundle_log.py b/src/chock/guardrails/chock_bundle_log.py index eae42724..89028234 100644 --- a/src/chock/guardrails/chock_bundle_log.py +++ b/src/chock/guardrails/chock_bundle_log.py @@ -1,7 +1,8 @@ -"""The toggle wrapper's log line. Stdlib only: shipped beside the wrapper, with the gate's journal, in every merged plugin.""" +"""The toggle wrapper's log line and HEAD lookup. Stdlib only: shipped beside the wrapper in every merged plugin.""" from __future__ import annotations +import subprocess from pathlib import Path try: # beside the wrapper in a merged plugin; inside the chock package everywhere else @@ -10,6 +11,25 @@ from chock.gate.runner.journal import LOG_INFO, LOG_OFF, log_append, log_settings _LOG_PARTS = ("log", "gate-events.jsonl") +_GIT_SECONDS = 5 + + +def head_text(root: Path, name: str) -> str | None: + """`name` as HEAD of the repository at `root` holds it; None when HEAD has no such file or git fails.""" + argv = ["git", "-C", str(root), "show", f"HEAD:{name}"] + try: + proc = subprocess.run(argv, capture_output=True, text=True, timeout=_GIT_SECONDS, check=False) # noqa: S603 + except (OSError, subprocess.SubprocessError): + return None + return proc.stdout if proc.returncode == 0 else None + + +def vouched(toggles: dict[str, dict[str, str]], head: dict[str, dict[str, str]]) -> tuple[dict, list[str]]: + """(`toggles` keeping only the offs `head` also has, the `bundle/member` offs it dropped).""" + dropped = [ + f"{b}/{m}" for b, ms in toggles.items() for m, s in ms.items() if s == "off" and head.get(b, {}).get(m) != "off" + ] + return {b: {m: s for m, s in ms.items() if f"{b}/{m}" not in dropped} for b, ms in toggles.items()}, dropped def _repo(start: Path) -> Path | None: diff --git a/src/chock/guardrails/cli.py b/src/chock/guardrails/cli.py index 93747e53..74e9d3f5 100644 --- a/src/chock/guardrails/cli.py +++ b/src/chock/guardrails/cli.py @@ -141,7 +141,9 @@ def switch(policy_id: str, state: str, bundle: str | None, scope: str | None) -> if scope == toggle.USER and root is not None and os.path.lexists(root / toggle.FILENAME): print(f"note: {root / toggle.FILENAME} governs this repository; the user file applies only outside it.") if scope == toggle.REPO and state == toggle.OFF: - print("Commit it: `chock check --only baseline` reports a policy switched off against the base as a loosening.") + print("Commit it: a policy is off in a repository only once HEAD's copy says so, and") + print("`chock check --only baseline` reports a policy switched off against the base as a loosening.") + print(f"This applies only once {toggle.FILENAME} is committed, and from the next session.") return 0 diff --git a/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py index 485d5b4e..d594f460 100644 --- a/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py +++ b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py @@ -7,15 +7,14 @@ from __future__ import annotations -import fnmatch import itertools -import os -import posixpath import re import shlex from collections.abc import Callable from pathlib import Path +from .names import base_is, fold, real_path + NAME = "guardrails.json" #: The sha256 `chock bundle` records of the file it wrote, under `.chock/state/`: protected as the file is. RECORD = "guardrails.sha256" @@ -75,88 +74,48 @@ FOLDER_WRITERS = frozenset({"mv", "cp", "ln", "rsync", "install", "tar", "unzip", "ditto", "move-item", "copy-item"}) _SEPARATORS = frozenset({";", "&", "&&", "|", "||", "|&", "(", ")", ";;"}) -_GLOB = re.compile(r"[*?\[]") -_BRACES = re.compile(r"\{([^{}]*)\}") _ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\+?=") _EXE = ".exe" -def fold(path: str) -> str: - """`path` lower-cased, backslashes read as slashes, `//`, `/./` and `..` folded.""" - text = path.replace("\\", "/").lower() - if not text: - return text - return posixpath.normpath(re.sub(r"/{2,}", "/", text)) - - -def _braces(text: str) -> list[str]: - """Every spelling a brace list stands for (`{a,b}`), at most a few dozen.""" - found, todo = [], [text] - while todo and len(found) < 64: # noqa: PLR2004 -- a bound on expansion, not a rule - item = todo.pop() - match = _BRACES.search(item) - if match is None: - found.append(item) - continue - todo += [item[: match.start()] + part + item[match.end() :] for part in match.group(1).split(",")] - return found - - -def _base_is(text: str, wanted: str) -> bool: - """Whether the last segment of a folded path is `wanted`, or a glob or brace list that can name it.""" - for spelling in _braces(text): - base = spelling.rstrip("/").rsplit("/", 1)[-1] - if base == wanted or (_GLOB.search(base) and fnmatch.fnmatchcase(wanted, base)): - return True - return False - - -def _real(token: str, cwd: Path) -> str: - """The folded path `token` names once `~` is expanded and links are followed, from `cwd`.""" - try: - return fold(os.path.realpath(os.path.join(cwd, os.path.expanduser(token)))) - except (OSError, ValueError): - return "" - - def names_toggle(token: str, cwd: Path) -> bool: """Whether a shell word can name a toggle file or its record: as written, folded, or through a link.""" - texts = (fold(token), _real(token, cwd)) - return any(n in t for t in texts for n in NAMES) or any(_base_is(t, s) for t in texts for s in SAMPLES) + texts = (fold(token), real_path(token, cwd)) + return any(n in t for t in texts for n in NAMES) or any(base_is(t, s) for t in texts for s in SAMPLES) def names_folder(token: str, cwd: Path) -> bool: """Whether a shell word names the `.chock` folder itself.""" - return _base_is(fold(token), FOLDER) or _base_is(_real(token, cwd), FOLDER) + return base_is(fold(token), FOLDER) or base_is(real_path(token, cwd), FOLDER) def names_marker(token: str, cwd: Path) -> bool: """Whether a shell word can name an install marker: as written, folded, or through a link.""" folded = fold(token) - return MARKER in folded or _base_is(folded, MARKER) or _base_is(_real(token, cwd), MARKER) + return MARKER in folded or base_is(folded, MARKER) or base_is(real_path(token, cwd), MARKER) def names_log(token: str, cwd: Path) -> bool: """Whether a shell word can name a log settings file: as written, folded, or through a link.""" folded = fold(token) - return LOG_SETTINGS in folded or _base_is(folded, LOG_SETTINGS) or _base_is(_real(token, cwd), LOG_SETTINGS) + return LOG_SETTINGS in folded or base_is(folded, LOG_SETTINGS) or base_is(real_path(token, cwd), LOG_SETTINGS) def is_log_path(path: str, root: Path) -> bool: """Whether a written path is a log settings file (in any `.chock` folder), as written or through links.""" - return any(("/" + folded).endswith(f"/{FOLDER}/{LOG_SETTINGS}") for folded in (fold(path), _real(path, root))) + return any(("/" + folded).endswith(f"/{FOLDER}/{LOG_SETTINGS}") for folded in (fold(path), real_path(path, root))) def is_marker_path(path: str, root: Path) -> bool: """Whether a written path is an install marker, as written or through links.""" - return any(_base_is(folded, MARKER) for folded in (fold(path), _real(path, root))) + return any(base_is(folded, MARKER) for folded in (fold(path), real_path(path, root))) def is_toggle_path(path: str, root: Path) -> bool: """Whether a written path is a toggle file or its record (anywhere), as written or through links.""" return any( folded == want or folded.endswith("/" + want) or f"/{FOLDER}/state/{SESSION}" in "/" + folded - for folded in (fold(path), _real(path, root)) + for folded in (fold(path), real_path(path, root)) for want in PROTECTED ) @@ -256,6 +215,23 @@ def _segment_refused(words: list[str], cwd: Path) -> bool: return _writes(words, cwd, names_toggle) +#: Text that a name fragment held in a variable or an escape could complete into a protected path. +_EXPANDED = re.compile(r"\$|`") +#: Fragments a protected name can be assembled from; a write that expands a variable next to one is refused. +_FRAGMENTS = ("guard", "rails", "sha256") + + +def _expanded_write(words: list[str], command: str) -> bool: + """Whether a segment writes through a path word built by expansion, on a line that mentions a protected fragment.""" + name, _ = _command(words) + plain, targets = _split_redirects(words) + written = targets + ([] if name in READERS else plain) + if not any(_EXPANDED.search(word) for word in written): + return False + lowered = fold(command) + return any(fragment in lowered for fragment in _FRAGMENTS) + + def _hidden(command: str, segments: list[list[str]], wanted: list[str]) -> bool: """Whether a line that names every `wanted` text also runs other text it could hide them in.""" lowered = fold(command) @@ -277,7 +253,7 @@ def install_refused(command: str, cwd: Path) -> bool: def refuses(command: str, cwd: Path) -> bool: """Whether an agent's shell command could write a toggle file or switch a member.""" segments = _segments(_tokens(command)) - if any(_segment_refused(words, cwd) for words in segments): + if any(_segment_refused(words, cwd) or _expanded_write(words, command) for words in segments): return True named = any(name in fold(command) for name in NAMES) or any(names_toggle(t, cwd) for s in segments for t in s) if not named: diff --git a/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py new file mode 100644 index 00000000..4294cc33 --- /dev/null +++ b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py @@ -0,0 +1,50 @@ +"""Path folding for the self-protection guard: case, slashes, dots, braces, globs and links (stdlib only).""" + +from __future__ import annotations + +import fnmatch +import os +import posixpath +import re +from pathlib import Path + +_GLOB = re.compile(r"[*?\[]") +_BRACES = re.compile(r"\{([^{}]*)\}") + + +def fold(path: str) -> str: + """`path` lower-cased, backslashes read as slashes, `//`, `/./` and `..` folded.""" + text = path.replace("\\", "/").lower() + if not text: + return text + return posixpath.normpath(re.sub(r"/{2,}", "/", text)) + + +def braces(text: str) -> list[str]: + """Every spelling a brace list stands for (`{a,b}`), at most a few dozen.""" + found, todo = [], [text] + while todo and len(found) < 64: # noqa: PLR2004 -- a bound on expansion, not a rule + item = todo.pop() + match = _BRACES.search(item) + if match is None: + found.append(item) + continue + todo += [item[: match.start()] + part + item[match.end() :] for part in match.group(1).split(",")] + return found + + +def base_is(text: str, wanted: str) -> bool: + """Whether the last segment of a folded path is `wanted`, or a glob or brace list that can name it.""" + for spelling in braces(text): + base = spelling.rstrip("/").rsplit("/", 1)[-1] + if base == wanted or (_GLOB.search(base) and fnmatch.fnmatchcase(wanted, base)): + return True + return False + + +def real_path(token: str, cwd: Path) -> str: + """The folded path `token` names once `~` is expanded and links are followed, from `cwd`.""" + try: + return fold(os.path.realpath(os.path.join(cwd, os.path.expanduser(token)))) + except (OSError, ValueError): + return "" diff --git a/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl b/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl index 063fa813..6649a199 100644 --- a/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl +++ b/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl @@ -11,7 +11,10 @@ pull request that turns one off is reviewed as a loosening) or `~/.chock/guardra A repository's own file governs it; the user file counts only where the repository has none. A switch applies from the next session: a session honours an `off` only if its file and record were -already in place when the session started, and the session start lists every member that is off. This is +already in place when the session started, and the session start lists every member that is off. +In a repository an `off` also needs a commit: it applies only once `.chock/guardrails.json` is committed +(HEAD's copy must hold it too), and from the next session. Until then the member stays on, and +`chock bundle status` and the session start say so. The user file has no commit rule. This is not a security boundary against an agent running as the person. ## Members diff --git a/src/chock/guardrails/toggle.py b/src/chock/guardrails/toggle.py index e37c7443..1fb521d7 100644 --- a/src/chock/guardrails/toggle.py +++ b/src/chock/guardrails/toggle.py @@ -1,6 +1,7 @@ """The guardrails toggle file and its lookup. Stdlib only: shipped verbatim in every merged plugin as its hook wrapper. `.chock/guardrails.json` (repo; else `~/.chock/guardrails.json`) is all on when absent, invalid or unlike its record. +In a repository a member is off only where HEAD's copy says so too: the record is as rewritable as the file. `--bundle --member [args...]`: off runs the adapter with no args. `--verify `: a Stop is refused while a toggle file in its `cwd` or any workspace root loosened or changed during the session. `--session-start `: binds the session to the files it sees and lists every member that is off. @@ -23,10 +24,10 @@ try: # beside this wrapper in a merged plugin; inside the chock package everywhere else import chock_bundle_state as ts - from chock_bundle_log import log_off + from chock_bundle_log import head_text, log_off, vouched except ImportError: from chock.guardrails import toggle_state as ts - from chock.guardrails.chock_bundle_log import log_off + from chock.guardrails.chock_bundle_log import head_text, log_off, vouched DELETED, DRIFTED, PROTECT_ID, ToggleError = ts.DELETED, ts.DRIFTED, ts.PROTECT_ID, ts.ToggleError NAME_RE, OFF, ON, STATES, VERSION = ts.NAME_RE, ts.OFF, ts.ON, ts.STATES, ts.VERSION @@ -42,6 +43,7 @@ MOVED = ( "changed during this session, so an `off` in it applies from the next session; every guardrail stays on until then" ) +UNCOMMITTED = "is off in the working file but not in HEAD's, so it stays on until that change is committed" _VERDICT_DENY = "deny" _BLOCKING_EXIT = 2 # the launcher's refusal too: the wrapper speaks no client's dialect _NOTICE = ( @@ -49,6 +51,7 @@ "them):\n{listed}\nA toggle is not a security boundary against an agent running as the person; a change made " "during a session applies from the next one." ) +_HELD = "chock: these guardrails stay on:\n{held}\nEach {uncommitted}." def user_path() -> Path: @@ -71,6 +74,14 @@ def source(start: Path) -> tuple[Path | None, str | None]: return (user, USER) if os.path.lexists(user) else (None, None) +def committed(root: Path) -> dict[str, dict[str, str]]: + """The toggles HEAD holds for the repository at `root`; none when HEAD has no valid copy or git fails.""" + try: + return parse(head_text(root, FILENAME) or "") + except ToggleError: + return {} + + def load(start: Path) -> tuple[Path | None, str | None, dict[str, dict[str, str]], str | None]: """(governing file, scope, toggles, warning). An invalid file yields no toggles: all on, plus the warning.""" path, scope = source(start) @@ -79,9 +90,11 @@ def load(start: Path) -> tuple[Path | None, str | None, dict[str, dict[str, str] if drift(path): return path, scope, {}, f"{path}: {DRIFTED}" try: - return path, scope, read(path), None + toggles = read(path) except ToggleError as exc: return path, scope, {}, f"{path} is ignored, every guardrail stays on: {exc}" + toggles, dropped = vouched(toggles, committed(path.parent.parent)) if scope == REPO else (toggles, []) + return path, scope, toggles, f"{path}: {', '.join(dropped)} {UNCOMMITTED}" if dropped else None def state(toggles: dict[str, dict[str, str]], bundle: str, member: str) -> str: @@ -123,9 +136,18 @@ def moved(starts: list[Path], bound: dict | None) -> list[Path]: return ts.moved(_states(starts), bound) -def switched_off(starts: list[Path]) -> list[str]: - """One line per toggle file in scope that switches a member off: its scope, path and members.""" - return listed_off(list(dict.fromkeys(p for s in starts for p in scopes(s))), source(starts[0])[0], user_path()) +def switched_off(starts: list[Path]) -> tuple[list[str], list[str]]: + """(one line per toggle file in scope that switches a member off, one per repo file whose offs HEAD does not hold). + + A repo file's offs go through `vouched`, as the guard reads them: its scope, path and members. + """ + repo_files = {r / FILENAME for r in map(repo_root, starts) if r} + + def vouch(path: Path, toggles: dict[str, dict[str, str]]) -> tuple[dict, list[str]]: + return vouched(toggles, committed(path.parent.parent)) if path in repo_files else (toggles, []) + + paths = list(dict.fromkeys(p for s in starts for p in scopes(s))) + return listed_off(paths, source(starts[0])[0], user_path(), vouch) def handed_to_user() -> str | None: @@ -211,8 +233,10 @@ def handle(event): if decision is not None or getattr(event, "event", "") != "session_start": return decision bind(wheres, session, fresh=resumed) - listed = switched_off(wheres) - return adapter.Decision.allow(context=_NOTICE.format(listed="\n".join(listed))) if listed else None + listed, held = switched_off(wheres) + notice = [_NOTICE.format(listed="\n".join(listed))] if listed else [] + notice += [_HELD.format(held="\n".join(held), uncommitted=UNCOMMITTED)] if held else [] + return adapter.Decision.allow(context="\n".join(notice)) if notice else None adapter.handle = handle sys.argv = list(argv) diff --git a/src/chock/guardrails/toggle_state.py b/src/chock/guardrails/toggle_state.py index 16403ec0..ac446a01 100644 --- a/src/chock/guardrails/toggle_state.py +++ b/src/chock/guardrails/toggle_state.py @@ -8,6 +8,7 @@ import os import re import time +from collections.abc import Callable from pathlib import Path VERSION = 1 @@ -186,19 +187,34 @@ def has_off(path: Path) -> bool: return False -def listed_off(paths: list[Path], governing: Path | None, user: Path) -> list[str]: - """One line per recorded file in `paths` that switches a member off; `governing` is the file that applies here.""" - lines = [] +def listed_off( + paths: list[Path], + governing: Path | None, + user: Path, + vouch: Callable[[Path, dict], tuple[dict, list[str]]] | None = None, +) -> tuple[list[str], list[str]]: + """(one line per recorded file in `paths` that switches a member off, one per file whose offs `vouch` held back). + + `governing` is the file that applies here; `vouch(path, toggles)` is (the toggles still off, the offs it dropped). + """ + lines, held = [], [] for path in paths: if not os.path.lexists(path) or drift(path): continue try: - off = sorted( - f"{b}/{i}" for b, ms in read(path).items() for i, s in ms.items() if s == OFF and i != PROTECT_ID - ) + toggles = read(path) except ToggleError: continue - if off: - shadowed = "" if governing in (None, path) else " (not applied here: the repository's own file governs)" - lines.append(f"{'user' if path == user else 'repo'} ({path}){shadowed}: {', '.join(off)}") - return lines + dropped = [] + if vouch is not None: + toggles, dropped = vouch(path, toggles) + shadowed = "" if governing in (None, path) else " (not applied here: the repository's own file governs)" + kept = sorted(d for d in dropped if d.split("/", 1)[1] != PROTECT_ID) + for found, into in ((_offs(toggles), lines), (kept, held)): + if found: + into.append(f"{'user' if path == user else 'repo'} ({path}){shadowed}: {', '.join(found)}") + return lines, held + + +def _offs(toggles: dict[str, dict[str, str]]) -> list[str]: + return sorted(f"{b}/{i}" for b, ms in toggles.items() for i, s in ms.items() if s == OFF and i != PROTECT_ID) diff --git a/src/chock/install/claude_plugin.py b/src/chock/install/claude_plugin.py index 243d4c21..d833d081 100644 --- a/src/chock/install/claude_plugin.py +++ b/src/chock/install/claude_plugin.py @@ -36,14 +36,16 @@ def routes(client_id: str, data: dict[str, Any] | None = None) -> list[dict[str, return [r for r in entry["routes"] if r["witness"] in witnessed] -def tokens(dest: Path, name: str, selection_arg: str) -> dict[str, str]: +def tokens(dest: Path, name: str, selection_arg: str, marketplace: str | None = None) -> dict[str, str]: """The Claude build's own folder and marketplace, for the steps another agent runs on it.""" + marketplace = package.marketplace(marketplace) build = settings()["build_client"] return { "__DEST__": str(dest), "__PLUGIN_DIR__": str(place.plugin_dir(build, dest, name)), "__PLUGIN__": name, - "__MARKETPLACE__": package.settings()["marketplace"], + "__MARKETPLACE__": marketplace, + "__MARKETPLACE_FLAG__": "" if marketplace == package.marketplace() else f" --marketplace-name {marketplace}", "__SELECTION__": shlex.quote(selection_arg), "__BUILD_CLIENT__": build, } diff --git a/src/chock/install/cli.py b/src/chock/install/cli.py index 09647611..7c1366a5 100644 --- a/src/chock/install/cli.py +++ b/src/chock/install/cli.py @@ -40,7 +40,11 @@ def _report(client_id: str, labels: list[package.Label], lines: list[str]) -> No def _show(rendered: list[steps.Step], *, applying: bool) -> None: """Print every step; the header says whether `--apply` runs the commands now.""" - print("Running:" if applying and any(s.command for s in rendered) else "Next:") + _print_steps("Running:" if applying and any(s.command for s in rendered) else "Next:", rendered) + + +def _print_steps(header: str, rendered: list[steps.Step]) -> None: + print(header) for step in rendered: if step.command: print(f" {shlex.join(step.command)}") @@ -72,12 +76,13 @@ def _apply(client_id: str, rendered: list[steps.Step]) -> int: @dataclass(frozen=True) class Options: - """What the command line adds to a selection: trusted sources, the selection's folder, and local-code trust.""" + """What the command line adds to a selection: trusted sources, folder, local-code trust and marketplace name.""" allowed: list[str] base: Path | None = None trust_local: dict[str, str] = field(default_factory=dict) interactive: bool = False + marketplace: str = field(default_factory=package.marketplace) def _catalog(chosen: dict[str, Any], allowed: list[str], work: Path) -> Path: @@ -117,7 +122,7 @@ def _members(chosen: dict[str, Any], catalog: Path | None, found: list[local.Loc def install(chosen: dict[str, Any], dest: Path, options: Options) -> tuple[list[package.Label], list[str]]: """Trust, fetch, verify, label and build the selection for its client; returns (labels, warnings).""" client_id, name = chosen["client"], chosen["bundle"]["name"] - place.check_owned(client_id, dest, name) + place.check_owned(client_id, dest, name, options.marketplace) target = place.plugin_dir(client_id, dest, name) with tempfile.TemporaryDirectory(prefix="chock-install-") as tmp: work = Path(tmp) @@ -138,7 +143,7 @@ def install(chosen: dict[str, Any], dest: Path, options: Options) -> tuple[list[ place.migrate_legacy(dest) record = package.marker(chosen, accepted) package.replace(target, package.stage(members, repo_root, record, version, target)) - place.write_marketplace(client_id, dest) + place.write_marketplace(client_id, dest, options.marketplace) return labels, lines @@ -157,6 +162,11 @@ def main(argv: list[str] | None = None) -> int: help="The agent to build for (default: the selection's client)", ) parser.add_argument("--dest", help="Install folder (default: the client's own, e.g. ~/.chock/marketplace/)") + parser.add_argument( + "--marketplace-name", + metavar="NAME", + help=f"Name the build's local marketplace (default: {package.marketplace()}), so a team's own build can use its own", + ) parser.add_argument("--apply", action="store_true", help="Also run the client's own install commands, if any") parser.add_argument( "--allow-source", @@ -175,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: + marketplace = package.marketplace(args.marketplace_name) chosen = selection.load(args.selection) chosen = {**chosen, "client": args.client or chosen["client"]} if chosen["client"] == RETIRED: @@ -189,7 +200,11 @@ def main(argv: list[str] | None = None) -> int: print(f"Catalog source: {chosen['catalog']['source']}") print(f"Catalog ref: {chosen['catalog']['ref']}", flush=True) options = Options( - args.allow_source, selection.folder(args.selection), local.given(args.trust_local), local.interactive() + args.allow_source, + selection.folder(args.selection), + local.given(args.trust_local), + local.interactive(), + marketplace, ) labels, lines = install(chosen, dest, options) except PinError as exc: @@ -206,13 +221,16 @@ def main(argv: list[str] | None = None) -> int: "__DEST__": str(dest), "__PLUGIN_DIR__": str(folder), "__PLUGIN__": name, - "__MARKETPLACE__": package.settings()["marketplace"], + "__MARKETPLACE__": marketplace, } rendered = steps.render(client_id, tokens) _show(rendered, applying=args.apply) + removal = steps.render_remove(client_id, tokens) + if removal: + _print_steps("To remove it:", removal) build = claude_plugin.settings()["build_client"] build_dest = dest if client_id == build else place.default_dest(build).resolve() - for line in claude_plugin.lines(client_id, claude_plugin.tokens(build_dest, name, args.selection)): + for line in claude_plugin.lines(client_id, claude_plugin.tokens(build_dest, name, args.selection, marketplace)): print(line) return _apply(client_id, rendered) if args.apply else 0 diff --git a/src/chock/install/data/claude_plugin.json b/src/chock/install/data/claude_plugin.json index e9b52bdd..5e4829bd 100644 --- a/src/chock/install/data/claude_plugin.json +++ b/src/chock/install/data/claude_plugin.json @@ -3,7 +3,7 @@ "offer_when": {"deny_reply": ["yes", "partial"]}, "heading": "This agent can also run the Claude Code plugin (agentseam loads_claude_plugins, seen in a live run):", "listing": "This build also runs in other agents (agentseam loads_claude_plugins, seen in a live run):", - "build": "Build it: chock install --selection __SELECTION__ --client __BUILD_CLIENT__ (then, in __WHERE__:)", + "build": "Build it: chock install --selection __SELECTION__ --client __BUILD_CLIENT____MARKETPLACE_FLAG__ (then, in __WHERE__:)", "warnings": [ "Install one build per agent. If you install both the agent's own chock plugin and the Claude Code plugin, each one checks every action.", "When another agent runs the Claude Code plugin, chock logs its events as claude_code (~/.chock/log/claude_code/), not under that agent." @@ -45,7 +45,7 @@ "witness": "Copilot CLI: guard (shell command) and gate (file write) refused by preToolUse deny", "label": "guard and gate refused", "steps": [ - {"note": "If `copilot plugin marketplace add` says Marketplace \"__MARKETPLACE__\" already registered, that is chock's Copilot build (every chock build's local marketplace is named __MARKETPLACE__): keep one build and run `copilot plugin marketplace remove __MARKETPLACE__` first."}, + {"note": "If `copilot plugin marketplace add` says Marketplace \"__MARKETPLACE__\" already registered, that is chock's Copilot build (this build's local marketplace is named __MARKETPLACE__, and every chock build made with that name shares it): keep one build and run `copilot plugin marketplace remove __MARKETPLACE__` first."}, {"command": ["copilot", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["copilot", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "It loads live from __PLUGIN_DIR__ (nothing is copied); `copilot plugin uninstall` only disables it."} diff --git a/src/chock/install/data/install.json b/src/chock/install/data/install.json index 792349aa..6292f67d 100644 --- a/src/chock/install/data/install.json +++ b/src/chock/install/data/install.json @@ -35,6 +35,11 @@ {"command": ["claude", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["claude", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "Both commands are safe to repeat. The plugin loads in place from the directory: restart the Claude Code session, or run /reload-plugins in it, to pick up a rebuild."} + ], + "remove": [ + {"command": ["claude", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, + {"note": "Only if no other chock build for this agent is still installed (every chock build made with the marketplace name __MARKETPLACE__ shares it, so removing it takes their registration too): `claude plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} ] }, "copilot": { @@ -51,6 +56,13 @@ {"command": ["copilot", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "VS Code finds plugins the Copilot CLI installed. Without the CLI, add the plugin folder to your VS Code user settings yourself:"}, {"settings": {"chat.pluginLocations": {"__PLUGIN_DIR__": true}}} + ], + "remove": [ + {"note": "`copilot plugin uninstall` only disables the plugin (seen in a live run): it removes nothing from disk and leaves the marketplace registered."}, + {"command": ["copilot", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, + {"note": "Only if no other chock build for this agent is still installed (every chock build made with the marketplace name __MARKETPLACE__ shares it, so removing it takes their registration too): `copilot plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."}, + {"note": "In VS Code, remove the \"__PLUGIN_DIR__\" entry from chat.pluginLocations in your user settings (chock does not edit them)."} ] }, "codex": { @@ -84,6 +96,9 @@ "gate_turn_end_only": false, "steps": [ {"note": "Cursor loads __PLUGIN_DIR__ as a local plugin: run \"Developer: Reload Window\" (or restart Cursor), then check it under Customize. There is no command to run."} + ], + "remove": [ + {"note": "Delete the build folder __PLUGIN_DIR__ (Cursor loads it from there), then run \"Developer: Reload Window\" (or restart Cursor). `chock bundle status` lists a build for as long as its folder exists."} ] }, "devin": { @@ -99,6 +114,10 @@ {"note": "On Windows, `devin plugins install --local` creates a symbolic link, so it needs Developer Mode (Settings > System > For developers) or an elevated shell; otherwise it fails with \"os error 1314: A required privilege is not held by the client\"."}, {"command": ["devin", "plugins", "install", "--local", "__PLUGIN_DIR__"]}, {"note": "The plugin is linked in place: a rebuild applies from the next Devin session."} + ], + "remove": [ + {"command": ["devin", "plugins", "remove", "__PLUGIN__", "--local"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."} ] }, "goose": { @@ -112,6 +131,9 @@ "gate_turn_end_only": false, "steps": [ {"note": "goose discovers __PLUGIN_DIR__ as a user plugin, in every project: start a new goose session. There is no command to run (`goose plugin install` takes a git URL only). To turn it off, add __PLUGIN__ to disabledPlugins in ~/.config/goose/settings.json."} + ], + "remove": [ + {"note": "Delete the build folder __PLUGIN_DIR__ (goose discovers it there), then start a new goose session. `chock bundle status` lists a build for as long as its folder exists."} ] }, "grok": { @@ -126,6 +148,10 @@ "steps": [ {"command": ["grok", "plugin", "install", "__PLUGIN_DIR__", "--trust"]}, {"note": "Plugins are off in grok until enabled, and chock does not edit grok's config: run `grok plugin list` for the name grok gave this plugin (chock expects __PLUGIN__, its folder name; the docs do not say how a plugin with no plugin.json is named), then `grok plugin enable `, or add it to `enabled` under [plugins] in ~/.grok/config.toml, or press Space on it in the Plugins tab. Re-run the install command after a rebuild."} + ], + "remove": [ + {"note": "Run `grok plugin list` for the name grok gave this plugin, then `grok plugin uninstall `."}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."} ] }, "antigravity": { @@ -153,6 +179,10 @@ "gate_turn_end_only": false, "steps": [ {"note": "OpenHands loads a local plugin when it starts: run `openhands --plugin __PLUGIN_DIR__`, or add \"__PLUGIN_DIR__\" to sources under [plugins] in ~/.openhands/config.toml (chock does not edit it). Its hooks name __PLUGIN_DIR__ by absolute path (OpenHands documents no plugin-root variable), so keep that folder where it is; a rebuild applies from the next start."} + ], + "remove": [ + {"note": "Stop passing `--plugin __PLUGIN_DIR__`, and remove \"__PLUGIN_DIR__\" from sources under [plugins] in ~/.openhands/config.toml if you added it (chock does not edit it)."}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."} ] }, "kimi-code": { @@ -182,6 +212,12 @@ {"command": ["droid", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["droid", "plugin", "install", "__PLUGIN__@__MARKETPLACE__", "--scope", "project"]}, {"note": "Droid names a local marketplace from its source; if `droid plugin marketplace list` shows a name other than __MARKETPLACE__ (a --dest folder named otherwise), install __PLUGIN__@. Droid copies the plugin into its cache: after a rebuild run `droid plugin marketplace update` and `droid plugin update __PLUGIN__@__MARKETPLACE__ --scope project`, then start a new session."} + ], + "remove": [ + {"note": "Run these from the root of the project the plugin was installed for."}, + {"command": ["droid", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__", "--scope", "project"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, + {"note": "Only if no other chock build for this agent is still installed (every chock build made with the marketplace name __MARKETPLACE__ shares it, so removing it takes their registration too): `droid plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} ] } } diff --git a/src/chock/install/package.py b/src/chock/install/package.py index 61acc08c..4583b6cd 100644 --- a/src/chock/install/package.py +++ b/src/chock/install/package.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import shutil import tempfile from dataclasses import dataclass @@ -33,6 +34,24 @@ def settings() -> dict[str, Any]: return json.loads(package_data_dir("chock.install", "data").joinpath("install.json").read_text(encoding="utf-8")) +#: Lowercase letters and digits in hyphen-separated words: the part of every client's marketplace naming rule they share. +MARKETPLACE_NAME = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +MARKETPLACE_NAME_MAX = 64 + + +def marketplace(chosen: str | None = None) -> str: + """The local marketplace name a build gets: `chosen` once valid, else the default in data/install.json.""" + if chosen is None: + return str(settings()["marketplace"]) + if not MARKETPLACE_NAME.fullmatch(chosen) or len(chosen) > MARKETPLACE_NAME_MAX: + msg = ( + f"--marketplace-name {chosen!r} is not a valid marketplace name: use lowercase letters, digits and " + f"single hyphens, 1 to {MARKETPLACE_NAME_MAX} characters, not starting or ending with a hyphen" + ) + raise ValueError(msg) + return chosen + + def client(client_id: str) -> dict[str, Any]: """One client's packaging, layout and steps data.""" return settings()["clients"][client_id] diff --git a/src/chock/install/place.py b/src/chock/install/place.py index d9374f9c..e908a6e6 100644 --- a/src/chock/install/place.py +++ b/src/chock/install/place.py @@ -44,8 +44,17 @@ def _marketplace_client(dest: Path) -> str | None: return selection.SCHEMA1_CLIENT if (dest / package.MARKER).is_file() else None -def check_owned(client_id: str, dest: Path, name: str) -> None: - """Refuse a destination chock did not build, or built for another client; never touch either.""" +def _marketplace_name(dest: Path) -> str: + """The marketplace name a chock marketplace at `dest` was built with; the default for one that records none.""" + try: + return str(json.loads((dest / MARKETPLACE_MARKER).read_text(encoding="utf-8"))["marketplace"]) + except (OSError, ValueError, KeyError, TypeError): + return package.marketplace() + + +def check_owned(client_id: str, dest: Path, name: str, marketplace: str | None = None) -> None: + """Refuse a destination chock did not build, built for another client or under another marketplace name.""" + marketplace = package.marketplace(marketplace) if package.client(client_id)["layout"] == MARKETPLACE and dest.exists() and any(dest.iterdir()): found = _marketplace_client(dest) if found is None: @@ -54,6 +63,13 @@ def check_owned(client_id: str, dest: Path, name: str) -> None: if found != client_id: msg = f"{dest} is chock's marketplace for {found}, not {client_id}; choose another --dest" raise FileExistsError(msg) + if _marketplace_name(dest) != marketplace: + msg = ( + f"{dest} is the {_marketplace_name(dest)} marketplace; renaming it to {marketplace} would re-point " + f"every plugin installed from it. Choose another --dest, or build with --marketplace-name " + f"{_marketplace_name(dest)}" + ) + raise FileExistsError(msg) return marker = package.read_marker(plugin_dir(client_id, dest, name)) if marker is not None and marker.get("client") != client_id: @@ -77,12 +93,12 @@ def migrate_legacy(dest: Path) -> None: folder, marker = legacy if folder.is_dir() and package.read_marker(folder) is None: write_generated(folder / package.MARKER, json.dumps(marker, indent=2, sort_keys=True) + "\n") - _write_marketplace_marker(dest, selection.SCHEMA1_CLIENT) + _write_marketplace_marker(dest, selection.SCHEMA1_CLIENT, package.marketplace()) (dest / package.MARKER).unlink() -def _write_marketplace_marker(dest: Path, client_id: str) -> None: - marker = {"client": client_id, "marketplace": package.settings()["marketplace"]} +def _write_marketplace_marker(dest: Path, client_id: str, marketplace: str) -> None: + marker = {"client": client_id, "marketplace": marketplace} write_generated(dest / MARKETPLACE_MARKER, json.dumps(marker, indent=2, sort_keys=True) + "\n") @@ -118,13 +134,13 @@ def _index(spec: dict[str, Any], dest: Path, fmt: str, marketplace: str) -> dict return {"name": marketplace, "plugins": plugins} -def write_marketplace(client_id: str, dest: Path) -> list[Path]: +def write_marketplace(client_id: str, dest: Path, marketplace: str | None = None) -> list[Path]: """The marketplace marker and the client's index files, rebuilt over every plugin now in `dest`.""" + marketplace = package.marketplace(marketplace) data = package.client(client_id) if data["layout"] != MARKETPLACE: return [] - marketplace = package.settings()["marketplace"] - _write_marketplace_marker(dest, client_id) + _write_marketplace_marker(dest, client_id, marketplace) written = [] for spec in data["index"]: path = dest / spec["path"] diff --git a/src/chock/install/selection.py b/src/chock/install/selection.py index 9f6cbbeb..aa0fff31 100644 --- a/src/chock/install/selection.py +++ b/src/chock/install/selection.py @@ -15,6 +15,7 @@ from chock import yamlio from chock.validation.loading import load_schema +MIN_QUOTED = 2 SCHEMA = "selection.schema.json" FRAGMENT = "#s=" SCHEMA1, SCHEMA2 = 1, 2 @@ -37,7 +38,10 @@ def _decode(code: str) -> Any: raw = base64.urlsafe_b64decode(code + "=" * (-len(code) % 4)) return json.loads(raw.decode("utf-8")) except (binascii.Error, ValueError) as exc: - msg = f"the selection code is not base64url-encoded JSON ({exc})" + msg = ( + f"the selection code is not base64url-encoded JSON ({exc}); " + "cmd.exe passes quotes through to chock and the code needs none, so paste it bare" + ) raise SelectionError(msg) from exc @@ -49,8 +53,15 @@ def _is_file(path: Path) -> bool: return False +def _unquote(arg: str) -> str: + """`arg` without one surrounding pair of matching single or double quotes, which cmd.exe leaves on.""" + arg = arg.strip() + return arg[1:-1] if len(arg) >= MIN_QUOTED and arg[0] == arg[-1] and arg[0] in "'\"" else arg + + def _raw(arg: str) -> Any: """The selection object `arg` names: a URL fragment, a file, or a bare code, in that order.""" + arg = _unquote(arg) if FRAGMENT in arg: return _decode(arg.split(FRAGMENT, 1)[1]) path = Path(arg).expanduser() @@ -60,7 +71,7 @@ def _raw(arg: str) -> Any: except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc: msg = f"{path}: cannot read the selection ({exc})" raise SelectionError(msg) from exc - return _decode(arg.strip()) + return _decode(arg) def _validator(data: Any) -> jsonschema.Draft7Validator: @@ -104,6 +115,7 @@ def upgrade(data: dict[str, Any]) -> dict[str, Any]: def folder(arg: str) -> Path | None: """The folder of the selection file `arg` names, which local paths resolve against; None for a link or code.""" + arg = _unquote(arg) if FRAGMENT in arg: return None path = Path(arg).expanduser() diff --git a/src/chock/install/steps.py b/src/chock/install/steps.py index 75a5f093..bfbdae60 100644 --- a/src/chock/install/steps.py +++ b/src/chock/install/steps.py @@ -36,6 +36,11 @@ def render(client_id: str, tokens: dict[str, str]) -> list[Step]: return render_steps(package.client(client_id)["steps"], tokens) +def render_remove(client_id: str, tokens: dict[str, str]) -> list[Step]: + """The client's removal steps with every token filled in; empty when chock has no confirmed removal for it.""" + return render_steps(package.client(client_id).get("remove", []), tokens) + + def render_steps(data: list[dict[str, Any]], tokens: dict[str, str]) -> list[Step]: """Step records (`command`, `settings` or `note`) with every token filled in.""" out = [] diff --git a/src/chock/pipe.py b/src/chock/pipe.py index 69ba15a9..b5b06606 100644 --- a/src/chock/pipe.py +++ b/src/chock/pipe.py @@ -23,7 +23,11 @@ def write(self, text: str) -> int: if self.closed_by_reader: return len(text) try: - return self._stream.write(text) + try: + return self._stream.write(text) + except UnicodeEncodeError: + encoding = getattr(self._stream, "encoding", None) or "ascii" + return self._stream.write(text.encode(encoding, "backslashreplace").decode(encoding)) except OSError as exc: if not _is_broken_pipe(exc): raise diff --git a/tests/fixtures/plugin_targets/antigravity.json b/tests/fixtures/plugin_targets/antigravity.json index 421768ad..6c84d482 100644 --- a/tests/fixtures/plugin_targets/antigravity.json +++ b/tests/fixtures/plugin_targets/antigravity.json @@ -7,11 +7,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/droid.json b/tests/fixtures/plugin_targets/droid.json index d66741a1..956e73d0 100644 --- a/tests/fixtures/plugin_targets/droid.json +++ b/tests/fixtures/plugin_targets/droid.json @@ -6,11 +6,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/goose.json b/tests/fixtures/plugin_targets/goose.json index 09c34420..a679be7c 100644 --- a/tests/fixtures/plugin_targets/goose.json +++ b/tests/fixtures/plugin_targets/goose.json @@ -5,11 +5,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/grok.json b/tests/fixtures/plugin_targets/grok.json index 58bd5b86..6d3a612f 100644 --- a/tests/fixtures/plugin_targets/grok.json +++ b/tests/fixtures/plugin_targets/grok.json @@ -5,6 +5,7 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/kimi.json b/tests/fixtures/plugin_targets/kimi.json index e4087b62..4005b924 100644 --- a/tests/fixtures/plugin_targets/kimi.json +++ b/tests/fixtures/plugin_targets/kimi.json @@ -5,11 +5,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/openhands.json b/tests/fixtures/plugin_targets/openhands.json index 507425e8..c1f8658f 100644 --- a/tests/fixtures/plugin_targets/openhands.json +++ b/tests/fixtures/plugin_targets/openhands.json @@ -6,11 +6,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/runtime_goldens/_chock_runtime.py b/tests/fixtures/runtime_goldens/_chock_runtime.py index 59e2f682..dbde6c72 100644 --- a/tests/fixtures/runtime_goldens/_chock_runtime.py +++ b/tests/fixtures/runtime_goldens/_chock_runtime.py @@ -286,7 +286,8 @@ def log_settings_read(path): problems.append(f'{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576') unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(''.join([format(path), ': unknown key(s) ', format(', '.join(map(str, unknown))), '; only level and max_bytes are read'])) + names = ', '.join(map(str, unknown)) + problems.append(f'{path}: unknown key(s) {names}; only level and max_bytes are read') return (found, problems) def log_settings_paths(repo_root): @@ -858,7 +859,7 @@ def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | Non verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST['outcome'] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}: {message}') 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: @@ -949,7 +950,8 @@ def dlog_target(event, repo): tool = str(getattr(event, 'tool', None) or '') mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(''.join([format(mcp['server']), '/', format(mcp['tool'])]))[:_DLOG_MCP_CAP] + server, name = (mcp['server'], mcp['tool']) + return log_redact(f'{server}/{name}')[:_DLOG_MCP_CAP] command, path = (getattr(event, 'command', None), getattr(event, 'path', None)) if command: return dlog_command_print(command) @@ -1075,8 +1077,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == 'guard' and 'outcome' in GUARD_LAST: - return ''.join(['guard-', format(GUARD_LAST['outcome'])]) - return source if source == 'fault' else ''.join([format(source), '-', format(_DLOG_SPOKEN.get(outcome, 'allow'))]) + guarded = GUARD_LAST['outcome'] + return f'guard-{guarded}' + if source == 'fault': + return source + spoken = _DLOG_SPOKEN.get(outcome, 'allow') + return f'{source}-{spoken}' def _dlog_paths(route, runtime, agent, event): """(log file, repository whose settings apply) for this call.""" @@ -1149,6 +1155,8 @@ def log_heartbeat(event, manifests): _CREATE_TEXT = 'file_text' +_CREATE_KEYS = ('content', _CREATE_TEXT) + def _edit_call_input(event): raw = getattr(event, 'raw', None) tool_input = raw.get('tool_input') if isinstance(raw, dict) else None @@ -1210,12 +1218,16 @@ def edited_text(event, root=None): return text def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, 'content', None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" @@ -1386,7 +1398,8 @@ def _patch_not_empty(state, text): if last is None or last['op'] != 'update': return if not last['body'] and state['mode'] == 'update': - _patch_refuse(state['number'], text, ''.join(['update of ', format(repr(last['path'])), ' has no hunk'])) + target = last['path'] + _patch_refuse(state['number'], text, f'update of {target!r} has no hunk') if last['body'] and (not (last['body'][-1]['old'] or last['body'][-1]['new'])): _patch_refuse(state['number'], text, 'a hunk has no lines') @@ -1516,7 +1529,11 @@ def _read(root, path): return None def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -1528,7 +1545,7 @@ def patched_files(event, root=None): disk[key] = files[key] = ''.join((line + '\n' for line in body)) elif op == 'delete': disk[key] = None - files.pop(key, None) + files[key] = '' else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -1536,7 +1553,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = '' disk[target] = files[target] = after return files @@ -2168,6 +2185,85 @@ def workspace_root(event): path = getattr(event, 'path', None) return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) +_CASE_BLIND = sys.platform in ('darwin', 'win32') + +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 on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = (_chock_Path(root), []) + for part in _chock_PurePosixPath(name).parts: + try: + stored = _chock_os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return _chock_PurePosixPath(*spelled).as_posix() + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """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,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) + +_MODE_LINK = 40960 + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return (_chock_os.readlink(target), None) + if kind != STOP_MODE_FILE: + return (None, f'{target} is neither a file nor a link') + fd = _chock_os.open(target, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'rb') as handle: + return (handle.read().decode('utf-8'), None) + except UnicodeDecodeError: + return (None, None) + except OSError as exc: + return (None, f'{target} could not be read ({exc})') + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = _chock_os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return ('', None) + except OSError as exc: + return (None, f'{target} could not be examined ({exc})') + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) + _REPO_STOP_PROJECT_ENV = ('CLAUDE_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'OPENHANDS_PROJECT_DIR') _REPO_STOP_PROJECT_KEYS = ('workspacePaths',) @@ -2294,18 +2390,29 @@ def repo_stop_refusal(event): _GIT_DIR = '.git' -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = _chock_Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = _chock_Path(named) + return repository_of(path) or path if path.is_absolute() else path + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, _chock_Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] def per_root(said): @@ -2383,37 +2490,8 @@ def writes_from_event(event, root=None): return {} return {str(path): content} -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, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] try: @@ -2437,8 +2515,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = (field[:2], field[3:]) if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, '')) + if path: paths.append(path) return paths @@ -2454,10 +2532,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(_chock_Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f'chock: {unsafe}, worktree not checked\n') + return None + if text is not None: + writes[path] = text return writes def run_gate(gate, writes, event, run_in, deadline): @@ -2496,7 +2578,7 @@ def root_for(gate): return parents[_GATE_DEPTH_TO_CHOCK].parent def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -2504,7 +2586,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, 'cwd', None) or workspace_root(event) - return _chock_Path(cwd) if cwd else _chock_Path.cwd() + return judged_root(cwd) if cwd else _chock_Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index e87bed04..e0ecd1cb 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 34438330..f8363244 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 9b699d09..43dd8bda 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 3036ec85..3313be92 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index ba58c7be..4ced8037 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py index 3108a2a5..48e574cd 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 2482da42..53676caa 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py index ac3192ab..b8786bd3 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index d22df270..74f8315b 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py index c1baf62c..d1a66dfe 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py index 50673761..9720d7eb 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 46a2965f..9621ba33 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 1b7bdeec..9a0e6aa1 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index c2523375..c719df2e 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 = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/guardrails_support.py b/tests/guardrails_support.py index 1935d601..9f6038c9 100644 --- a/tests/guardrails_support.py +++ b/tests/guardrails_support.py @@ -4,6 +4,7 @@ import json import os +import subprocess from pathlib import Path from typing import Any @@ -78,12 +79,30 @@ def any_refused(commands: list[str], plugin: Path, repo: Path, payload: dict, ho return any(refused(p) for p in outcomes(commands, plugin, repo, payload, home)) -def set_toggles(where: Path, bundles: dict[str, dict[str, str]] | str, *, recorded: bool = True) -> Path: - """Write a toggle file at `where/.chock/guardrails.json` (a string as is), recorded as `chock bundle` records it.""" +def commit_toggles(where: Path) -> None: + """Commit the toggle file in the repository at `where`, as a person does: a member is off only once HEAD says so.""" + git = ["git", "-C", str(where), "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"] + if subprocess.run([*git, "rev-parse", "--git-dir"], capture_output=True, check=False).returncode: + return + subprocess.run([*git, "add", "-f", TOGGLE], check=True, capture_output=True) + subprocess.run( + [*git, "commit", "-q", "--no-verify", "-m", "toggles", "--allow-empty"], check=True, capture_output=True + ) + + +def set_toggles( + where: Path, bundles: dict[str, dict[str, str]] | str, *, recorded: bool = True, committed: bool = True +) -> Path: + """Write a toggle file at `where/.chock/guardrails.json` (a string as is), recorded as `chock bundle` records it. + + In a git repository it is also committed, unless `committed=False`. + """ path = where / TOGGLE path.parent.mkdir(parents=True, exist_ok=True) text = bundles if isinstance(bundles, str) else json.dumps({"version": 1, "bundles": bundles}) path.write_text(text, encoding="utf-8") if recorded: record(path) + if committed and (where / ".git").exists(): + commit_toggles(where) return path diff --git a/tests/test_gate_write_path.py b/tests/test_gate_write_path.py index f6537ab9..40d26337 100644 --- a/tests/test_gate_write_path.py +++ b/tests/test_gate_write_path.py @@ -196,3 +196,28 @@ def test_unreadable_stdin_judges_nothing_rather_than_guessing(tmp_path: Path, ga init_repo(tmp_path) result = _cli(gate_py, _gate(tmp_path), "pre-tool-use", {}, tmp_path) assert result.returncode == 0 + + +# --- a policy's gate judges the paths of its own folders ------------------------------------------ + + +def _own_gate(tmp_path: Path, params: dict) -> Path: + gate = tmp_path / ".chock" / "compiled" / "protect-it" / "pre-tool-use" / "gate.json" + gate.parent.mkdir(parents=True) + spec = {"kind": "content_regex", "on": ["tool_use"], "action": "block", "message": "no", "params": params} + gate.write_text(json.dumps(spec), encoding="utf-8") + return gate + + +def test_a_path_gate_refuses_a_write_into_its_own_compiled_folder(tmp_path: Path) -> None: + init_repo(tmp_path) + gate = _own_gate(tmp_path, {"content_pattern": "(?!)", "forbidden_path_regex": r"\.chock[/\\]compiled"}) + own = ".chock/compiled/protect-it/pre-tool-use/gate.json" + assert run(gate, "pre-tool-use", None, tmp_path, writes={own: "{}"}) == 1 + + +def test_a_policys_own_folder_is_still_not_scanned_for_its_content_pattern(tmp_path: Path) -> None: + init_repo(tmp_path) + gate = _own_gate(tmp_path, {"content_pattern": r"AKIA[0-9A-Z]{16}"}) + own = ".agents/policies/protect-it/evals/fixture.txt" + assert run(gate, "pre-tool-use", None, tmp_path, writes={own: SECRET}) == 0 diff --git a/tests/test_guard_fail_to_ask.py b/tests/test_guard_fail_to_ask.py index 289e6c24..d846ac45 100644 --- a/tests/test_guard_fail_to_ask.py +++ b/tests/test_guard_fail_to_ask.py @@ -14,7 +14,7 @@ from runtime_support import write_runtime from chock import evidence -from chock.gate import budget, guard_runner, runtime_bundle +from chock.gate import budget, gate_outcome, guard_runner, runtime_bundle @pytest.fixture(scope="module") @@ -211,6 +211,27 @@ def test_every_gated_runtime_is_covered_here() -> None: assert set(PAYLOADS) == set(runtime_bundle.RUNTIME_AGENTS) +@pytest.mark.parametrize("agent", sorted(DENY_ON_THE_WIRE)) +def test_a_deny_carries_the_guards_own_reason_after_the_policy_name(agent: str, tmp_path: Path, runtimes) -> None: + guard = make_guard(tmp_path, "refuse.sh", 'echo "BLOCKED: rm -rf on .git is not allowed" >&2; exit 1') + + verdict = decision(run(runtimes, agent, guard, "rm -rf .git")) + + assert verdict["decision"] == DENY_ON_THE_WIRE[agent] + assert verdict["reason"].startswith("Blocked by chock policy: refuse") + assert "rm -rf on .git is not allowed" in verdict["reason"] + + +def test_a_runner_deny_without_a_reason_names_only_the_policy(tmp_path: Path) -> None: + gate = tmp_path / "pol" / "implementations" / "gate.py" + + assert gate_outcome.gate_decision(gate_outcome.GATE_BLOCKED, "", gate) == ( + guard_runner.VERDICT_DENY, + "Blocked by chock policy: pol", + ) + assert gate_outcome.gate_decision(gate_outcome.GATE_BLOCKED, "why", gate)[1] == "why" + + def test_a_refusal_echoing_agent_text_is_never_taken_for_a_crash(tmp_path: Path) -> None: """The refusal names the command's own words: "syntax error" in them must not turn a block into an ask.""" guard = make_guard(tmp_path, "rm.sh", "echo \"BLOCKED: rm targeting '$CHOCK_RAW_COMMAND'\" >&2; exit 1") diff --git a/tests/test_guardrails_cli.py b/tests/test_guardrails_cli.py index 08a5da2d..c2d76211 100644 --- a/tests/test_guardrails_cli.py +++ b/tests/test_guardrails_cli.py @@ -8,6 +8,7 @@ import pytest from conftest import init_repo +from guardrails_support import commit_toggles from chock.guardrails import cli, toggle from chock.guardrails.plugin import MEMBERS @@ -53,6 +54,7 @@ def test_off_then_on_round_trips_at_repo_scope(repo: Path, capsys: pytest.Captur assert cli.main(["off", P]) == 0 assert _doc(repo / toggle.FILENAME) == {"version": 1, "bundles": {B: {P: "off"}}} assert "check --only baseline" in capsys.readouterr().out + commit_toggles(repo) assert cli.main(["status"]) == 0 out = capsys.readouterr().out assert re.search(rf"{P} +off blocks: refuses a destructive command", out) @@ -62,6 +64,43 @@ def test_off_then_on_round_trips_at_repo_scope(repo: Path, capsys: pytest.Captur toggle.parse((repo / toggle.FILENAME).read_text(encoding="utf-8")) +def test_off_ends_with_the_commit_and_next_session_line_at_repo_scope_only( + repo: Path, home: Path, capsys: pytest.CaptureFixture +) -> None: + line = f"This applies only once {toggle.FILENAME} is committed, and from the next session." + assert cli.main(["off", P]) == 0 + assert capsys.readouterr().out.rstrip().endswith(line) + assert cli.main(["on", P]) == 0 + assert line not in capsys.readouterr().out + assert cli.main(["off", Q, "--scope", "user"]) == 0 + assert line not in capsys.readouterr().out + + +def test_status_shows_an_uncommitted_off_as_on_until_committed(repo: Path, capsys: pytest.CaptureFixture) -> None: + assert cli.main(["off", P]) == 0 + capsys.readouterr() + assert cli.main(["status"]) == 0 + out = capsys.readouterr().out + assert re.search(rf"{P} +on ", out) and toggle.UNCOMMITTED in out + commit_toggles(repo) + assert cli.main(["status"]) == 0 + out = capsys.readouterr().out + assert re.search(rf"{P} +off ", out) and toggle.UNCOMMITTED not in out + + +def test_status_at_user_scope_needs_no_commit( + repo: Path, home: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + outside = repo.parent / "plain" + outside.mkdir() + monkeypatch.chdir(outside) + assert cli.main(["off", P]) == 0 + capsys.readouterr() + assert cli.main(["status"]) == 0 + out = capsys.readouterr().out + assert re.search(rf"{P} +off ", out) and toggle.UNCOMMITTED not in out + + def test_user_scope_writes_the_home_file_and_says_the_repo_governs( repo: Path, home: Path, capsys: pytest.CaptureFixture ) -> None: @@ -178,4 +217,19 @@ def test_reset_needs_adopt_and_adopt_still_takes_a_valid_file(repo: Path) -> Non path.parent.mkdir() path.write_text(json.dumps({"version": 1, "bundles": {B: {P: "off"}}}), encoding="utf-8") assert cli.main(["status", "--adopt"]) == 0 + commit_toggles(repo) assert toggle.drift(path) is None and toggle.load(repo)[2] == {B: {P: "off"}} + + +def test_an_off_only_in_the_working_file_stays_on_until_committed(repo: Path) -> None: + """A record the agent can rewrite is not proof a person wrote the file: HEAD's copy is.""" + assert cli.main(["off", P]) == 0 + _path, _scope, toggles, warning = toggle.load(repo) + assert toggle.state(toggles, B, P) == toggle.ON and P in warning and "committed" in warning + commit_toggles(repo) + assert toggle.load(repo)[2] == {B: {P: "off"}} + path = repo / toggle.FILENAME + forged = {"version": 1, "bundles": {B: {P: "off", Q: "off"}}} + path.write_text(json.dumps(forged), encoding="utf-8") + cli.record(path) + assert toggle.load(repo)[2] == {B: {P: "off"}} diff --git a/tests/test_guardrails_protect.py b/tests/test_guardrails_protect.py index 079a1329..42b86932 100644 --- a/tests/test_guardrails_protect.py +++ b/tests/test_guardrails_protect.py @@ -61,6 +61,10 @@ "sha256sum .chock/guardrails.json | cut -c1-64 > ~/.chock/state/GUARDRAILS.sha256", "rm .chock/state/guardrails.sha256", "chock bundle status --adopt", + # a name put together by expansion + "n=rails; cp /tmp/x .chock/guard${n}.json", + "cp /tmp/x .chock/$'guard\\x72ails'.json", + "printf x > .chock/state/${h}.sha256", ] READS = [ "cat .chock/guardrails.json", @@ -74,6 +78,7 @@ "cat .chock/state/guardrails.sha256", "echo x > notes.md", "ls -la", + 'mkdir -p "$TMPDIR/build" && cp a.txt "$TMPDIR/build/b.txt"', ] diff --git a/tests/test_guardrails_toggle_session.py b/tests/test_guardrails_toggle_session.py index d9557d85..c76f6a60 100644 --- a/tests/test_guardrails_toggle_session.py +++ b/tests/test_guardrails_toggle_session.py @@ -82,6 +82,32 @@ def test_a_session_start_lists_every_member_that_is_off_by_scope(tmp_path: Path, assert "not a security boundary" in out +def _notice(commands, plugin, repo, home, session) -> str: + out = json.loads(_start(commands, plugin, repo, home, session).stdout) + return out["hookSpecificOutput"]["additionalContext"] + + +def test_a_session_start_does_not_list_an_uncommitted_repo_off_as_off(tmp_path: Path, home: Path) -> None: + plugin, repo, commands = build(tmp_path, "claude") + set_toggles(repo, {BUNDLE_ID: {GUARD_ID: "off"}}, committed=False) + out = _notice(commands, plugin, repo, home, "one") + assert "switched off for this session" not in out + assert f"repo ({repo / toggle.FILENAME}): {BUNDLE_ID}/{GUARD_ID}" in out + assert "stay on" in out and toggle.UNCOMMITTED in out + + +def test_a_session_start_lists_a_committed_repo_off_and_an_uncommitted_user_off(tmp_path: Path, home: Path) -> None: + plugin, repo, commands = build(tmp_path, "claude") + outside = tmp_path / "scratch" + outside.mkdir() + set_toggles(repo, {BUNDLE_ID: {GUARD_ID: "off"}}) + set_toggles(home, {BUNDLE_ID: {GATE_ID: "off"}}, committed=False) + assert toggle.UNCOMMITTED not in _notice(commands, plugin, repo, home, "one") + out = _notice(commands, plugin, outside, home, "two") + assert "switched off for this session" in out and toggle.UNCOMMITTED not in out + assert f"user ({home / toggle.FILENAME}): {BUNDLE_ID}/{GATE_ID}" in out + + def test_a_session_start_binds_the_session_and_a_compaction_does_not_rebind_it(tmp_path: Path, home: Path) -> None: plugin, repo, commands = build(tmp_path, "claude") mine = client_payloads.in_session(shell("claude", repo, RM), "one") diff --git a/tests/test_guardrails_turn_end.py b/tests/test_guardrails_turn_end.py index 8fcd482f..8fa1c725 100644 --- a/tests/test_guardrails_turn_end.py +++ b/tests/test_guardrails_turn_end.py @@ -9,7 +9,7 @@ import client_payloads import pytest from bundle_fixtures import ALL_ROOTS, BUNDLE_ID, GUARD_ID -from conftest import run_hook_command +from conftest import init_repo, run_hook_command from guardrails_support import MERGED_CLIENTS, TOGGLE, any_refused, build, outcomes, refused, set_toggles, shell from chock.guardrails import cli, toggle @@ -244,8 +244,8 @@ def test_the_user_file_is_checked_too(tmp_path: Path, home: Path, monkeypatch: p def test_a_file_that_differs_from_its_record_switches_nothing_off(tmp_path: Path, home: Path) -> None: - repo = tmp_path / "r" - (repo / ".git").mkdir(parents=True) + (tmp_path / "r").mkdir() + repo = init_repo(tmp_path / "r") set_toggles(repo, {BUNDLE_ID: {GUARD_ID: "off"}}, recorded=False) _path, _scope, toggles, warning = toggle.load(repo) assert toggles == {} and toggle.DRIFTED in (warning or "") diff --git a/tests/test_install_coexist.py b/tests/test_install_coexist.py index cf079c3b..86c51edc 100644 --- a/tests/test_install_coexist.py +++ b/tests/test_install_coexist.py @@ -130,4 +130,4 @@ def test_migrating_a_schema_1_marketplace_keeps_it_chocks(catalog, tmp_path: Pat place.migrate_legacy(dest) assert not (dest / package.MARKER).exists() assert _read(dest / place.MARKETPLACE_MARKER)["client"] == "claude-code" - place.check_owned("claude-code", dest, "set-two") + place.check_owned("claude-code", dest, "set-two", package.marketplace()) diff --git a/tests/test_install_marketplace_name.py b/tests/test_install_marketplace_name.py new file mode 100644 index 00000000..d93a71d1 --- /dev/null +++ b/tests/test_install_marketplace_name.py @@ -0,0 +1,148 @@ +"""`chock install --marketplace-name`: the build's local marketplace under a name of the team's own (IR-13).""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path + +import pytest +from install_fixtures import make_catalog +from test_install_claude_plugin import DATA, OFFERED +from test_install_clients import _read, _run, v2 +from test_install_command import _stub_claude + +from chock.install import claude_plugin, package, place + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git required") +DEFAULT = package.settings()["marketplace"] +CUSTOM = "acme-chock" +MARKETPLACE_CLIENTS = [c for c in package.settings()["clients"] if package.client(c)["layout"] == place.MARKETPLACE] +CLIENTS = list(package.settings()["clients"]) + + +@pytest.fixture +def catalog(tmp_path: Path) -> tuple[Path, str]: + return make_catalog(tmp_path) + + +def _build(catalog: tuple[Path, str], tmp_path: Path, client: str, *extra: str) -> int: + root, ref = catalog + return _run(tmp_path, v2(root, ref, client), "--dest", str(tmp_path / "market"), *extra) + + +def _index(dest: Path, client: str) -> dict: + return _read(dest / package.client(client)["index"][0]["path"]) + + +@pytest.mark.parametrize("client", MARKETPLACE_CLIENTS) +def test_without_the_flag_every_marketplace_is_chock_local(catalog, tmp_path: Path, capsys, client: str) -> None: + assert _build(catalog, tmp_path, client) == 0 + dest = tmp_path / "market" + assert _index(dest, client)["name"] == DEFAULT == "chock-local" + assert _read(dest / place.MARKETPLACE_MARKER)["marketplace"] == DEFAULT + assert f"@{DEFAULT}" in capsys.readouterr().out or client == "codex" + + +@pytest.mark.parametrize("client", MARKETPLACE_CLIENTS) +def test_a_custom_name_is_the_manifest_and_marker_name(catalog, tmp_path: Path, client: str) -> None: + assert _build(catalog, tmp_path, client, "--marketplace-name", CUSTOM) == 0 + dest = tmp_path / "market" + assert _index(dest, client)["name"] == CUSTOM + assert _read(dest / place.MARKETPLACE_MARKER)["marketplace"] == CUSTOM + + +@pytest.mark.parametrize("client", CLIENTS) +def test_a_custom_name_replaces_the_default_in_every_printed_step(catalog, tmp_path: Path, capsys, client: str) -> None: + assert _build(catalog, tmp_path, client, "--marketplace-name", CUSTOM) == 0 + out = capsys.readouterr().out + assert DEFAULT not in out, "no next step, removal step or note keeps the default name" + if client == "claude-code": + assert f" claude plugin install chock-guardrails@{CUSTOM}" in out + assert f" claude plugin uninstall chock-guardrails@{CUSTOM}" in out + assert f"`claude plugin marketplace remove {CUSTOM}`" in out + if client == "copilot": + assert f" copilot plugin uninstall chock-guardrails@{CUSTOM}" in out + + +def test_the_copilot_note_and_the_build_command_name_the_chosen_marketplace(tmp_path: Path) -> None: + filled = claude_plugin.tokens(tmp_path / "d", "chock-guardrails", "CODE", CUSTOM) + out = "\n".join(claude_plugin.lines(DATA["build_client"], filled)) + if "copilot" in OFFERED: + assert f'Marketplace "{CUSTOM}" already registered' in out + assert f"copilot plugin marketplace remove {CUSTOM}" in out + assert DEFAULT not in out + other = next((c for c in OFFERED if c != DATA["build_client"]), None) + if other: + text = "\n".join(claude_plugin.lines(other, filled)) + assert f"--client claude-code --marketplace-name {CUSTOM}" in text + + +def test_the_default_build_command_has_no_flag(tmp_path: Path) -> None: + filled = claude_plugin.tokens(tmp_path / "d", "chock-guardrails", "CODE", DEFAULT) + other = next(c for c in OFFERED if c != DATA["build_client"]) + text = "\n".join(claude_plugin.lines(other, filled)) + assert "--client claude-code (then" in text + assert "--marketplace-name" not in text + + +@pytest.mark.skipif(os.name == "nt", reason="the stub is a POSIX shell script") +def test_apply_runs_the_commands_with_the_chosen_name(catalog, tmp_path: Path, monkeypatch) -> None: + log = _stub_claude(tmp_path, monkeypatch) + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM, "--apply") == 0 + assert log.read_text(encoding="utf-8").splitlines() == [ + f"plugin marketplace add {(tmp_path / 'market').resolve()}", + f"plugin install chock-guardrails@{CUSTOM}", + ] + + +@pytest.mark.parametrize( + "bad", + ["", "Acme", "acme_chock", "acme chock", "-acme", "acme-", "acme--chock", "acme.chock", "../acme", "é", "a" * 65], +) +def test_an_invalid_name_is_refused_and_nothing_is_built(catalog, tmp_path: Path, capsys, bad: str) -> None: + assert _build(catalog, tmp_path, "claude-code", f"--marketplace-name={bad}") == 1 + assert "not a valid marketplace name" in capsys.readouterr().err + assert not (tmp_path / "market").exists() + + +@pytest.mark.parametrize("good", ["a", "acme", "acme-chock", "team-2", "0", "a" * 64]) +def test_a_name_of_lowercase_letters_digits_and_single_hyphens_is_accepted(good: str) -> None: + assert package.marketplace(good) == good + + +def test_a_marketplace_folder_is_not_renamed_under_the_plugins_installed_from_it( + catalog, tmp_path: Path, capsys +) -> None: + assert _build(catalog, tmp_path, "claude-code") == 0 + capsys.readouterr() + dest = tmp_path / "market" + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM) == 1 + err = capsys.readouterr().err + assert "chock-local marketplace" in err + assert "--dest" in err + assert _index(dest, "claude-code")["name"] == DEFAULT + assert _build(catalog, tmp_path, "claude-code") == 0, "the same name rebuilds" + + +def test_a_custom_folder_rebuilds_under_its_own_name_only(catalog, tmp_path: Path, capsys) -> None: + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM) == 0 + capsys.readouterr() + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM) == 0 + assert _build(catalog, tmp_path, "claude-code") == 1 + assert json.loads((tmp_path / "market" / place.MARKETPLACE_MARKER).read_text())["marketplace"] == CUSTOM + + +def test_three_argument_tokens_are_the_default_name_tokens(tmp_path: Path) -> None: + old = claude_plugin.tokens(tmp_path, "set-two", "sel.yaml") + assert old == claude_plugin.tokens(tmp_path, "set-two", "sel.yaml", DEFAULT) + assert old["__MARKETPLACE__"] == DEFAULT + assert old["__MARKETPLACE_FLAG__"] == "" + + +def test_place_helpers_default_to_the_default_name(tmp_path: Path) -> None: + place.check_owned("claude-code", tmp_path / "empty", "set-two") + (tmp_path / "market").mkdir() + assert place.write_marketplace("claude-code", tmp_path / "market") + assert json.loads((tmp_path / "market" / place.MARKETPLACE_MARKER).read_text())["marketplace"] == DEFAULT diff --git a/tests/test_install_remove.py b/tests/test_install_remove.py new file mode 100644 index 00000000..6fc8d935 --- /dev/null +++ b/tests/test_install_remove.py @@ -0,0 +1,74 @@ +"""`chock install` prints what removes a build: the plugin, then its folder, then the shared marketplace (IR-12).""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from install_fixtures import make_catalog, selection, write_selection + +from chock.install import cli, package, place, steps + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git required") +#: Clients whose remove command chock could not confirm from the client's docs or a live run; they print no removal. +UNCONFIRMED = {"codex", "antigravity", "kimi-code"} +WITH_REMOVE = [c for c in package.settings()["clients"] if c not in UNCONFIRMED] +MARKETPLACE_CLIENTS = [c for c in WITH_REMOVE if package.client(c)["layout"] == place.MARKETPLACE] +TOKENS = { + "__DEST__": "/d", + "__PLUGIN_DIR__": "/d/claude/chock-guardrails", + "__PLUGIN__": "chock-guardrails", + "__MARKETPLACE__": "chock-local", +} + + +def _text(client: str) -> list[str]: + return [s.text or " ".join(s.command or []) for s in steps.render_remove(client, TOKENS)] + + +def test_exactly_the_unconfirmed_clients_have_no_removal() -> None: + assert {c for c in package.settings()["clients"] if "remove" not in package.client(c)} == UNCONFIRMED + + +@pytest.mark.parametrize("client", WITH_REMOVE) +def test_every_removal_names_the_build_folder_and_fills_every_token(client: str) -> None: + lines = _text(client) + assert any("/d/claude/chock-guardrails" in line for line in lines), "the build folder is named" + assert not any("__" in line for line in lines), "every token is filled" + + +@pytest.mark.parametrize("client", MARKETPLACE_CLIENTS) +def test_the_shared_marketplace_is_removed_only_on_the_readers_say_so(client: str) -> None: + rendered = steps.render_remove(client, TOKENS) + removing = [s for s in rendered if "marketplace remove" in s.text] + assert len(removing) == 1 + assert "Only if no other chock build" in removing[0].text + assert not any(s.command and "marketplace" in s.command and "remove" in s.command for s in rendered) + + +def test_claude_code_removes_the_plugin_then_the_folder_then_the_marketplace() -> None: + lines = _text("claude-code") + assert lines[0] == "claude plugin uninstall chock-guardrails@chock-local" + assert "Delete the build folder /d/claude/chock-guardrails" in lines[1] + assert lines[2].endswith("`claude plugin marketplace remove chock-local`. Otherwise leave it registered.") + assert len(lines) == 3 + + +def test_copilot_says_its_uninstall_only_disables() -> None: + assert "only disables" in _text("copilot")[0] + + +def test_install_prints_the_removal_after_the_install_steps(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + root, ref = make_catalog(tmp_path) + base = selection(root, ref) + picks = [{"from": "catalog", **p} for p in base["policies"]] + data = {**base, "schema": 2, "client": "claude-code", "bundle": {"version": "2.0.0"}, "policies": picks} + path = write_selection(tmp_path / "chock.selection.yaml", data) + assert cli.main(["--selection", str(path), "--allow-source", data["catalog"]["source"]]) == 0 + out = capsys.readouterr().out + assert out.index("Next:") < out.index("To remove it:") + assert " claude plugin uninstall chock-guardrails@chock-local" in out + assert "Delete the build folder" in out diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 23fb2a08..12f10ccc 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -205,3 +205,34 @@ def test_each_warning_kind_fires_only_on_its_condition(tmp_path: Path) -> None: (tmp_path / "allow.txt").write_text("x", encoding="utf-8") quiet = warnings.warnings_for(warnings.Context(["a-one", "a-two", "b-one", "c-one"], {}, tmp_path, "cursor"), RULES) assert quiet == [] + + +@pytest.mark.parametrize("quote", ["'", '"']) +def test_one_surrounding_quote_pair_is_stripped_from_a_code_or_link(valid: dict, quote: str) -> None: + """cmd.exe hands `--selection 'CODE'` over with the quotes still on.""" + bare = sel.load(code(valid)) + assert sel.load(f"{quote}{code(valid)}{quote}") == bare + assert sel.load(f"{quote}https://chock.sh/build#s={code(valid)}{quote}") == bare + + +@pytest.mark.parametrize( + ("given", "expected"), + [ + ("'abc'", "abc"), + ('"abc"', "abc"), + ("''abc''", "'abc'"), + ("'abc\"", "'abc\""), + ("'abc", "'abc"), + ("'", "'"), + ("", ""), + ], +) +def test_unquote_strips_exactly_one_matching_pair(given: str, expected: str) -> None: + assert sel._unquote(given) == expected + + +def test_a_code_that_fails_to_decode_says_cmd_exe_passes_quotes_through() -> None: + with pytest.raises( + sel.SelectionError, match=r"not base64url-encoded JSON.*cmd\.exe passes quotes through.*needs none" + ): + sel.load("'oops") diff --git a/tests/test_mcp_gateway_scope.py b/tests/test_mcp_gateway_scope.py index 58d6a268..cc779858 100644 --- a/tests/test_mcp_gateway_scope.py +++ b/tests/test_mcp_gateway_scope.py @@ -90,3 +90,39 @@ def test_forbidden_path_regex_is_bounded_by_scope(): def test_pragma_on_the_line_does_not_waive_at_the_gateway_even_when_in_scope(): args = {"path": ".github/workflows/ci.yml", "content": "uses: a/b@v4 # pragma: allow"} assert gateway_gates.evaluate(_scoped(), "write_file", args) is not None + + +def _protect(repo_root: str) -> list[dict]: + params = {"content_pattern": "(?!)", "forbidden_path_regex": r"\.chock[/\\](bin|compiled)|\.claude[/\\]settings"} + return [{**_gate("content_regex", params), "repo_root": repo_root}] + + +def test_a_path_spelled_with_dots_or_doubled_slashes_is_still_forbidden(tmp_path): + gates = _protect(str(tmp_path)) + for spelled in ( + ".chock/./bin/gate.py", + ".claude//settings.json", + "docs/../.chock/bin/gate.py", + f"{tmp_path}/.chock/./bin/x", + ): + assert gateway_gates.evaluate(gates, "write_file", {"path": spelled, "content": "x"}) is not None, spelled + + +def test_a_forbidden_path_reached_through_a_symlink_is_still_forbidden(tmp_path): + (tmp_path / ".chock" / "bin").mkdir(parents=True) + (tmp_path / "alias").symlink_to(tmp_path / ".chock" / "bin") + args = {"path": "alias/gate.py", "content": "x"} + assert gateway_gates.evaluate(_protect(str(tmp_path)), "write_file", args) is not None + + +def test_a_scoped_gate_sees_an_equivalent_spelling_of_an_in_scope_path(tmp_path): + gates = [{**_scoped()[0], "repo_root": str(tmp_path)}] + for spelled in ("docs/../.github/workflows/ci.yml", ".//.github/workflows/ci.yml", ".github//workflows/ci.yml"): + args = {"path": spelled, "content": "- uses: a/b@v4"} + assert gateway_gates.evaluate(gates, "write_file", args) is not None, spelled + + +def test_a_scoped_gate_judges_a_path_that_climbs_out_of_the_repository(tmp_path): + gates = [{**_scoped()[0], "repo_root": str(tmp_path)}] + args = {"path": "../elsewhere/../../x", "content": "- uses: a/b@v4"} + assert gateway_gates.evaluate(gates, "write_file", args) is not None diff --git a/tests/test_pattern_deadline.py b/tests/test_pattern_deadline.py index 2838e317..64e7ab78 100644 --- a/tests/test_pattern_deadline.py +++ b/tests/test_pattern_deadline.py @@ -142,10 +142,14 @@ def test_the_history_scan_needs_no_signal() -> None: def test_the_judge_matches_times_out_and_reports_a_dead_child() -> None: + # A call that starts a child spends interpreter start-up out of its budget, and start-up on a + # loaded runner can outlast SECONDS; only the catastrophic pattern needs the short budget, and + # by then the child is already up. Every other call returns as soon as the child answers or dies. + start_up = budget.PATTERN_SECONDS judge = budget.pattern_judge() assert budget.judge_patterns(judge, [("a", []), ("b", [])], SECONDS) == ([[], []], None) assert judge["proc"] is None, "nothing to judge starts no child" - assert budget.judge_patterns(judge, [("a+", ["xa", "b", "aaa"]), (r"\d", ["1", "x"])], SECONDS) == ( + assert budget.judge_patterns(judge, [("a+", ["xa", "b", "aaa"]), (r"\d", ["1", "x"])], start_up) == ( [[0, 2], [0]], None, ) @@ -154,9 +158,9 @@ def test_the_judge_matches_times_out_and_reports_a_dead_child() -> None: assert hits is None and why == f"the patterns gave no verdict within {SECONDS}s (a catastrophic pattern?)" assert SECONDS <= time.monotonic() - started < budget.PATTERN_SECONDS assert judge["proc"] is None, "a timed-out child is killed" - hits, why = budget.judge_patterns(judge, [("(", ["x"])], SECONDS) + hits, why = budget.judge_patterns(judge, [("(", ["x"])], start_up) assert hits is None and why.startswith("the pattern judge exited early (") - assert budget.judge_patterns(judge, [("x", ["x"])], SECONDS) == ([[0]], None), "a new child after a crash" + assert budget.judge_patterns(judge, [("x", ["x"])], start_up) == ([[0]], None), "a new child after a crash" assert budget.close_judge(judge) == "" and judge["proc"] is None diff --git a/tests/test_person_only_commands.py b/tests/test_person_only_commands.py index e52ad74c..045c2e33 100644 --- a/tests/test_person_only_commands.py +++ b/tests/test_person_only_commands.py @@ -101,6 +101,26 @@ def test_reading_commands_stay_open_to_an_agent( assert REFUSED not in capsys.readouterr().err +@pytest.mark.parametrize( + "argv", + [ + ["disable", "--", "--check"], + ["remove", "--", "--check"], + ["init", "--", "--check"], + ["install-hooks", "--check"], + ["sync", "--", "--check"], + ["enable", "--check", POLICY], + ], +) +def test_a_check_token_that_is_not_the_flag_does_not_open_the_command( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], argv: list[str] +) -> None: + """`--check` after `--`, or on a command that has no such flag, is a positional value.""" + monkeypatch.setenv("CLAUDECODE", "1") + assert cli.main([*argv, "--repo", str(repo)]) == 1 + assert REFUSED in capsys.readouterr().err + + def test_a_persons_shell_still_runs_them(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: """No vendor marker, with or without `CHOCK_AGENT_COMMIT=0`: the person's own shell.""" monkeypatch.setenv("CHOCK_AGENT_COMMIT", "0") diff --git a/tests/test_pipe_tolerance.py b/tests/test_pipe_tolerance.py index 0061133e..d8ea1cb2 100644 --- a/tests/test_pipe_tolerance.py +++ b/tests/test_pipe_tolerance.py @@ -117,3 +117,66 @@ def test_recompile_finishes_its_bookkeeping_when_the_reader_closes_early(tmp_pat f"just installed now fail every commit:\n{output}" ) assert "index_freshness" not in output, f"INDEX.md was left stale by the truncated run:\n{output}" + + +def test_unencodable_character_is_escaped_not_raised() -> None: + """IR-F18: U+FFFD on a cp1252 stream (Windows console) must not crash the command.""" + raw = io.BytesIO() + stream = io.TextIOWrapper(raw, encoding="cp1252", errors="strict") + writer = PipeTolerantWriter(stream) + + writer.write("ok � end") + writer.flush() + + assert raw.getvalue() == b"ok \\ufffd end" + + +def test_representable_text_is_written_unchanged() -> None: + raw = io.BytesIO() + writer = PipeTolerantWriter(io.TextIOWrapper(raw, encoding="cp1252", errors="strict")) + + writer.write("café €") + writer.flush() + + assert raw.getvalue() == "café €".encode("cp1252") + + +def test_broken_pipe_on_the_escaped_retry_is_tolerated() -> None: + class _Cp1252ThenClosed(io.StringIO): + encoding = "cp1252" + + def write(self, text: str) -> int: + if "�" in text: + raise UnicodeEncodeError("charmap", text, 0, 1, "undefined") + raise BrokenPipeError(32, "Broken pipe") + + writer = PipeTolerantWriter(_Cp1252ThenClosed()) + + writer.write("ok � end") + + assert writer.closed_by_reader + + +def test_eval_output_with_unencodable_text_survives_a_cp1252_console( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """IR-F31: `chock check --only evals` printing U+FFFD to a Windows cp1252 console must not raise.""" + import shutil + + import chock.eval.cli as eval_cli + from chock.cli import main + + target = tmp_path / ".agents" / "policies" / "agent-discipline" + target.parent.mkdir(parents=True) + shutil.copytree(ROOT / ".agents" / "policies" / "agent-discipline", target) + + real_render = eval_cli.render_text + monkeypatch.setattr(eval_cli, "render_text", lambda *a, **k: real_render(*a, **k) + "\nguard said � bad byte") + raw = io.BytesIO() + stream = io.TextIOWrapper(raw, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", stream) + + main(["check", "--only", "evals", "--repo", str(tmp_path)]) + sys.stdout.flush() + + assert b"guard said \\ufffd bad byte" in raw.getvalue() diff --git a/tests/test_runtime_compiles_on_oldest_python.py b/tests/test_runtime_compiles_on_oldest_python.py new file mode 100644 index 00000000..0abfaea6 --- /dev/null +++ b/tests/test_runtime_compiles_on_oldest_python.py @@ -0,0 +1,41 @@ +"""Every rendered runtime file compiles on the oldest supported Python (3.11). + +`ast.unparse` writes nested-quote f-strings that only parse on 3.12+. A runtime built that way +raises SyntaxError on a 3.11 machine, and every hook call is then refused (IR-F13). The 3.11 +matrix job runs this on 3.11 itself; on a newer interpreter the files are compiled by a 3.11 +interpreter when one is on PATH. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +RENDERED = sorted( + [*(ROOT / ".chock" / "bin").glob("*.py"), *(ROOT / "tests" / "fixtures" / "runtime_goldens").glob("*.py")] +) +OLDEST = (3, 11) +_COMPILE = "import sys\nfor p in sys.argv[1:]:\n compile(open(p, encoding='utf-8').read(), p, 'exec')\n" + + +def test_the_rendered_runtime_files_are_found() -> None: + kinds = {p.parent.name for p in RENDERED} + assert kinds == {"bin", "runtime_goldens"}, kinds + assert len(RENDERED) >= 20 + + +def test_every_rendered_runtime_file_compiles_on_the_oldest_supported_python() -> None: + if sys.version_info[:2] == OLDEST: + for path in RENDERED: + compile(path.read_text(encoding="utf-8"), str(path), "exec") + return + exe = shutil.which(f"python{OLDEST[0]}.{OLDEST[1]}") + if exe is None: + pytest.skip("no Python 3.11 on PATH; the 3.11 matrix job runs this check") + run = subprocess.run([exe, "-c", _COMPILE, *map(str, RENDERED)], capture_output=True, text=True, check=False) + assert run.returncode == 0, f"not valid on Python 3.11:\n{run.stderr[-600:]}" diff --git a/tests/test_runtime_goldens.py b/tests/test_runtime_goldens.py index 8c1afefa..e4d40a0e 100644 --- a/tests/test_runtime_goldens.py +++ b/tests/test_runtime_goldens.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import inspect import os import shutil from pathlib import Path @@ -10,7 +11,7 @@ import pytest from agentseam import bundler, contract -from chock.gate import runtime_bundle, write_gate +from chock.gate import runtime_bundle, runtime_shared, write_gate GOLDEN = Path(__file__).resolve().parent / "fixtures" / "runtime_goldens" @@ -81,3 +82,30 @@ def test_chock_handler_shadows_no_agentseam_name(agent: str) -> None: shared = _top_level_names(head + tail) & _top_level_names(runtime_bundle._handler_source(agent)) assert shared <= {"PRE_TOOL"}, f"{agent}: chock's handler rebinds agentseam's {sorted(shared)}" assert write_gate.PRE_TOOL == contract.PRE_TOOL + + +def _quoting_fstrings(source: str) -> list[int]: + """Lines of f-strings whose replacement fields hold a string or another f-string: `ast.unparse` quotes those by Python patch level.""" + return sorted( + { + node.lineno + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.FormattedValue) + for inner in ast.walk(node.value) + if isinstance(inner, ast.JoinedStr) or (isinstance(inner, ast.Constant) and isinstance(inner.value, str)) + } + ) + + +def test_the_scan_finds_a_nested_quote_however_deep() -> None: + assert _quoting_fstrings('a = f"{x[\'k\']}"\nb = f"{\', \'.join(y)}"\nc = f"{x}-{y!r}"\n') == [1, 2] + + +def test_no_handler_module_has_an_fstring_whose_unparse_depends_on_the_python_patch_level() -> None: + """3.12.3 prints f"{m['k']}" as f'{m['k']}', 3.12.13 as f"{m['k']}": the rendered bytes would follow the interpreter.""" + found = { + module.__name__: lines + for module in runtime_shared._HANDLER_MODULES + if (lines := _quoting_fstrings(inspect.getsource(module))) + } + assert not found, f"hoist the quoted part of these f-strings into a local: {found}" diff --git a/tests/test_write_gate_handler.py b/tests/test_write_gate_handler.py index 3964bf25..29d26b6c 100644 --- a/tests/test_write_gate_handler.py +++ b/tests/test_write_gate_handler.py @@ -7,13 +7,14 @@ from __future__ import annotations import json +import os import subprocess from pathlib import Path from types import SimpleNamespace from conftest import init_repo -from chock.gate import write_gate +from chock.gate import repo_names, write_gate from chock.gate.assemble import runner_source from chock.gate.stop_reentry import REENTRY_CAP @@ -97,13 +98,66 @@ def test_a_stop_hook_already_active_still_reads_the_worktree(tmp_path: Path) -> assert write_gate.writes_for(_event("stop", raw={"stop_hook_active": True}), gate)["app.py"] == SECRET -def test_a_deletion_leaves_no_content_to_judge(tmp_path: Path) -> None: +def test_a_deletion_is_judged_by_path_with_no_content(tmp_path: Path) -> None: init_repo(tmp_path) (tmp_path / "gone.py").write_text("x = 1\n", encoding="utf-8") subprocess.run(["git", "-C", str(tmp_path), "add", "-A"], check=True, capture_output=True) subprocess.run(["git", "-C", str(tmp_path), "commit", "-m", "add"], check=True, capture_output=True) (tmp_path / "gone.py").unlink() - assert "gone.py" not in write_gate.writes_from_worktree(tmp_path) + assert write_gate.writes_from_worktree(tmp_path)["gone.py"] == "" + + +def test_a_rename_judges_both_names(tmp_path: Path) -> None: + init_repo(tmp_path) + (tmp_path / "old.py").write_text("x = 1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "-A"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "-m", "add"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(tmp_path), "mv", "old.py", "new.py"], check=True, capture_output=True) + assert write_gate.writes_from_worktree(tmp_path) == {"old.py": "", "new.py": "x = 1\n"} + + +def test_a_fifo_in_the_worktree_is_refused_not_read(tmp_path: Path) -> None: + """Opening a FIFO blocks the hook until its client times out, which a client may read as an allow.""" + os.mkfifo(tmp_path / "pipe") + text, why = write_gate.worktree_text(tmp_path / "pipe") + assert text is None + assert "neither a file nor a link" in why + + +def test_a_symlink_to_a_fifo_is_judged_as_a_link_and_never_opened(tmp_path: Path) -> None: + init_repo(tmp_path) + os.mkfifo(tmp_path / "pipe") + (tmp_path / "pipe").rename(tmp_path / ".pipe") + (tmp_path / "link").symlink_to(".pipe") + assert write_gate.writes_from_worktree(tmp_path)["link"] == ".pipe" + + +def test_a_stop_in_a_subdirectory_reads_the_worktree_from_the_top_level(tmp_path: Path) -> None: + init_repo(tmp_path) + (tmp_path / "sub").mkdir() + (tmp_path / "app.py").write_text(SECRET, encoding="utf-8") + event = _event("stop", raw={"cwd": str(tmp_path / "sub")}) + event.cwd = str(tmp_path / "sub") + assert write_gate.stop_roots(event, tmp_path / "sub") == [tmp_path] + assert write_gate.repo_root_for(event, tmp_path / "plugin" / "gate.json") == tmp_path + + +def test_a_write_of_nothing_is_still_a_judged_write() -> None: + event = _event( + path="/r/.chock/bin/gate.py", raw={"tool_input": {"file_path": "/r/.chock/bin/gate.py", "content": ""}} + ) + assert write_gate.writes_from_event(event) == {"/r/.chock/bin/gate.py": ""} + + +def test_an_edit_with_no_file_text_is_not_a_write() -> None: + assert write_gate.writes_from_event(_event(path="/r/a.py", raw={"tool_input": {"file_path": "/r/a.py"}})) == {} + + +def test_a_differently_cased_path_is_also_judged_as_stored(tmp_path: Path) -> None: + (tmp_path / ".claude").mkdir() + names = write_gate.repo_paths(".CLAUDE/Settings.json", str(tmp_path), case_blind=True) + assert ".claude/Settings.json" in names + assert write_gate.repo_paths(".CLAUDE/x", str(tmp_path), case_blind=False) == (".CLAUDE/x",) # --- the decision it returns ------------------------------------------------------------------------- @@ -198,23 +252,23 @@ def test_a_write_through_a_symlinked_folder_inside_the_repo_is_judged_as_its_tar 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" + assert repo_names.repo_relative("/r/.github/workflows/ci.yml", "/r") == ".github/workflows/ci.yml" + assert repo_names.repo_relative("./a/../.github/x.yml", "/r") == ".github/x.yml" + assert repo_names.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" + assert repo_names.repo_relative(windows, r"c:\users\dev\repo") == ".github/workflows/ci.yml" + assert repo_names.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" + repo_names.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" + assert repo_names.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: diff --git a/tests/test_write_gate_patch.py b/tests/test_write_gate_patch.py index f954a3c0..f5621890 100644 --- a/tests/test_write_gate_patch.py +++ b/tests/test_write_gate_patch.py @@ -96,7 +96,12 @@ def test_several_files_hunks_a_move_and_a_delete(tmp_path: Path) -> None: *** Add File: extra.txt +hi *** End Patch""" - assert patch_image.patched_files(_codex(patch), root) == {"src/Moved.java": "A\nb\nc\nD\n", "extra.txt": "hi\n"} + assert patch_image.patched_files(_codex(patch), root) == { + REPO: "", + "gone.txt": "", + "src/Moved.java": "A\nb\nc\nD\n", + "extra.txt": "hi\n", + } def test_a_hunk_with_no_header_and_context_as_bare_lines(tmp_path: Path) -> None: @@ -209,3 +214,10 @@ def test_a_patch_with_no_end_marker_is_refused() -> None: def test_a_deleted_file_adds_no_lines() -> None: patch = "*** Begin Patch\n*** Delete File: gone.txt\n*** Add File: a.txt\n+x\n*** End Patch" assert patch_image.patch_added(_codex(patch)) == {"a.txt": "x"} + + +def test_a_delete_or_move_names_its_path_so_path_gates_see_it(tmp_path: Path) -> None: + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / "settings.json").write_text("{}\n", encoding="utf-8") + patch = "*** Begin Patch\n*** Delete File: .claude/settings.json\n*** End Patch" + assert patch_image.patched_files(_codex(patch), tmp_path) == {".claude/settings.json": ""} diff --git a/tests/test_write_gate_patch_parse.py b/tests/test_write_gate_patch_parse.py index 0eac6e76..e9f8576c 100644 --- a/tests/test_write_gate_patch_parse.py +++ b/tests/test_write_gate_patch_parse.py @@ -177,7 +177,7 @@ def test_what_codex_writes_is_judged(tmp_path: Path, name: str) -> None: "an update in the agent's subdirectory": {"sub/f.txt": f"a\n{TOKEN}\n"}, "a pure addition after an anchor": {"f.txt": f"a\nb\n{TOKEN}\n"}, "an add then an update of the same file": {"f.txt": f"a\n{TOKEN}\n"}, - "an update moved to a file a later update changes": {"g.txt": f"b\n{TOKEN}\n"}, + "an update moved to a file a later update changes": {"f.txt": "", "g.txt": f"b\n{TOKEN}\n"}, }