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
4 changes: 2 additions & 2 deletions .agents/policies/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/chock/index/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)


Expand Down
12 changes: 10 additions & 2 deletions src/chock/index/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"] + [""])

Expand Down
45 changes: 45 additions & 0 deletions tests/test_index_in_agent_gates.py
Original file line number Diff line number Diff line change
@@ -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")
Loading