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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ docs/assets/promo/frames_test/
docs/assets/promo/audio_work/
docs/assets/promo/audio.wav
dist-test/
# chock: the gate runtime and compiled gates are what every hook runs -- tracked even where
# a global rule (a `bin/` from a Visual Studio or Java template) would ignore them
!.chock/bin/
!.chock/bin/**
!.chock/compiled/
!.chock/compiled/**
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Chock changelog

## Unreleased

- **A global `bin/` ignore no longer takes the gate out of an adopter's repository.** The `bin/`
rule in the Visual Studio, .NET and Java gitignore templates, common in a developer's global
excludes file, matched `.chock/bin/`: `git add -A` never tracked the runtime every agent hook
runs, and a clone, a CI checkout or a `git clean -x` left `.claude/settings.json`,
`.cursor/hooks.json`, `.codex/hooks.json` and `.devin/hooks.json` naming a file that was not
there. Claude Code reported `SessionStart:startup hook error ... can't open file
'.chock/bin/claude_code.py'`, and no gate judged anything from then on: a hook whose script
cannot start refuses every call with an unrelated error on a client that reads its exit code 2
as a block, and lets every call through on one that treats a failed hook as non-blocking.
`chock init` and `chock sync` now add `!.chock/bin/` and `!.chock/compiled/` (and `/**` under
each) to the repository's .gitignore, which outranks a global excludes file.
Existing adopters get the rules at their next `chock sync`; commit them with the files.
- **`chock check` names a hook target git would not keep.** The dangling-hook check caught only a
runtime that was missing here. It now reports a runtime that git ignores, naming the rule and
where it lives, and covers the compiled gate a hook hands the runtime as well as the runtime
itself.

## 0.11.2 — The plugins page states each client's own crash answer

- **The plugins page states each client's own answer to a crashed guard.** Every tree's
Expand Down
6 changes: 5 additions & 1 deletion docs/adopting.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ reinstalled:
3. `ls .chock/compiled/scan-secrets/git-hook/gate.json` should exist.
4. Commit a file containing a credential. `scan-secrets` should block it and print the policy message.

If your consumer `.gitignore` contains a broad `.chock/` rule, add un-ignore lines so the committed artifacts stay tracked:
`chock init` and `chock sync` write these un-ignore lines to your `.gitignore`, so a global ignore
rule cannot keep the gate out of your commits. The `bin/` in the Visual Studio, .NET and Java
templates is the usual culprit: it matches `.chock/bin/`, and every agent hook then names a file a
clone does not have. `chock check` reports a hook target that is missing or git-ignored. If your
own `.gitignore` has a broad `.chock/` rule, keep the lines below after it:

```gitignore
!.chock/bin/
Expand Down
5 changes: 3 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ chock init .

- creates `.chock/` (config, `coverage.json`, `dependency-allowlist.txt`, the vendored
runtime under `bin/` and compiled output under `compiled/`) plus `chock.lock` at the repo root and an empty `.agents/policies/`,
- writes `AGENTS.md`, a `.gitattributes` pinning generated scripts to LF, a `.gitignore` rule for
the per-machine gate log (`.chock/log/`), a wrapper file for each agent that does **not** read
- writes `AGENTS.md`, a `.gitattributes` pinning generated scripts to LF, `.gitignore` rules that
ignore the per-machine gate log (`.chock/log/`) and keep the gate runtime and compiled gates
(`.chock/bin/`, `.chock/compiled/`) tracked even where a global `bin/` rule would ignore them, a wrapper file for each agent that does **not** read
`AGENTS.md` natively (root `CLAUDE.md` for Claude Code, and by default nothing else — Cursor,
Copilot, Codex, Gemini, VS Code and Windsurf all read `AGENTS.md` directly), and the
guardrail pairs `.agents/policies/{AGENTS.md,CLAUDE.md}` and `.agents/skills/{AGENTS.md,CLAUDE.md}`,
Expand Down
52 changes: 52 additions & 0 deletions src/chock/scaffold/gitrules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""The two things chock says about an adopter's .gitignore: what must stay out, and what must stay in.

The gate outcome log is per machine and grows on every commit, so it is ignored. The gate runtime
(`.chock/bin/`) and the compiled gates (`.chock/compiled/`) are what every agent hook and git hook
runs, so they are tracked -- explicitly, because a *global* ignore rule reaches them otherwise: the
`bin/` in the Visual Studio, .NET and Java gitignore templates matches `.chock/bin/`, `git add -A`
skips it, and every clone, CI checkout and `git clean -x` then has hooks naming a file that is not
there. A repository's own .gitignore outranks the global excludes file, so the negation wins.

Written once, appended, never rewritten: a rule the adopter already has, in a form git honours the
same way, is left alone.
"""

from __future__ import annotations

from pathlib import Path

from chock.emit import write_generated

GATE_LOG_IGNORE = ".chock/log/"
#: Re-included whatever a global excludes file says: the directories (against a `bin/` rule) and
#: everything under them (against a rule that names the files, a `*.json`, say).
TRACKED_RUNTIME = ("!.chock/bin/", "!.chock/bin/**", "!.chock/compiled/", "!.chock/compiled/**")

_BLOCKS = (
((GATE_LOG_IGNORE,), "# chock: the gate outcome log is per machine and grows on every commit"),
(
TRACKED_RUNTIME,
"# chock: the gate runtime and compiled gates are what every hook runs -- tracked even where\n"
"# a global rule (a `bin/` from a Visual Studio or Java template) would ignore them",
),
)


def _normal(rule: str) -> str:
return rule.strip().rstrip("/")


def ensure_git_rules(repo_root: Path) -> None:
"""Append whichever of chock's rules the repository's .gitignore does not already carry."""
gitignore = Path(repo_root) / ".gitignore"
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
present = {_normal(line) for line in existing.splitlines()}
added = ""
for rules, comment in _BLOCKS:
missing = [rule for rule in rules if _normal(rule) not in present]
if missing:
added += comment + "\n" + "".join(f"{rule}\n" for rule in missing)
if not added:
return
joiner = "" if not existing or existing.endswith("\n") else "\n"
write_generated(gitignore, existing + joiner + added)
22 changes: 2 additions & 20 deletions src/chock/scaffold/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
write_instructions,
)
from chock.scaffold.agents_md import update_agents_md
from chock.scaffold.gitrules import ensure_git_rules
from chock.scaffold.recompile import BookkeepingError, discover_policy_dirs, recompile
from chock.scaffold.skills import install_skills
from chock.scaffold.templates import (
Expand Down Expand Up @@ -96,29 +97,10 @@ def _write_config(repo_root: Path, agents: list[str], *, agent_agnostic: bool) -
allowlist = path.parent / "dependency-allowlist.txt"
if not allowlist.exists():
allowlist.write_text(_dependency_allowlist_template(), encoding="utf-8")
_ignore_gate_log(repo_root)
ensure_git_rules(repo_root)
return path


GATE_LOG_IGNORE = ".chock/log/"


def _ignore_gate_log(repo_root: Path) -> None:
"""Keep the per-machine gate outcome log out of the adopter's commits.

Every git hook appends to it, so an adopter who ran `git add -A` after `chock init`
committed a file that then changed on every commit they made. Appended once, never
rewritten: an existing rule, in any form git already honours, is left alone.
"""
gitignore = repo_root / ".gitignore"
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
if any(line.strip().rstrip("/") == GATE_LOG_IGNORE.rstrip("/") for line in existing.splitlines()):
return
block = f"# chock: the gate outcome log is per machine and grows on every commit\n{GATE_LOG_IGNORE}\n"
joiner = "" if not existing or existing.endswith("\n") else "\n"
write_generated(gitignore, existing + joiner + block)


def _normalize_agents(args_agents: list[str] | None, *, agent_agnostic: bool, repo_root: Path) -> list[str]:
if agent_agnostic:
return sorted(CHOCK_AGENT)
Expand Down
4 changes: 4 additions & 0 deletions src/chock/scaffold/recompile.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from chock.output import warn
from chock.policies import discover_policy_dirs
from chock.registry.core import save_registry, scan
from chock.scaffold.gitrules import ensure_git_rules
from chock.vendored import vendored_differences
from chock.vendors import CHOCK_AGENT

Expand Down Expand Up @@ -210,6 +211,9 @@ def recompile(repo_root: Path | str, agents: list[str], *, skip_hooks: bool = Fa
(chock_dir / "bin").mkdir(parents=True, exist_ok=True)
shutil.copy2(str(staged_runner), str(chock_dir / "bin" / "gate.py"))

# An adopter who ran `init` before the runtime was tracked explicitly gets the rule here.
ensure_git_rules(repo_root)

write_generated_json(coverage_path, coverage)

if not skip_hooks:
Expand Down
81 changes: 81 additions & 0 deletions src/chock/validation/checks_hook_targets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Hook targets: every file a chock-written agent hook runs must exist here and in every clone."""

from __future__ import annotations

import re
import shutil
import subprocess
from pathlib import Path

from chock import vendors
from chock.hooks.in_agent_install import WIRED_VENDORS, agent_hooks_rel
from chock.validation.report import Finding, Report

#: A file an agent hook command runs or hands the gate: the runtime (`.chock/bin/<vendor>.py`)
#: and the compiled gate it reads. chock is the only writer of both directories, so naming a
#: path under them identifies an entry as chock's own, independent of the vendor-specific shapes
#: `in_agent_merged.py`/`in_agent_generic.py` merge it through.
_BIN_TARGET_RE = re.compile(r"\.chock/(?:bin/[\w.-]+\.py|compiled/[\w./-]+\.json)")


def _ignore_rule(root: Path, rel: str) -> str | None:
"""The rule git would ignore `rel` by (`source:line:pattern`), or None; None outside a repo.

A tracked file is never ignored, so this names only a file a clone would not have. Whether
it is ignored is asked plainly: `check-ignore -v` also reports a path a `!` negation
re-includes, and exits 0 for it, so it only names the rule once the answer is yes.
"""
git = shutil.which("git")
if git is None:
return None
base = [git, "-C", str(root), "check-ignore"]
ignored = subprocess.run([*base, "-q", "--", rel], capture_output=True, check=False) # noqa: S603 -- asking git
if ignored.returncode != 0:
return None
named = subprocess.run([*base, "-v", "--", rel], capture_output=True, text=True, check=False) # noqa: S603
return named.stdout.strip().split("\t", 1)[0] or "a gitignore rule"


def _dangling_reason(root: Path, target: str) -> str | None:
"""Why a hook naming `target` fails -- here, or in every clone -- or None when it does not."""
rule = _ignore_rule(root, target)
if rule is not None:
return (
f"is ignored by git ({rule}), so a clone, a teammate's checkout or CI has no such file "
"and the hook fails there. A global `bin/` rule is the usual cause: `chock sync` adds "
"`!.chock/bin/` and `!.chock/compiled/` to .gitignore; commit them and the files."
)
if not (root / target).exists():
return "does not exist. Run `chock sync` to reinstall or uninstall this vendor's hooks."
return None


def check_dangling_hook_targets(root: Path, report: Report) -> None:
"""A chock-written hook entry naming a file that is missing, or that git would not keep.

`sync` wires in-agent hooks only for the vendors `supported_agents` names and prunes a
vendored runtime once its vendor falls out of that list (`recompile.py`); a hook config
still naming the deleted runtime is a client-side failure on every tool call, silent to
both `chock sync --check` and `chock check` unless something reads the config back. A
runtime that exists here but is git-ignored fails the same way in every other checkout:
there, no hook can start the gate at all.
"""
paths = [root / vendors.config_path(vendor) for vendor in WIRED_VENDORS]
paths.append(root / agent_hooks_rel())

seen: set[tuple[str, str]] = set()
for path in paths:
if not path.exists():
continue
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
for target in _BIN_TARGET_RE.findall(text):
key = (str(path), target)
if key in seen:
continue
seen.add(key)
reason = _dangling_reason(root, target)
if reason is not None:
report.add(Finding(str(path), "dangling_hook_target", "error", f"{path} runs {target}, which {reason}"))
43 changes: 0 additions & 43 deletions src/chock/validation/checks_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,12 @@

import yaml

from chock import vendors
from chock.hooks.in_agent_install import WIRED_VENDORS, agent_hooks_rel
from chock.index.builder import max_tokens_for
from chock.index.cli import is_stale
from chock.scaffold.agents_md import POINTER_BLOCK, POINTER_END, POINTER_START
from chock.validation.loading import discover_artifacts
from chock.validation.report import Finding, Report

#: `.chock/bin/<vendor>.py`, wherever it turns up inside a hook command: chock is the only
#: writer of that directory, so naming a path under it identifies an entry as chock's own,
#: independent of the vendor-specific shapes `in_agent_merged.py`/`in_agent_generic.py` merge
#: it through.
_BIN_TARGET_RE = re.compile(r"\.chock/bin/[\w.-]+\.py")

_POINTER_RE = re.compile(
re.escape(POINTER_START) + r"(.*?)" + re.escape(POINTER_END),
re.DOTALL,
Expand Down Expand Up @@ -232,41 +224,6 @@ def _tracked_under(root: Path, rel: str) -> list[str]:
return result.stdout.split() if result.returncode == 0 else []


def check_dangling_hook_targets(root: Path, report: Report) -> None:
"""A chock-written hook entry naming a `.chock/bin/` runtime `sync` already deleted.

`sync` wires in-agent hooks only for the vendors `supported_agents` names and prunes a
vendored runtime once its vendor falls out of that list (`recompile.py`); a hook config
still naming the deleted runtime is a client-side failure on every tool call, silent to
both `chock sync --check` and `chock check` unless something reads the config back.
"""
paths = [root / vendors.config_path(vendor) for vendor in WIRED_VENDORS]
paths.append(root / agent_hooks_rel())

seen: set[tuple[str, str]] = set()
for path in paths:
if not path.exists():
continue
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
for target in _BIN_TARGET_RE.findall(text):
key = (str(path), target)
if key in seen or (root / target).exists():
continue
seen.add(key)
report.add(
Finding(
str(path),
"dangling_hook_target",
"error",
f"{path} runs {target}, which does not exist. Run `chock sync` to reinstall or "
"uninstall this vendor's hooks.",
)
)


def check_ambient_token_budget(root: Path, report: Report) -> None:
"""Spec F2: the attention surface in INDEX.md must fit index.max_tokens (default 2000)."""
index_path = root / ".agents" / "policies" / "INDEX.md"
Expand Down
2 changes: 1 addition & 1 deletion src/chock/validation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
check_registry_freshness,
)
from chock.validation.checks_evals import check_eval_first
from chock.validation.checks_hook_targets import check_dangling_hook_targets
from chock.validation.checks_manifest_advice import check_manifest_advice
from chock.validation.checks_manifest_schema import check_manifest_schema
from chock.validation.checks_orchestration import (
Expand All @@ -44,7 +45,6 @@
check_adapter_integrity,
check_ambient_rule_blocks,
check_ambient_token_budget,
check_dangling_hook_targets,
check_gate_log_untracked,
check_release_consistency,
)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dangling_hook_target_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from conftest import baseline_policy, init_repo

from chock.scaffold.recompile import recompile
from chock.validation.checks_repo import check_dangling_hook_targets
from chock.validation.checks_hook_targets import check_dangling_hook_targets
from chock.validation.report import Report
from chock.vendors import CHOCK_AGENT

Expand Down
7 changes: 5 additions & 2 deletions tests/test_gate_log_is_not_committed.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

from conftest import init_repo

from chock.scaffold.init import GATE_LOG_IGNORE, cmd_init
from chock.scaffold.gitrules import GATE_LOG_IGNORE
from chock.scaffold.init import cmd_init
from chock.validation.checks_repo import check_gate_log_untracked
from chock.validation.report import Report

Expand Down Expand Up @@ -55,7 +56,9 @@ def test_a_rule_the_adopter_already_wrote_is_respected(tmp_path: Path) -> None:
repo = init_repo(tmp_path)
(repo / ".gitignore").write_text(".chock/log\n", encoding="utf-8")
assert _init(repo) == 0
assert (repo / ".gitignore").read_text(encoding="utf-8") == ".chock/log\n"
lines = (repo / ".gitignore").read_text(encoding="utf-8").splitlines()
assert lines[0] == ".chock/log"
assert GATE_LOG_IGNORE not in lines


# --- and validate says so when the log was committed anyway --------------------------------------
Expand Down
Loading
Loading