From 69a4b85fe51483f48ff386ad0b0f45c96f03db55 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:16:46 +0000 Subject: [PATCH 1/4] fix(actor): an agent cannot switch chock off (CH-1) The commands that change .chock/config.yaml, chock.lock or the compiled tree (sync, recompile, install-hooks, enable, disable, add, remove, init, compile) refuse under an agent marker with the rule `chock resume` applies; a person runs them from their own shell. The refusal is logged as policy_id chock-cli. `sync --check`, `check` and `status` stay open. One actor rule serves every check: CHOCK_AGENT_COMMIT=0 unsets that one variable and clears no vendor marker (CLAUDECODE=1, AI_AGENT, a configured agent_commit_env name), since an agent can set it too. The ask refusal tells a person the CHOCK_ALLOW line and tells an agent to ask the person, naming no override. For the agent asking chock_guidance, a policies.disabled entry counts only once committed at HEAD, the way the committed rollout caps the working tree's. docs/assurance-case.md restates "tamper-evident" to what the mechanism gives and lists the bytecode and runtime-pin limits; the spec and the surfaces doc state the new rule. Tests that asserted the old escape hatch now assert the opposite: the hatch was the review's B2 (an agent sets it too). Co-Authored-By: Claude Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/agent-cannot-switch-chock-off.md | 12 ++ docs/agentic-risk-coverage.md | 2 +- docs/assurance-case.md | 20 ++- docs/decision-log.md | 2 +- docs/enforcement-surfaces.md | 10 +- spec/gate-dsl.md | 12 +- src/chock/cli.py | 33 ++++- src/chock/config.py | 27 +++- src/chock/data/commands.json | 27 ++-- src/chock/gate/runner/actor.py | 45 ++---- src/chock/gate/runner/constants.py | 6 +- src/chock/gate/runner/verdict.py | 4 +- src/chock/guidance/advise.py | 31 +++-- src/chock/stop_common.py | 29 ++-- tests/test_agent_commit_detection.py | 32 +++-- tests/test_decision_principal.py | 3 +- tests/test_gate_actions.py | 24 +++- tests/test_guidance_safety.py | 25 +++- tests/test_person_only_commands.py | 138 +++++++++++++++++++ tests/test_rollout.py | 12 +- tests/test_script_hook_verdicts.py | 6 +- 21 files changed, 392 insertions(+), 108 deletions(-) create mode 100644 changelog.d/agent-cannot-switch-chock-off.md create mode 100644 tests/test_person_only_commands.py diff --git a/changelog.d/agent-cannot-switch-chock-off.md b/changelog.d/agent-cannot-switch-chock-off.md new file mode 100644 index 00000000..1856bb71 --- /dev/null +++ b/changelog.d/agent-cannot-switch-chock-off.md @@ -0,0 +1,12 @@ +- **An agent cannot switch chock off.** `chock sync`, `recompile`, `install-hooks`, `enable`, `disable`, + `add`, `remove`, `init` and `compile`, the commands that change `.chock/config.yaml`, `chock.lock` or the + compiled tree, refuse under an agent marker (`CLAUDECODE=1`, `AI_AGENT`, a truthy `CHOCK_AGENT_COMMIT`, a + configured `agent_commit_env` name) with the rule `chock resume` applies: a person runs them from their own + shell. The refusal is logged (`policy_id: chock-cli`). `sync --check`, `check` and `status` stay open. One + actor rule now serves every check: `CHOCK_AGENT_COMMIT=0` unsets that one variable and clears no vendor + marker, so `CHOCK_ALLOW= CHOCK_AGENT_COMMIT=0` in an agent's terminal no longer answers an ask and + `CHOCK_ROLLOUT` is ignored there; a person's own shell, which has no marker, keeps both. A refused ask tells + a person the `CHOCK_ALLOW` line and tells an agent to ask the person, naming no override. For the agent + asking `chock_guidance`, a `policies.disabled` entry counts only once committed (HEAD), the way the + committed rollout caps the working tree's. docs/assurance-case.md restates "tamper-evident" to what the + mechanism gives and lists the bytecode and runtime-pin limits. diff --git a/docs/agentic-risk-coverage.md b/docs/agentic-risk-coverage.md index 7766bcce..7d5f6dab 100644 --- a/docs/agentic-risk-coverage.md +++ b/docs/agentic-risk-coverage.md @@ -45,7 +45,7 @@ Chock's guard degrades to allow, so no row here earns it. | …wildcard permission grants in agent config? | `block-wildcard-agent-permissions` gate | `enforced-at-commit` | | …a workflow pulling a third-party Action by a tag that can be re-pointed? | `pin-github-actions` gate | `enforced-at-commit`; `best-effort` at the write path on Claude Code and Gemini CLI and `enforceable` on Cursor, bounded to `.github/workflows/*` and `.github/actions/*` | | …an agent calling an MCP or other tool I did not approve (`WebFetch`, `mcp__*`)? | a `tool_call` gate you author: `content_regex` over the tool input, or a `script` | A mechanism, not a shipped policy: no catalog policy is claimed. Where a policy declares it, `best-effort` (`enforceable` on Cursor) on Claude Code, Codex CLI, Cursor, Gemini CLI and Copilot (`agent-hooks`), the vendors with a recorded tool vocabulary; nothing on the others, and not at commit, Stop or the `mcp-gateway`. The gate matches tool name and input text; it does not understand what a tool does, and a crashed hook fails open on hosts that do | -| …an agent waiving a gate itself, with `allowlist_pragma` or a waiver line in its own commit? | agent-commit detection (`CLAUDECODE`, `AI_AGENT`, `agent_commit_env`, `CHOCK_AGENT_COMMIT`) | `enforced-at-commit` slice: an agent's commit does not honour a waiver it adds; one already in HEAD still counts. Only Claude Code's markers are witnessed; other agents need `agent_commit_env`. `CHOCK_AGENT_COMMIT=0` overrides every marker, so it is a default, not an identity. CI reads waivers in the PR range | +| …an agent waiving a gate itself, with `allowlist_pragma` or a waiver line in its own commit? | agent-commit detection (`CLAUDECODE`, `AI_AGENT`, `agent_commit_env`, `CHOCK_AGENT_COMMIT`) | `enforced-at-commit` slice: an agent's commit does not honour a waiver it adds; one already in HEAD still counts. Only Claude Code's markers are witnessed; other agents need `agent_commit_env`. `CHOCK_AGENT_COMMIT=0` clears no vendor marker (an agent can set it too); the markers are defaults, not an identity. CI reads waivers in the PR range | | …an agent deleting tests or assertions to get green? | `test-integrity` gate, `agent-discipline` (`assertion_deletion: block`) | `enforced-at-commit` (gate) — blocks a deleted test file, a net loss of assertions across the change, or a vacuous assertion (`assert True`, `expect(true)`) added in their place; re-checked in CI on the PR head via the `ci-gate` backstop, so it holds for an inbound contributor whose agent never ran a hook. The pragma `chock: test-removal-reviewed` on the removing line is the reviewed escape hatch. No `pre-tool-use` binding exists, so this is not claimed at the in-agent tier. The ambient rule text remains `advisory` | | …a PR merging with no machine-checkable basis for what it claims to have passed? | `require-review-evidence` (opt-in), `chock review require --base ` | `enforced-in-ci`, and nothing more: it re-derives a contributor's claimed checks against the repository's own `required_checks` (by `command_set_hash`, not by lookup — a shrunk set or a redefined check both fail) and judges outcome separately from validity. It does **not** make review find more bugs, and it is opt-in because it needs curated `required_checks` config; unset, it claims nothing | diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 6440f7ce..c2ab96e7 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -13,10 +13,10 @@ that connects them. **An adopter repo using Chock knows exactly which policies are enforced, by which mechanism, on which agent — and cannot silently lose that enforcement.** -Deliberately, the claim is about *truthful, tamper-evident* enforcement, not total -prevention. Chock is a governance layer, not a sandbox: a determined human with a -terminal can bypass git hooks, and an agent outside every wired surface sees only -advisory text. The design goal is that every such gap is *stated* — in the coverage +Deliberately, the claim is about *truthful* enforcement that an agent cannot switch +off, not total prevention. Chock is a governance layer, not a sandbox: a determined +human with a terminal can bypass git hooks and rewrite what is installed, and an agent +outside every wired surface sees only advisory text. The design goal is that every such gap is *stated* — in the coverage report, in SECURITY.md, in this page — never papered over. Overclaiming is treated as the highest-severity failure the project can ship, because one discovered overclaim discredits every honest number. @@ -49,7 +49,7 @@ Threat classes defended against: | Requirement | Mechanism | Evidence | |---|---|---| | Untrusted input is validated by allowlist (T1, T2) | Policy ids: anchored `fullmatch` against a fixed pattern, id must equal folder name; manifests: JSON Schema; agent selections: fixed allowlist; URLs: https-only; catalog paths: confinement to the catalog root | `tests/test_manifest_id_safety.py`, property-based suite (`tests/test_properties.py`), weekly atheris fuzzing of the same parsers | -| Installed content is tamper-evident (T2, T3) | `chock.lock` pins sha256 of every pack and compiled artifact; `--verify-sha` refuses mismatched content before it touches disk; `chock check --only verify` re-derives hashes | lockfile test suite; verify exercised in CI on every PR | +| Installed content is pinned, and only a person changes it (T1, T2, T3) | `chock.lock` pins sha256 of every pack file and compiled artifact (bytecode caches are outside the hash); `--verify-sha` refuses mismatched content before it touches disk; `chock check --only verify` re-derives hashes, and CI's baseline check refuses a pull request whose policy set is weaker than its base; the commands that rewrite the config, the lock or the compiled tree (`sync`, `add`, `remove`, `enable`, `disable`, `init`, `compile`, `install-hooks`, `recompile`) refuse under an agent marker, as `chock stop` and `resume` do, and `CHOCK_AGENT_COMMIT=0` clears no vendor marker; an uncommitted `policies.disabled` entry changes nothing for an agent | lockfile test suite; verify and the baseline check exercised in CI on every PR; `tests/test_person_only_commands.py`, `tests/test_agent_commit_detection.py` | | Gates fail closed, not open (T4) | CI range mode exits non-zero on an unresolvable base; agent hooks run through a committed launcher that refuses (exit 2) when no Python runs, never exit 127 = allow; Cursor gate entries set `failClosed`; diff filters include renames and merge commits; `core.quotePath` handled for non-ASCII paths | `tests/test_gate_bypasses.py`, `tests/test_pretooluse*.py`, `tests/test_hook_launcher.py` — each closed bypass carries its regression test | | Enforcement claims are computed, never asserted (T5) | `coverage_level()` credits a surface only when its install is witnessed (hook present, workflow written, settings installed); packaging formats cannot raise coverage (no plugin Surface exists); empty shims report nothing | `tests/test_coverage_honesty*.py`, `tests/test_agent_plugin.py::test_packaging_raises_no_coverage_claim` | | Compiled output is deterministic (T3, T5) | All generated artifacts written LF-normalized via one writer; PATCH releases are byte-identical by contract | golden-file suite (`tests/test_emitter_stability.py`) run in CI; `tests/test_windows_line_endings.py` | @@ -107,6 +107,16 @@ request as a standing check against this class of defect, with findings triaged - **Fail-open hosts.** Some agent hosts ignore unexpected hook exits; where Chock emits for such a host, the emitted description states the fail posture rather than implying parity. +- **The agent markers are environment variables.** `CLAUDECODE`, `AI_AGENT` and the + configured `agent_commit_env` names are what the vendor's process exports, not an + authenticated identity: a shell that unsets them is a person's as far as a hook can + tell. The person-only commands and the agent-commit rule hold for an agent whose + vendor marks its shell; the guards on an agent's own commands are the backstop for + one that strips its environment. +- **The pack hash covers source files, not bytecode**, and the vendored runtime's pin + is compared after the shared runtime is loaded: a planted `.pyc` or an edit to the + shared runtime is caught by `chock check --only verify` and by CI, not before the + file runs. Each of these is a documented limit with a stated mitigation path — the assurance the project offers is that the map matches the territory, including the holes. diff --git a/docs/decision-log.md b/docs/decision-log.md index 259f753d..c4961450 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -77,7 +77,7 @@ and `target` from the hook that ran it. A git hook's line carries `actor` only: | Field | Value | | :--- | :--- | -| `actor` | `agent:` for an agent hook (the payload's client, else the bundle's agent). On a git hook, the agent rule a commit is judged by: `CLAUDECODE=1` or `AI_AGENT` names the client, any other marker is `agent:unknown`, none (or `CHOCK_AGENT_COMMIT=0`) is `person` | +| `actor` | `agent:` for an agent hook (the payload's client, else the bundle's agent). On a git hook, the agent rule a commit is judged by: `CLAUDECODE=1` or `AI_AGENT` names the client, any other marker is `agent:unknown`, none is `person` (`CHOCK_AGENT_COMMIT=0` clears no vendor marker) | | `session_id` | the client's session id, when the payload carries one | | `target` | a fingerprint of what one tool call touched, never its content (below) | diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 68edaaaa..50540fe0 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -44,10 +44,12 @@ each guarantee holds. > empty, `0`, `false`, `no`, `off`), or `CLAUDECODE=1`, or `AI_AGENT` is non-empty, or a variable > named under `agent_commit_env:` in `.chock/config.yaml` is set. The pre-commit gate then judges > the commit at the event `agent-commit`: a waiver the commit adds is not honoured, one already in -> HEAD still is, and a refusal says which marker it saw. `CHOCK_AGENT_COMMIT=0` says "a person" and -> wins over every marker, for a person running git inside an agent's terminal; an agent could set -> it too, so it is a default and not an identity, and the guards on an agent's own commands are the -> separate backstop. Claude Code's two variables are witnessed reaching a git hook (a probe from +> HEAD still is, and a refusal says which marker it saw. `CHOCK_AGENT_COMMIT=0` unsets that one +> variable and clears no vendor marker (an agent could set it too): a person commits from their +> own shell, which has none. The guards on an agent's own commands are the separate backstop, and +> the commands that change what chock enforces (`sync`, `add`, `remove`, `enable`, `disable`, +> `init`, `compile`, `install-hooks`, `recompile`) refuse under any marker, as `chock stop` and +> `resume` do. Claude Code's two variables are witnessed reaching a git hook (a probe from > the Bash tool, 2026-09-29); Codex, Cursor and Copilot markers are unverified, so add them once > captured: `agent_commit_env: [CODEX_SANDBOX]`. Pre-push and CI are unchanged, and CI honours a > waiver in the range it reads. diff --git a/spec/gate-dsl.md b/spec/gate-dsl.md index c56f8689..a03c10f6 100644 --- a/spec/gate-dsl.md +++ b/spec/gate-dsl.md @@ -44,12 +44,12 @@ an `ask`, and an agent commit ignores it. A refusal prints the reason, then the **Agent commits.** A commit or push is an agent's when `CHOCK_AGENT_COMMIT` is truthy, or `CLAUDECODE=1`, or `AI_AGENT` is non-empty, or any variable named under `agent_commit_env:` in -`.chock/config.yaml` is non-empty. `CHOCK_AGENT_COMMIT` set to `0`, `false`, `no` or `off` says -"a person" and wins over every marker: the escape hatch for a person running git in an agent's -terminal. An agent can set it too: the marker is a default, not an authenticated identity, and -`CHOCK_ALLOW` is typed as easily. What backs them is the separate guards on an agent's own -commands and review, not the hook. A person answering an `ask` in an agent's terminal is told to -set both variables. An agent commit is judged at the event `agent-commit`: a +`.chock/config.yaml` is non-empty. `CHOCK_AGENT_COMMIT` set to `0`, `false`, `no` or `off` unsets +that one variable and clears no vendor marker (an agent can set it too): a person runs git from +their own shell, which has none. The markers are defaults, not an authenticated identity, and +`CHOCK_ALLOW` is typed as easily; what backs them is the separate guards on an agent's own commands +and review, not the hook. A refused `ask` tells a person the `CHOCK_ALLOW` line to run and tells +an agent to ask the person, naming no override. An agent commit is judged at `agent-commit`: a waiver it adds is not honoured. Only Claude Code's markers are witnessed (a git hook run by the Bash tool saw both, `src/chock/data/witnesses.json`, surface `git-hook-env`); Codex, Cursor and Copilot markers are unverified and are added per repository under `agent_commit_env:`. diff --git a/src/chock/cli.py b/src/chock/cli.py index 06cd80df..a1e2359e 100644 --- a/src/chock/cli.py +++ b/src/chock/cli.py @@ -8,10 +8,15 @@ import json import sys from importlib import import_module +from pathlib import Path from chock import __version__ from chock.pipe import guard_stdout, silence_interpreter_flush from chock.resources import package_data_dir +from chock.stop_common import person_only_refused + +#: A person-only command given this flag reports drift and writes nothing, so it stays open to an agent. +_CHECK_FLAG = "--check" def _module_main(module_name: str): @@ -30,15 +35,29 @@ def _main(argv: list[str] | None) -> int: return _main -def _build_group(group: dict[str, dict[str, str]]) -> dict[str, tuple]: - """One command name -> (lazy handler, help text), from a data/commands.json group.""" +def _build_group(group: dict[str, dict[str, object]]) -> dict[str, tuple]: + """One command name -> (lazy handler, help text, person-only), from a data/commands.json group. + + `person_only: true` marks a command that changes `.chock/config.yaml`, `chock.lock` or the compiled tree: + it refuses under an agent marker, the rule `chock resume` applies, so an agent cannot switch chock off. + """ built = {} for name, spec in group.items(): handler = _module_fn(spec["module"], spec["fn"]) if "fn" in spec else _module_main(spec["module"]) - built[name] = (handler, spec["help"]) + built[name] = (handler, spec["help"], bool(spec.get("person_only"))) return built +def _repo_root(argv: list[str]) -> Path: + """The `--repo` a command was given (its config can name further agent markers), else the working directory.""" + for index, arg in enumerate(argv): + if arg == "--repo" and index + 1 < len(argv): + return Path(argv[index + 1]) + if arg.startswith("--repo="): + return Path(arg.removeprefix("--repo=")) + return Path() + + _COMMANDS_JSON = json.loads(package_data_dir("chock", "data").joinpath("commands.json").read_text(encoding="utf-8")) EVERYDAY = _build_group(_COMMANDS_JSON["everyday"]) @@ -60,10 +79,10 @@ def _dispatch(argv: list[str]) -> int: if not argv or argv[0] in ("-h", "--help"): print(__doc__.strip()) print("\nEveryday:") - for name, (_, help_text) in EVERYDAY.items(): + for name, (_, help_text, _person_only) in EVERYDAY.items(): print(f" {name:10} {help_text}") print("\nAuthoring:") - for name, (_, help_text) in AUTHORING.items(): + for name, (_, help_text, _person_only) in AUTHORING.items(): print(f" {name:10} {help_text}") return 0 if argv[0] in ("--version", "-V"): @@ -73,7 +92,9 @@ def _dispatch(argv: list[str]) -> int: if command not in COMMANDS: print(f"Unknown command: {command!r}. Run 'chock --help'.", file=sys.stderr) return 2 - fn, _ = COMMANDS[command] + fn, _, person_only = COMMANDS[command] + if person_only and _CHECK_FLAG not in rest and person_only_refused(_repo_root(rest).resolve(), command): + return 1 return int(fn(rest) or 0) diff --git a/src/chock/config.py b/src/chock/config.py index 8c60faae..43425870 100644 --- a/src/chock/config.py +++ b/src/chock/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import subprocess from pathlib import Path from typing import Any @@ -24,6 +25,22 @@ def load_config(repo_root: Path | str) -> dict[str, Any]: return yamlio.safe_load(path.read_text(encoding="utf-8")) or {} +def committed_config_text(repo_root: Path | str) -> str | None: + """`.chock/config.yaml` as committed at HEAD; None when git cannot show it (no repository, no commit, no file).""" + try: + shown = subprocess.run( # noqa: S603 -- reading one committed file + ["git", "-C", str(repo_root), "show", f"HEAD:{CONFIG_DIR}/{CONFIG_NAME}"], # noqa: S607 + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError, UnicodeDecodeError): + return None + return shown.stdout if shown.returncode == 0 else None + + def agents_from_config(repo_root: Path) -> list[str]: """The agent list a repo compiles for: configured, or every supported agent.""" config = load_config(repo_root) @@ -86,10 +103,18 @@ def policy_status( config: dict[str, Any], policy_id: str, manifest: dict[str, Any] | None = None, + committed: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Resolve effective state for a policy from config + manifest.""" + """Resolve effective state for a policy from config + manifest. + + `committed` is the config as HEAD has it, given at an agent's event: a policy is then disabled only when + both say so, the way the committed rollout caps the working tree's, so an uncommitted disable changes + nothing for an agent. Absent (a person's command, or a check), the working tree's config decides. + """ manifest = manifest or {} disabled = _disabled_list(config) + if committed is not None: + disabled &= _disabled_list(committed) override = _policy_overrides(config).get(policy_id, {}) mandatory = bool(manifest.get("mandatory", False)) diff --git a/src/chock/data/commands.json b/src/chock/data/commands.json index 19751943..3619f52e 100644 --- a/src/chock/data/commands.json +++ b/src/chock/data/commands.json @@ -2,11 +2,13 @@ "everyday": { "init": { "module": "chock.scaffold.init", - "help": "Scaffold a consumer repo (wiring only -- no policies)" + "help": "Scaffold a consumer repo (wiring only -- no policies)", + "person_only": true }, "add": { "module": "chock.scaffold.add", - "help": "Install a policy or skill from a catalog and compile it" + "help": "Install a policy or skill from a catalog and compile it", + "person_only": true }, "install": { "module": "chock.install.cli", @@ -14,12 +16,14 @@ }, "remove": { "module": "chock.scaffold.remove", - "help": "Remove an installed policy and resync" + "help": "Remove an installed policy and resync", + "person_only": true }, "sync": { "module": "chock.lifecycle", "fn": "sync_main", - "help": "Recompile + rewire so the repo matches its policies (--ci/--skills for extras)" + "help": "Recompile + rewire so the repo matches its policies (--ci/--skills for extras)", + "person_only": true }, "check": { "module": "chock.history.cli", @@ -56,12 +60,14 @@ "enable": { "module": "chock.toggles", "fn": "enable_main", - "help": "Enable a policy by id" + "help": "Enable a policy by id", + "person_only": true }, "disable": { "module": "chock.toggles", "fn": "disable_main", - "help": "Disable a policy by id" + "help": "Disable a policy by id", + "person_only": true }, "bundle": { "module": "chock.guardrails.cli", @@ -75,7 +81,8 @@ }, "compile": { "module": "chock.compile.compiler", - "help": "Low-level single-policy compile" + "help": "Low-level single-policy compile", + "person_only": true }, "install-skills": { "module": "chock.scaffold.skills", @@ -130,7 +137,8 @@ "recompile": { "module": "chock.toggles", "fn": "recompile_main", - "help": "alias of: sync" + "help": "alias of: sync", + "person_only": true }, "refresh": { "module": "chock.index.cli", @@ -138,7 +146,8 @@ }, "install-hooks": { "module": "chock.hooks.install", - "help": "alias of: sync (hooks part)" + "help": "alias of: sync (hooks part)", + "person_only": true }, "install-ci": { "module": "chock.scaffold.install_ci", diff --git a/src/chock/gate/runner/actor.py b/src/chock/gate/runner/actor.py index 4da9089f..d2fb96c9 100644 --- a/src/chock/gate/runner/actor.py +++ b/src/chock/gate/runner/actor.py @@ -64,32 +64,14 @@ def _config_agent_env(repo_root: Path) -> list[str]: def agent_signal(repo_root: Path | None = None) -> str | None: - """The marker that makes this an agent's commit, or None for a person's. + """The first agent marker set, or None for a person's shell: a truthy `CHOCK_AGENT_COMMIT`, `CLAUDECODE=1`, + a non-empty `AI_AGENT`, or any non-empty variable named under the repository's `agent_commit_env`. - An explicit `CHOCK_AGENT_COMMIT` decides: truthy is an agent, `0|false|no|off` is a person - whatever else is set (an escape hatch for a person's own git in an agent's terminal, which an - agent can set too). Otherwise `CLAUDECODE=1`, a non-empty `AI_AGENT`, or any non-empty variable - named in the repository's `agent_commit_env` marks an agent. + One rule for every check (commit, push, tool call, stop, resume, the person-only commands). `CHOCK_AGENT_COMMIT=0` + clears nothing a vendor set, since an agent can set it too: a person's git in an agent's terminal is told apart + by having no vendor marker, which is what their own shell has. """ env = os.environ - explicit = env.get(AGENT_COMMIT_ENV, "").strip().lower() - if explicit in _HUMAN_ENV: - return None - if explicit: - return AGENT_COMMIT_ENV - if env.get(CLAUDECODE_ENV) == "1": - return f"{CLAUDECODE_ENV}=1" - if env.get(AI_AGENT_ENV, "").strip(): - return AI_AGENT_ENV - configured = _config_agent_env(repo_root) if repo_root is not None else [] - return next((name for name in configured if env.get(name)), None) - - -def stop_agent_signal(repo_root: Path | None = None) -> str | None: - """The first agent marker set, for every stop check (commit, push, stop, resume): `CHOCK_AGENT_COMMIT=0`, - the commit rule's escape hatch for a person's git in an agent's terminal, clears nothing, since an agent can - set it too.""" - env = os.environ named = env.get(AGENT_COMMIT_ENV, "").strip().lower() checks = ( (AGENT_COMMIT_ENV, bool(named) and named not in _HUMAN_ENV), @@ -222,19 +204,16 @@ def approval(repo_root: Path | None = None) -> dict[str, str]: def _ask_refusal(policy_id: str | None, signal: str | None) -> str: - """Why a hook refuses an `ask`, and the one way through: a person's explicit override.""" + """Why a hook refuses an `ask`, and the one way through: a person's explicit override, which only a person is told.""" if policy_id is None: return f"gate: this gate asks a person, a hook cannot prompt, and it has no policy id for {ALLOW_ENV} to name." - person = f"{ALLOW_ENV}={policy_id}" + lead = f"gate: {policy_id} asks a person to decide, and a hook cannot prompt." if signal: - person = f"{person} {AGENT_COMMIT_ENV}=0" - who = f" This is treated as an agent's ({signal}), and an agent cannot answer for the person." - else: - who = "" - return ( - f"gate: {policy_id} asks a person to decide, and a hook cannot prompt.{who} " - f"To go ahead, a person runs this one command from their own shell with {person} set." - ) + return ( + f"{lead} This is treated as an agent's ({signal}), and an agent cannot answer for the person: " + "ask them. If they agree, they run this one command from their own shell." + ) + return f"{lead} To go ahead, a person runs this one command from their own shell with {ALLOW_ENV}={policy_id} set." def _ask_answered(policy_id: str | None, signal: str | None) -> bool: diff --git a/src/chock/gate/runner/constants.py b/src/chock/gate/runner/constants.py index 540216d0..60d8c102 100644 --- a/src/chock/gate/runner/constants.py +++ b/src/chock/gate/runner/constants.py @@ -75,8 +75,8 @@ #: The event of both agent surfaces (pre-tool-use and stop), by the name policies declare. TOOL_USE_EVENT = "tool_use" -#: Env var a person or agent sets to say who is committing: truthy marks an agent, and a value in -#: `_HUMAN_ENV` marks a person and wins over every detected marker. +#: Env var that says an agent is acting: truthy marks an agent. A value in `_HUMAN_ENV` unsets this +#: one variable and clears no vendor marker, since an agent can set it too. AGENT_COMMIT_ENV = "CHOCK_AGENT_COMMIT" #: Markers Claude Code's Bash tool sets in every command it runs (witnessed; a git hook inherits @@ -117,7 +117,7 @@ _AGENT_COMMIT_NOTE = ( "gate: this commit is treated as an agent's ({signal}), so a waiver it adds " "was not honoured; only waivers already in HEAD count. A person reviews and waives it, then " - f"commits from their own shell with {AGENT_COMMIT_ENV}=0." + "commits from their own shell." ) diff --git a/src/chock/gate/runner/verdict.py b/src/chock/gate/runner/verdict.py index d1ec896a..8d1a5bfe 100644 --- a/src/chock/gate/runner/verdict.py +++ b/src/chock/gate/runner/verdict.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -from .actor import _ask_answered, _ask_refusal, _judged_event, agent_signal, approval, rollout, stop_agent_signal +from .actor import _ask_answered, _ask_refusal, _judged_event, agent_signal, approval, rollout from .constants import ( _ACTION_RANK, _EVENT_NAME, @@ -117,7 +117,7 @@ def halt_git(repo_root: Path, event: str, policy_id: str | None = None) -> tuple """An agent's commit or push while a stop reaches it: refused and logged with reason `stopped`, whatever `CHOCK_ALLOW`, `CHOCK_AGENT_COMMIT=0` or the rollout level say. A person's own commit or push is not the stop's: None.""" - signal = stop_agent_signal(repo_root) + signal = agent_signal(repo_root) who = _actor(signal) client = str(who["actor"]).removeprefix(ACTOR_AGENT_PREFIX) stop = (halt_find(halt_git_roots(repo_root), client) or halt_everywhere_read()) if signal is not None else None diff --git a/src/chock/guidance/advise.py b/src/chock/guidance/advise.py index 81e50b18..1ac15a98 100644 --- a/src/chock/guidance/advise.py +++ b/src/chock/guidance/advise.py @@ -8,7 +8,7 @@ from typing import Any from chock import resources, yamlio -from chock.config import policy_status +from chock.config import committed_config_text, policy_status from chock.guidance import match, selection from chock.guidance.match import Hit from chock.guidance.source import GuidanceError, load_catalog, read_text @@ -64,19 +64,34 @@ def _too_deep(text: str) -> bool: return any(len(line) - len(line.lstrip(" ")) > 2 * _MAX_DEPTH for line in text.splitlines()) -def _enabled(repo: Path, policy: str, cap: int) -> bool: - """Whether the repo's config leaves the policy on. The config is read like every other file here: - no symlink, no oversize file; a config that cannot be parsed is an error, never a guess.""" - raw = read_text(repo, _CONFIG, cap) - if raw is not None and _too_deep(raw): +def _parsed(text: str | None, cap: int) -> dict: + """One config text as a mapping (empty when absent): the depth and size caps apply before the parser runs.""" + if text is None: + return {} + if len(text.encode("utf-8")) > cap: + msg = f"{_CONFIG} is larger than {cap} bytes; refusing to read it" + raise GuidanceError(msg) + if _too_deep(text): msg = f"{_CONFIG} nests deeper than {_MAX_DEPTH} levels; refusing to parse it" raise GuidanceError(msg) + config = yamlio.safe_load(text) or {} + return config if isinstance(config, dict) else {} + + +def _enabled(repo: Path, policy: str, cap: int) -> bool: + """Whether the repo's config leaves the policy on for the agent asking. The config is read like every other + file here (no symlink, no oversize file; a config that cannot be parsed is an error, never a guess), and a + disable counts only once committed: the caller is an agent, and an uncommitted edit changes nothing for it.""" + raw = read_text(repo, _CONFIG, cap) + head = committed_config_text(repo) try: - config = (yamlio.safe_load(raw) or {}) if raw is not None else {} - return policy_status(config if isinstance(config, dict) else {}, policy)["state"] != "disabled" + config, committed = _parsed(raw, cap), _parsed(head, cap) + except GuidanceError: + raise except Exception as exc: msg = f"{_CONFIG} cannot be read ({type(exc).__name__})" raise GuidanceError(msg) from exc + return policy_status(config, policy, committed=committed)["state"] != "disabled" def _verdicts(repo: Path, rel: str, catalog_packs: dict[str, set[str]], cap: int) -> tuple[dict[str, str], str]: diff --git a/src/chock/stop_common.py b/src/chock/stop_common.py index f5604362..8f5136c0 100644 --- a/src/chock/stop_common.py +++ b/src/chock/stop_common.py @@ -6,12 +6,15 @@ import sys from pathlib import Path -from chock.gate.runner.actor import actor_of, stop_agent_signal +from chock.gate.runner.actor import actor_of, agent_signal from chock.gate.runner.halt import HALT_POLICY, HALT_REASON from chock.gate.runner.journal import log_write LOG_PARTS = (".chock", "log", "gate-events.jsonl") UNKNOWN = "unknown" +#: The log's policy id for a refused person-only command (`chock sync`, `enable`, `disable`, ...). +PERSON_ONLY_POLICY = "chock-cli" +_STOP_RULE = "Only a person stops or resumes agents; they run this from their own shell." LIMITS = ( "It refuses the NEXT action of an agent whose hooks Chock wires; it stops no running process " "and revokes no credential." @@ -27,26 +30,34 @@ def log_stop(repo: Path | None, event: str, fields: dict[str, object]) -> None: if repo is None: return record = {"route": "sync", "policy_id": HALT_POLICY, "event": event, **fields} - record["actor"] = actor_of(stop_agent_signal(repo)) + record["actor"] = actor_of(agent_signal(repo)) try: log_write(repo.joinpath(*LOG_PARTS), record, repo) except OSError as exc: sys.stderr.write(f"chock: the stop is in place, but its log line was not written: {exc}\n") -def agent_refused(repo: Path | None, event: str, fields: dict[str, object] | None = None) -> bool: - """Only a person changes a stop (the actor rule): an agent's `chock stop` or `chock resume` is refused, logged.""" - signal = stop_agent_signal(repo) +def agent_refused( + repo: Path | None, event: str, fields: dict[str, object] | None = None, *, rule: str = _STOP_RULE +) -> bool: + """The actor rule: an agent's `chock stop`, `resume` or person-only command is refused, with `rule` said, and + logged.""" + signal = agent_signal(repo) if signal is None: return False - sys.stderr.write( - f"chock: refused: this shell is marked as an agent's ({signal}). Only a person stops or resumes " - "agents; they run this from their own shell.\n" - ) + sys.stderr.write(f"chock: refused: this shell is marked as an agent's ({signal}). {rule}\n") log_stop(repo, event, {"verdict": "block", "reason": HALT_REASON, **(fields or {})}) return True +def person_only_refused(repo: Path, command: str) -> bool: + """`chock ` changes what chock enforces here (its config, lock or compiled tree): an agent's call is + refused and logged, so a wrapper script that edits `.chock/config.yaml` cannot make `chock sync` apply it.""" + fields = {"policy_id": PERSON_ONLY_POLICY, "reason": "agent", "command": command} + rule = f"Only a person changes what chock enforces here; a person runs `chock {command}` from their own shell." + return agent_refused(repo, command, fields, rule=rule) + + def write_private(path: Path, text: str) -> None: """Write `path` whole, readable by its owner alone (0o600), in a folder only they can list (0o700).""" path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) diff --git a/tests/test_agent_commit_detection.py b/tests/test_agent_commit_detection.py index afe985cb..18cc443c 100644 --- a/tests/test_agent_commit_detection.py +++ b/tests/test_agent_commit_detection.py @@ -50,11 +50,26 @@ def test_only_the_witnessed_values_count(tmp_path: Path, monkeypatch: pytest.Mon @pytest.mark.parametrize("off", ["0", "false", "no", "off", "FALSE", " 0 "]) -def test_an_explicit_off_wins_over_every_marker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, off: str) -> None: +def test_an_explicit_off_clears_no_vendor_marker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, off: str) -> None: + """`CHOCK_AGENT_COMMIT=0` is something an agent can set too: the vendor's marker still names it.""" _config(tmp_path, f"{CONFIG_AGENT_ENV_KEY}: [CODEX_SANDBOX]\n") - monkeypatch.setenv("CLAUDECODE", "1") - monkeypatch.setenv("AI_AGENT", "claude-code_1_agent") + monkeypatch.setenv(AGENT_COMMIT_ENV, off) + assert agent_signal(tmp_path) is None monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") + assert agent_signal(tmp_path) == "CODEX_SANDBOX" + monkeypatch.setenv("AI_AGENT", "claude-code_1_agent") + assert agent_signal(tmp_path) == "AI_AGENT" + monkeypatch.setenv("CLAUDECODE", "1") + assert agent_signal(tmp_path) == "CLAUDECODE=1" + + +@pytest.mark.parametrize("off", ["0", "false", "no", "off"]) +def test_an_explicit_off_unsets_only_its_own_variable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, off: str +) -> None: + """A shell that exported a truthy `CHOCK_AGENT_COMMIT` is a person's again with it off and no vendor marker.""" + monkeypatch.setenv(AGENT_COMMIT_ENV, "1") + assert agent_signal(tmp_path) == AGENT_COMMIT_ENV monkeypatch.setenv(AGENT_COMMIT_ENV, off) assert agent_signal(tmp_path) is None @@ -150,8 +165,8 @@ def test_a_detected_agent_commit_does_not_honour_the_waiver_it_adds( monkeypatch.setenv(name, value) assert run(_waivable_gate(repo), "pre-commit", None, repo) == 1 err = capsys.readouterr().err - assert "not honoured" in err - assert f"{AGENT_COMMIT_ENV}=0" in err + assert "not honoured" in err and "from their own shell" in err + assert f"{AGENT_COMMIT_ENV}=0" not in err def test_a_configured_marker_is_read_from_the_repo_the_gate_runs_in( @@ -166,13 +181,14 @@ def test_a_configured_marker_is_read_from_the_repo_the_gate_runs_in( assert run(gate, "pre-commit", None, repo) == 1 -def test_the_escape_hatch_lets_a_person_commit_in_an_agents_terminal( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_the_escape_hatch_does_not_open_in_an_agents_terminal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A person's own shell has no vendor marker; one that does is the agent's, whatever else it exports.""" repo = init_repo(tmp_path) _stage_waived(repo) monkeypatch.setenv("CLAUDECODE", "1") monkeypatch.setenv(AGENT_COMMIT_ENV, "0") + assert run(_waivable_gate(repo), "pre-commit", None, repo) == 1 + monkeypatch.delenv("CLAUDECODE") assert run(_waivable_gate(repo), "pre-commit", None, repo) == 0 diff --git a/tests/test_decision_principal.py b/tests/test_decision_principal.py index c611556c..f36da6e0 100644 --- a/tests/test_decision_principal.py +++ b/tests/test_decision_principal.py @@ -198,7 +198,8 @@ def test_a_gate_with_nothing_to_judge_leaves_the_line_to_the_runtime(tmp_path: P ({"AI_AGENT": "claude-code_2.1.0_agent"}, "agent:claude_code"), ({"AI_AGENT": "Some Agent!"}, "agent:someagent"), ({"CHOCK_AGENT_COMMIT": "1"}, "agent:unknown"), - ({"CHOCK_AGENT_COMMIT": "0", "CLAUDECODE": "1"}, "person"), + ({"CHOCK_AGENT_COMMIT": "0", "CLAUDECODE": "1"}, "agent:claude_code"), + ({"CHOCK_AGENT_COMMIT": "0"}, "person"), ], ) def test_a_git_hook_line_names_the_actor_by_the_actor_rule( diff --git a/tests/test_gate_actions.py b/tests/test_gate_actions.py index 4ea5fdc8..20f833f5 100644 --- a/tests/test_gate_actions.py +++ b/tests/test_gate_actions.py @@ -168,18 +168,30 @@ def test_an_agent_cannot_answer_an_ask( monkeypatch.setenv(name, value) assert run(_gate(repo, "ask"), "pre-commit", None, repo) == 1 err = capsys.readouterr().err - assert "treated as an agent's" in err - assert f"{ALLOW_ENV}={POLICY} CHOCK_AGENT_COMMIT=0" in err + assert "treated as an agent's" in err and "ask them" in err + assert "CHOCK_AGENT_COMMIT=0" not in err and f"{ALLOW_ENV}=" not in err -def test_a_person_in_an_agent_terminal_answers_with_the_escape_hatch( - repo: Path, monkeypatch: pytest.MonkeyPatch +def test_a_person_is_told_the_override_and_an_agent_is_not( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + """The recipe is printed to the shell that can honour it: a person's own, with no agent marker.""" + _stage(repo, BAD) + assert run(_gate(repo, "ask"), "pre-commit", None, repo) == 1 + assert f"from their own shell with {ALLOW_ENV}={POLICY} set" in capsys.readouterr().err + + +@pytest.mark.parametrize("marker", [{"CLAUDECODE": "1"}, {"AI_AGENT": "claude-code_1_agent"}]) +def test_the_escape_hatch_does_not_open_under_a_vendor_marker( + repo: Path, monkeypatch: pytest.MonkeyPatch, marker: dict ) -> None: + """`CHOCK_ALLOW=x CHOCK_AGENT_COMMIT=0` in an agent's terminal is what the agent itself can type.""" _stage(repo, BAD) - monkeypatch.setenv("CLAUDECODE", "1") + for name, value in marker.items(): + monkeypatch.setenv(name, value) monkeypatch.setenv(ALLOW_ENV, POLICY) monkeypatch.setenv("CHOCK_AGENT_COMMIT", "0") - assert run(_gate(repo, "ask"), "pre-commit", None, repo) == 0 + assert run(_gate(repo, "ask"), "pre-commit", None, repo) == 1 def test_ask_does_not_override_a_block(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_guidance_safety.py b/tests/test_guidance_safety.py index f712fd68..03657498 100644 --- a/tests/test_guidance_safety.py +++ b/tests/test_guidance_safety.py @@ -4,6 +4,7 @@ import ast import hashlib +import subprocess from pathlib import Path import pytest @@ -130,15 +131,35 @@ def test_an_oversize_selection_is_refused(tmp_path): assert ask(root, plan=PLAN, paths=[J + "F.java"])["isError"] is True -def test_a_disabled_policy_gives_no_guidance(tmp_path): - root = install(tmp_path) +def _disable(root, *, commit: bool) -> None: (root / ".chock").mkdir(exist_ok=True) (root / ".chock" / "config.yaml").write_text("policies:\n disabled: [java-security]\n", encoding="utf-8") + if commit: + for args in ( + ["init", "-q"], + ["add", ".chock/config.yaml"], + ["-c", "user.name=t", "-c", "user.email=t@x", "commit", "-qm", "off"], + ): + subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True) + + +def test_a_committed_disable_gives_no_guidance(tmp_path): + root = install(tmp_path) + _disable(root, commit=True) result = ask(root, plan=PLAN, paths=[J + "F.java"]) assert result["structuredContent"]["guidance"] == [] assert result["isError"] is False +def test_an_uncommitted_disable_changes_nothing_for_the_agent_asking(tmp_path): + """The caller is an agent: a disable it could have written itself counts only once a person committed it.""" + root = install(tmp_path) + _disable(root, commit=False) + result = ask(root, plan=PLAN, paths=[J + "F.java"]) + assert result["structuredContent"]["guidance"] + assert result["isError"] is False + + def test_a_repo_without_the_policy_gets_empty_guidance(tmp_path): (tmp_path / ".agents" / "policies").mkdir(parents=True) result = ask(tmp_path, plan=PLAN, paths=[J + "F.java"]) diff --git a/tests/test_person_only_commands.py b/tests/test_person_only_commands.py new file mode 100644 index 00000000..fdc539e6 --- /dev/null +++ b/tests/test_person_only_commands.py @@ -0,0 +1,138 @@ +"""An agent cannot switch chock off: the commands that change what it enforces refuse under an agent marker.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from conftest import init_repo +from decision_log_support import records + +from chock import cli +from chock.config import policy_status +from chock.stop_common import PERSON_ONLY_POLICY + +FRAMEWORK_ROOT = Path(__file__).resolve().parents[1] +POLICY = "block-no-verify" +#: Every command that writes `.chock/config.yaml`, `chock.lock` or the compiled tree. +PERSON_ONLY = ("sync", "recompile", "install-hooks", "enable", "disable", "add", "remove", "init", "compile") +REFUSED = "chock: refused: this shell is marked as an agent's" + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A scaffolded repository with one policy compiled, as a person left it.""" + (tmp_path / "repo").mkdir() + root = init_repo(tmp_path / "repo") + assert cli.main(["init", str(root), "--skip-hooks"]) == 0 + shutil.copytree(FRAMEWORK_ROOT / ".agents" / "policies" / POLICY, root / ".agents" / "policies" / POLICY) + assert cli.main(["recompile", "--repo", str(root), "--skip-hooks"]) == 0 + assert (root / ".chock" / "compiled" / POLICY).is_dir() + return root + + +def _disable_by_hand(root: Path) -> None: + """The review's wrapper script: the config edit itself, which a catalog guard refuses and chock here does not.""" + config = root / ".chock" / "config.yaml" + config.write_text(config.read_text(encoding="utf-8") + f"policies:\n disabled: [{POLICY}]\n", encoding="utf-8") + + +def test_the_disarm_chain_is_refused_at_sync( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _disable_by_hand(repo) + monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.delenv("CHOCK_GATE_LOG", raising=False) + monkeypatch.setenv("CHOCK_LOG_LEVEL", "info") + assert cli.main(["sync", "--repo", str(repo)]) == 1 + err = capsys.readouterr().err + assert REFUSED in err and "(CLAUDECODE=1)" in err + assert "a person runs `chock sync` from their own shell" in err + assert (repo / ".chock" / "compiled" / POLICY).is_dir(), "the compiled gate must still be there" + [record] = records(repo / ".chock" / "log" / "gate-events.jsonl") + assert record["policy_id"] == PERSON_ONLY_POLICY and record["event"] == "sync" + assert record["verdict"] == "block" and record["actor"] == "agent:claude_code" + + +@pytest.mark.parametrize("command", PERSON_ONLY) +@pytest.mark.parametrize( + "env", + [ + {"CLAUDECODE": "1"}, + {"AI_AGENT": "claude-code_1_agent"}, + {"CHOCK_AGENT_COMMIT": "1"}, + {"CLAUDECODE": "1", "CHOCK_AGENT_COMMIT": "0"}, + ], + ids=["claudecode", "ai_agent", "opt_in", "hatch_under_marker"], +) +def test_every_person_only_command_refuses_an_agent( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], command: str, env: dict +) -> None: + for name, value in env.items(): + monkeypatch.setenv(name, value) + assert cli.main([command, "--repo", str(repo)]) == 1 + assert f"a person runs `chock {command}` from their own shell" in capsys.readouterr().err + + +def test_a_configured_marker_is_read_from_the_named_repo( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + config = repo / ".chock" / "config.yaml" + config.write_text(config.read_text(encoding="utf-8") + "agent_commit_env: [CODEX_SANDBOX]\n", encoding="utf-8") + monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") + assert cli.main(["sync", f"--repo={repo}", "--skip-hooks"]) == 1 + assert "(CODEX_SANDBOX)" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "argv", + [["sync", "--check"], ["recompile", "--check"], ["status"], ["check", "--only", "validate"]], + ids=["sync-check", "recompile-check", "status", "check"], +) +def test_reading_commands_stay_open_to_an_agent( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], argv: list[str] +) -> None: + monkeypatch.setenv("CLAUDECODE", "1") + cli.main([*argv, "--repo", str(repo)]) + assert REFUSED not 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") + assert cli.main(["disable", POLICY, "--repo", str(repo)]) == 0 + assert not (repo / ".chock" / "compiled" / POLICY).exists() + assert cli.main(["enable", POLICY, "--repo", str(repo)]) == 0 + assert (repo / ".chock" / "compiled" / POLICY).is_dir() + + +def test_help_still_lists_every_command() -> None: + assert all(len(entry) == 3 for entry in cli.COMMANDS.values()) # handler, help, person-only + assert {name for name, (_, _, person_only) in cli.COMMANDS.items() if person_only} == set(PERSON_ONLY) + + +# --- an uncommitted disable changes nothing for an agent --------------------------------------------------- + + +def test_a_disable_counts_for_an_agent_only_once_committed() -> None: + working = {"policies": {"disabled": [POLICY]}} + assert policy_status(working, POLICY)["state"] == "disabled" + assert policy_status(working, POLICY, committed={})["state"] == "enabled" + assert policy_status(working, POLICY, committed=working)["state"] == "disabled" + assert policy_status({}, POLICY, committed=working)["state"] == "enabled" + assert policy_status(working, POLICY, {"mandatory": True}, committed=working)["state"] == "enabled" + + +def test_the_committed_config_is_what_head_holds(repo: Path) -> None: + from chock.config import committed_config_text + + assert committed_config_text(repo) is None + _disable_by_hand(repo) + assert committed_config_text(repo) is None + from subprocess import run + + run(["git", "-C", str(repo), "add", "-A"], check=True, capture_output=True) + run(["git", "-C", str(repo), "commit", "-qm", "off"], check=True, capture_output=True) + assert json.dumps(committed_config_text(repo)).count(POLICY) == 1 diff --git a/tests/test_rollout.py b/tests/test_rollout.py index 981f7560..0578466a 100644 --- a/tests/test_rollout.py +++ b/tests/test_rollout.py @@ -161,7 +161,17 @@ def test_a_person_may_set_the_level_for_their_own_commit(repo: Path, monkeypatch assert _judge(repo, "pre-commit") == (0, "warn") -@pytest.mark.parametrize("env", [{"CLAUDECODE": "1"}, {"AI_AGENT": "claude-code_2_agent"}, {"CHOCK_AGENT_COMMIT": "1"}]) +@pytest.mark.parametrize( + "env", + [ + {"CLAUDECODE": "1"}, + {"AI_AGENT": "claude-code_2_agent"}, + {"CHOCK_AGENT_COMMIT": "1"}, + # `CHOCK_AGENT_COMMIT=0` clears no vendor marker: an agent can set it too. + {"CLAUDECODE": "1", "CHOCK_AGENT_COMMIT": "0"}, + {"AI_AGENT": "claude-code_2_agent", "CHOCK_AGENT_COMMIT": "0"}, + ], +) def test_an_agents_commit_ignores_the_override(repo: Path, monkeypatch: pytest.MonkeyPatch, env: dict) -> None: for name, value in env.items(): monkeypatch.setenv(name, value) diff --git a/tests/test_script_hook_verdicts.py b/tests/test_script_hook_verdicts.py index c6e34581..e57ebc92 100644 --- a/tests/test_script_hook_verdicts.py +++ b/tests/test_script_hook_verdicts.py @@ -127,9 +127,11 @@ def test_an_agent_cannot_answer_for_the_person(repo: Path, marker: dict) -> None assert "treated as an agent's" in done.stderr -def test_the_escape_hatch_answers_from_an_agent_terminal(repo: Path) -> None: +def test_the_escape_hatch_does_not_answer_from_an_agent_terminal(repo: Path) -> None: + """`CHOCK_AGENT_COMMIT=0` clears no vendor marker: the agent could type it itself.""" env = {"CHOCK_ALLOW": POLICY_ID, "CLAUDECODE": "1", "CHOCK_AGENT_COMMIT": "0"} - assert _commit(repo, "ask", env).returncode == 0 + assert _commit(repo, "ask", env).returncode == 1 + assert _commit(repo, "ask", {"CHOCK_ALLOW": POLICY_ID, "CHOCK_AGENT_COMMIT": "0"}).returncode == 0 def test_a_warn_is_the_same_for_an_agent(repo: Path) -> None: From 4993c608cec7c79f5d969e8947c056cab0eaeb2e Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:32:15 +0000 Subject: [PATCH 2/4] docs(surfaces): rewrap the agent-commit paragraph within the line budget (CH-1) Co-Authored-By: Claude Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/enforcement-surfaces.md | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 50540fe0..d820b2bb 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -38,21 +38,19 @@ each guarantee holds. > the pen. Only lines the turn changed against HEAD are judged, so an old violation in a touched > file does not block the turn. > -> **An agent's commit is not a human's.** The commit hook cannot ask who ran `git commit`, and an -> agent that can run it could add the waiver itself, so chock reads the environment the hook -> inherits from git. A commit is an agent's when `CHOCK_AGENT_COMMIT` is truthy (any value but -> empty, `0`, `false`, `no`, `off`), or `CLAUDECODE=1`, or `AI_AGENT` is non-empty, or a variable -> named under `agent_commit_env:` in `.chock/config.yaml` is set. The pre-commit gate then judges -> the commit at the event `agent-commit`: a waiver the commit adds is not honoured, one already in -> HEAD still is, and a refusal says which marker it saw. `CHOCK_AGENT_COMMIT=0` unsets that one -> variable and clears no vendor marker (an agent could set it too): a person commits from their -> own shell, which has none. The guards on an agent's own commands are the separate backstop, and -> the commands that change what chock enforces (`sync`, `add`, `remove`, `enable`, `disable`, -> `init`, `compile`, `install-hooks`, `recompile`) refuse under any marker, as `chock stop` and -> `resume` do. Claude Code's two variables are witnessed reaching a git hook (a probe from -> the Bash tool, 2026-09-29); Codex, Cursor and Copilot markers are unverified, so add them once -> captured: `agent_commit_env: [CODEX_SANDBOX]`. Pre-push and CI are unchanged, and CI honours a -> waiver in the range it reads. +> **An agent's commit is not a human's.** The commit hook cannot ask who ran `git commit`, and an agent +> that can run it could add the waiver itself, so chock reads the environment the hook inherits from git. +> A commit is an agent's when `CHOCK_AGENT_COMMIT` is truthy (any value but empty, `0`, `false`, `no`, +> `off`), or `CLAUDECODE=1`, or `AI_AGENT` is non-empty, or a variable named under `agent_commit_env:` in +> `.chock/config.yaml` is set. The pre-commit gate then judges the commit at the event `agent-commit`: a +> waiver the commit adds is not honoured, one already in HEAD still is, and a refusal says which marker +> it saw. `CHOCK_AGENT_COMMIT=0` unsets that one variable and clears no vendor marker (an agent could set +> it too): a person commits from their own shell, which has none. The guards on an agent's own commands +> are the separate backstop, and `sync`, `add`, `remove`, `enable`, `disable`, `init`, `compile`, +> `install-hooks` and `recompile` refuse under any marker, as `chock stop` does. Claude Code's two +> variables are witnessed reaching a git hook (a probe from the Bash tool, 2026-09-29); Codex, Cursor and +> Copilot markers are unverified, so add them once captured: `agent_commit_env: [CODEX_SANDBOX]`. +> Pre-push and CI are unchanged, and CI honours a waiver in the range it reads. > > **`action: warn | ask`.** A gate's `action` picks what a violation does (full table: > `spec/gate-dsl.md`). `warn` never blocks: at commit, push and ci the reason is printed (a From b80f7fd8bef2b10bbf771ac620ed8d497a8f93c4 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:36:37 +0000 Subject: [PATCH 3/4] fix(sessionstart): the unarmed-clone advice tells an agent to ask a person to run chock sync (CH-1) `chock sync` now refuses an agent's shell, so a sentence inviting the agent to run it was false. The Claude Code runtime golden carries the new text; the vendored .chock/bin/claude_code.py follows at the person-run regen. Co-Authored-By: Claude Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- src/chock/gate/sessionstart.py | 5 +++-- tests/fixtures/runtime_goldens/claude_code.py | 2 +- tests/test_sessionstart_arm.py | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/chock/gate/sessionstart.py b/src/chock/gate/sessionstart.py index f84476a8..6cf19659 100644 --- a/src/chock/gate/sessionstart.py +++ b/src/chock/gate/sessionstart.py @@ -11,9 +11,10 @@ _INSTRUCTION = ( "Chock: this clone's git hooks are NOT installed -- git never clones hooks, " - "so commit-time gates will not run locally until someone runs:\n" + "so commit-time gates will not run locally until a person runs, from their own shell:\n" " pip install chock && chock sync --repo .\n" - "Run that before the first commit. (The repo's CI gate, where wired, enforces regardless.)" + "Ask them to run it before the first commit; `chock sync` refuses an agent's shell. " + "(The repo's CI gate, where wired, enforces regardless.)" ) diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 06b67c5f..4309b3db 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -842,7 +842,7 @@ def _chock_load_runtime(): _GIT = _chock_shutil.which('git') or 'git' -_INSTRUCTION = "Chock: this clone's git hooks are NOT installed -- git never clones hooks, so commit-time gates will not run locally until someone runs:\n pip install chock && chock sync --repo .\nRun that before the first commit. (The repo's CI gate, where wired, enforces regardless.)" +_INSTRUCTION = "Chock: this clone's git hooks are NOT installed -- git never clones hooks, so commit-time gates will not run locally until a person runs, from their own shell:\n pip install chock && chock sync --repo .\nAsk them to run it before the first commit; `chock sync` refuses an agent's shell. (The repo's CI gate, where wired, enforces regardless.)" def _repo_root() -> _chock_Path: root = _chock_os.environ.get('CLAUDE_PROJECT_DIR') diff --git a/tests/test_sessionstart_arm.py b/tests/test_sessionstart_arm.py index 577294e5..dea1347c 100644 --- a/tests/test_sessionstart_arm.py +++ b/tests/test_sessionstart_arm.py @@ -165,6 +165,8 @@ def test_adapter_instructs_when_chock_is_not_importable(tmp_path: Path, monkeypa context = decision["hookSpecificOutput"]["additionalContext"] assert "chock sync --repo ." in context assert "NOT installed" in context + # The reader is an agent, and `chock sync` refuses an agent's shell: the advice is to ask a person. + assert "a person runs, from their own shell" in context and "Ask them to run it" in context def test_adapter_arms_a_fresh_clone_end_to_end(tmp_path: Path) -> None: From 94091a3b8bd80abdc246ce0944ed332857bfabb9 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:30:35 -0400 Subject: [PATCH 4/4] regen: .chock/bin after CH-1 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/claude_code.py | 2 +- .chock/bin/gate.py | 53 ++++++++++++--------------------------- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 06b67c5f..4309b3db 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -842,7 +842,7 @@ def _chock_load_runtime(): _GIT = _chock_shutil.which('git') or 'git' -_INSTRUCTION = "Chock: this clone's git hooks are NOT installed -- git never clones hooks, so commit-time gates will not run locally until someone runs:\n pip install chock && chock sync --repo .\nRun that before the first commit. (The repo's CI gate, where wired, enforces regardless.)" +_INSTRUCTION = "Chock: this clone's git hooks are NOT installed -- git never clones hooks, so commit-time gates will not run locally until a person runs, from their own shell:\n pip install chock && chock sync --repo .\nAsk them to run it before the first commit; `chock sync` refuses an agent's shell. (The repo's CI gate, where wired, enforces regardless.)" def _repo_root() -> _chock_Path: root = _chock_os.environ.get('CLAUDE_PROJECT_DIR') diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index d87ce213..fb77a0ce 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -287,8 +287,8 @@ def head_blob(self, path: str) -> str: #: The event of both agent surfaces (pre-tool-use and stop), by the name policies declare. TOOL_USE_EVENT = "tool_use" -#: Env var a person or agent sets to say who is committing: truthy marks an agent, and a value in -#: `_HUMAN_ENV` marks a person and wins over every detected marker. +#: Env var that says an agent is acting: truthy marks an agent. A value in `_HUMAN_ENV` unsets this +#: one variable and clears no vendor marker, since an agent can set it too. AGENT_COMMIT_ENV = "CHOCK_AGENT_COMMIT" #: Markers Claude Code's Bash tool sets in every command it runs (witnessed; a git hook inherits @@ -329,7 +329,7 @@ def head_blob(self, path: str) -> str: _AGENT_COMMIT_NOTE = ( "gate: this commit is treated as an agent's ({signal}), so a waiver it adds " "was not honoured; only waivers already in HEAD count. A person reviews and waives it, then " - f"commits from their own shell with {AGENT_COMMIT_ENV}=0." + "commits from their own shell." ) @@ -364,32 +364,14 @@ def _config_agent_env(repo_root: Path) -> list[str]: def agent_signal(repo_root: Path | None = None) -> str | None: - """The marker that makes this an agent's commit, or None for a person's. + """The first agent marker set, or None for a person's shell: a truthy `CHOCK_AGENT_COMMIT`, `CLAUDECODE=1`, + a non-empty `AI_AGENT`, or any non-empty variable named under the repository's `agent_commit_env`. - An explicit `CHOCK_AGENT_COMMIT` decides: truthy is an agent, `0|false|no|off` is a person - whatever else is set (an escape hatch for a person's own git in an agent's terminal, which an - agent can set too). Otherwise `CLAUDECODE=1`, a non-empty `AI_AGENT`, or any non-empty variable - named in the repository's `agent_commit_env` marks an agent. + One rule for every check (commit, push, tool call, stop, resume, the person-only commands). `CHOCK_AGENT_COMMIT=0` + clears nothing a vendor set, since an agent can set it too: a person's git in an agent's terminal is told apart + by having no vendor marker, which is what their own shell has. """ env = os.environ - explicit = env.get(AGENT_COMMIT_ENV, "").strip().lower() - if explicit in _HUMAN_ENV: - return None - if explicit: - return AGENT_COMMIT_ENV - if env.get(CLAUDECODE_ENV) == "1": - return f"{CLAUDECODE_ENV}=1" - if env.get(AI_AGENT_ENV, "").strip(): - return AI_AGENT_ENV - configured = _config_agent_env(repo_root) if repo_root is not None else [] - return next((name for name in configured if env.get(name)), None) - - -def stop_agent_signal(repo_root: Path | None = None) -> str | None: - """The first agent marker set, for every stop check (commit, push, stop, resume): `CHOCK_AGENT_COMMIT=0`, - the commit rule's escape hatch for a person's git in an agent's terminal, clears nothing, since an agent can - set it too.""" - env = os.environ named = env.get(AGENT_COMMIT_ENV, "").strip().lower() checks = ( (AGENT_COMMIT_ENV, bool(named) and named not in _HUMAN_ENV), @@ -1290,19 +1272,16 @@ def approval(repo_root: Path | None = None) -> dict[str, str]: def _ask_refusal(policy_id: str | None, signal: str | None) -> str: - """Why a hook refuses an `ask`, and the one way through: a person's explicit override.""" + """Why a hook refuses an `ask`, and the one way through: a person's explicit override, which only a person is told.""" if policy_id is None: return f"gate: this gate asks a person, a hook cannot prompt, and it has no policy id for {ALLOW_ENV} to name." - person = f"{ALLOW_ENV}={policy_id}" + lead = f"gate: {policy_id} asks a person to decide, and a hook cannot prompt." if signal: - person = f"{person} {AGENT_COMMIT_ENV}=0" - who = f" This is treated as an agent's ({signal}), and an agent cannot answer for the person." - else: - who = "" - return ( - f"gate: {policy_id} asks a person to decide, and a hook cannot prompt.{who} " - f"To go ahead, a person runs this one command from their own shell with {person} set." - ) + return ( + f"{lead} This is treated as an agent's ({signal}), and an agent cannot answer for the person: " + "ask them. If they agree, they run this one command from their own shell." + ) + return f"{lead} To go ahead, a person runs this one command from their own shell with {ALLOW_ENV}={policy_id} set." def _ask_answered(policy_id: str | None, signal: str | None) -> bool: @@ -1401,7 +1380,7 @@ def halt_git(repo_root: Path, event: str, policy_id: str | None = None) -> tuple """An agent's commit or push while a stop reaches it: refused and logged with reason `stopped`, whatever `CHOCK_ALLOW`, `CHOCK_AGENT_COMMIT=0` or the rollout level say. A person's own commit or push is not the stop's: None.""" - signal = stop_agent_signal(repo_root) + signal = agent_signal(repo_root) who = _actor(signal) client = str(who["actor"]).removeprefix(ACTOR_AGENT_PREFIX) stop = (halt_find(halt_git_roots(repo_root), client) or halt_everywhere_read()) if signal is not None else None