From aa34ab8b3fc9e4826a6c78aa535a26239a689ac0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:16:58 +0000 Subject: [PATCH] index: say where a gate runs, commit/push and in the agent The gates heading said enforced at commit/push only, but a gate compiled for tool use also refuses the write in the agent. Mark those gates and reword the heading. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .agents/policies/INDEX.md | 4 +-- CHANGELOG.md | 9 ++++++ src/chock/index/builder.py | 6 ++++ src/chock/index/render.py | 12 ++++++-- tests/test_index_in_agent_gates.py | 45 ++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/test_index_in_agent_gates.py diff --git a/.agents/policies/INDEX.md b/.agents/policies/INDEX.md index 049b871..ee23c41 100644 --- a/.agents/policies/INDEX.md +++ b/.agents/policies/INDEX.md @@ -53,11 +53,11 @@ before_add(file|abstraction|feature): need.exists AND repeated if not: leave_out; on_find(unused): delete -## Gates — enforced automatically at commit/push +## Gates — enforced automatically at commit/push, and in the agent where noted - **block-invisible-unicode**: Invisible or direction-override Unicode detected in staged changes. These characters change how code reads to a human or hide instructions an agent will still obey. Remove them, or add 'pragma: allowlist invisible-unicode' on the same line for a documented exception (e.g. a test fixture). - **block-wildcard-agent-permissions**: Wildcard agent permission grant detected. Scope the grant to specific tools or commands (e.g. Bash(git status:*), a named tool list), or add 'pragma: allowlist broad-agency' on the same line for a reviewed exception. -- **pin-github-actions**: Unpinned GitHub Action detected: a workflow references an action by a tag or branch (owner/repo at a movable ref) rather than a full 40-character commit SHA. Pin it to the SHA -- keep the version in a trailing comment for readability -- so a re-tagged or compromised release cannot change what runs. At commit, 'pragma: allowlist unpinned-action' on the same line marks a deliberate exception; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize. +- **pin-github-actions**: Unpinned GitHub Action detected: a workflow references an action by a tag or branch (owner/repo at a movable ref) rather than a full 40-character commit SHA. Pin it to the SHA -- keep the version in a trailing comment for readability -- so a re-tagged or compromised release cannot change what runs. At commit, 'pragma: allowlist unpinned-action' on the same line marks a deliberate exception; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize. Also checked in the agent: before a write, or at the end of the turn, depending on the agent. - **protect-main-branch**: Direct commits/pushes to a protected branch (main|master) are blocked. Create a feature branch and open a pull request. - **scan-secrets**: Potential secret detected in staged changes. Remove credentials and rotate any exposed keys. Add '# pragma: allowlist secret' on the same line only for documented test fixtures. - **test-integrity**: Tests were weakened, not fixed. If a test is genuinely obsolete, say so on the line that removes it with `chock: test-removal-reviewed` and have a human confirm it. diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b7482..c6362b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Chock changelog +## Unreleased + +- **INDEX.md says where a gate runs.** The generated index headed its gates "enforced + automatically at commit/push", so an agent reading it could expect nothing until a commit -- + while a gate compiled for tool use refuses the write in the turn. The heading now says gates + run at commit/push and in the agent where noted, and each gate declared `on: tool_use` ends + with "Also checked in the agent: before a write, or at the end of the turn, depending on the + agent." Adopters pick it up on their next `chock sync`. + ## 0.11.4 — An edit is judged as the file it would leave, and bytecode no longer fails a pack - **An edit is judged before it lands, not only at the turn's end.** Claude Code changes an diff --git a/src/chock/index/builder.py b/src/chock/index/builder.py index de362d2..092a7c5 100644 --- a/src/chock/index/builder.py +++ b/src/chock/index/builder.py @@ -28,6 +28,8 @@ class IndexEntry: description: str rule_text: str = "" manifest_path: str = "" + #: The gate is also compiled for the agent's own hooks (`on: tool_use`), not only for git. + in_agent: bool = False def priority(self) -> int: return _ENFORCEMENT_ORDER.get(self.enforcement, 3) @@ -81,8 +83,11 @@ def _entry_from_manifest(artifact_dir: Path, manifest: dict[str, Any], rel_path: rule_text = substitute_policy_vars(str((manifest.get("rule") or {}).get("text", "")), artifact_dir) description = _one_liner(manifest.get("description", "")) + in_agent = False if artifact == "hook": description = _summarize_hook(manifest, artifact_dir, root) + gate = (manifest.get("hook") or {}).get("gate") or {} + in_agent = "tool_use" in (gate.get("on") or []) return IndexEntry( id=pid, @@ -92,6 +97,7 @@ def _entry_from_manifest(artifact_dir: Path, manifest: dict[str, Any], rel_path: description=description, rule_text=rule_text, manifest_path=rel_path, + in_agent=in_agent, ) diff --git a/src/chock/index/render.py b/src/chock/index/render.py index a6e0b1e..1bf2bc0 100644 --- a/src/chock/index/render.py +++ b/src/chock/index/render.py @@ -26,8 +26,16 @@ def _rule_lines(entry: IndexEntry) -> list[str]: return [f"- **{entry.id}**:", *(f" {line}" for line in body)] +#: Said once per in-agent gate. INDEX.md is shared by every agent, so it names both places the +#: agent may meet the refusal: before the write where its hooks can refuse one, at the end of +#: the turn where they can only judge what it left. +GATES_HEADING = "## Gates — enforced automatically at commit/push, and in the agent where noted" +IN_AGENT_NOTE = "Also checked in the agent: before a write, or at the end of the turn, depending on the agent." + + def _gate_line(entry: IndexEntry) -> str: - return f"- **{entry.id}**: {entry.description or 'automatic gate'}" + line = f"- **{entry.id}**: {entry.description or 'automatic gate'}" + return f"{line} {IN_AGENT_NOTE}" if entry.in_agent else line def _skill_line(entry: IndexEntry) -> str: @@ -49,7 +57,7 @@ def _render_main(entries: list[IndexEntry], *, has_extended: bool) -> str: if sections["rule"]: lines.extend(["## Rules — always apply", ""] + sections["rule"] + [""]) if sections["hook"]: - lines.extend(["## Gates — enforced automatically at commit/push", ""] + sections["hook"] + [""]) + lines.extend([GATES_HEADING, ""] + sections["hook"] + [""]) if sections["skill"]: lines.extend(["## Skills — invoke when the task matches", ""] + sections["skill"] + [""]) diff --git a/tests/test_index_in_agent_gates.py b/tests/test_index_in_agent_gates.py new file mode 100644 index 0000000..07df939 --- /dev/null +++ b/tests/test_index_in_agent_gates.py @@ -0,0 +1,45 @@ +"""INDEX.md says where a gate runs. + +The heading once said gates are enforced "at commit/push", and an agent reading it could fairly +expect nothing until then -- while a gate compiled for tool use refuses its write in the turn. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from chock.index.builder import build_entries +from chock.index.render import GATES_HEADING, IN_AGENT_NOTE, render_index + + +def _gate(tmp_path: Path, policy_id: str, on: list[str]) -> None: + dir_ = tmp_path / ".agents" / "policies" / policy_id + dir_.mkdir(parents=True) + manifest = { + "id": policy_id, + "name": policy_id, + "version": "0.1.0", + "description": f"Description for {policy_id}.", + "artifact": "hook", + "enforcement": "block", + "provenance": {"author": "x", "license": "Apache-2.0", "trust_tier": "sandbox"}, + "hook": {"gate": {"kind": "content_regex", "on": on, "message": f"{policy_id} refused.", "params": {}}}, + } + (dir_ / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + + +def _line(index: str, policy_id: str) -> str: + return next(line for line in index.splitlines() if line.startswith(f"- **{policy_id}**")) + + +def test_a_gate_compiled_for_tool_use_is_marked_as_checked_in_the_agent(tmp_path: Path) -> None: + _gate(tmp_path, "in-agent", ["commit", "tool_use"]) + _gate(tmp_path, "commit-only", ["commit"]) + entries, _ = build_entries(tmp_path) + index = render_index(entries, 2000).main + assert GATES_HEADING in index + assert "in the agent" in GATES_HEADING + assert _line(index, "in-agent").endswith(IN_AGENT_NOTE) + assert IN_AGENT_NOTE not in _line(index, "commit-only")