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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .chock/bin/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
53 changes: 16 additions & 37 deletions .chock/bin/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
)


Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions changelog.d/agent-cannot-switch-chock-off.md
Original file line number Diff line number Diff line change
@@ -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=<id> 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.
2 changes: 1 addition & 1 deletion docs/agentic-risk-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref>` | `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 |

Expand Down
20 changes: 15 additions & 5 deletions docs/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ and `target` from the hook that ran it. A git hook's line carries `actor` only:

| Field | Value |
| :--- | :--- |
| `actor` | `agent:<client>` 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:<client>` 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) |

Expand Down
26 changes: 13 additions & 13 deletions docs/enforcement-surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +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` 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
> 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
Expand Down
Loading
Loading