Add opt-in read-budget guard and cheap-model bulk-read/code-write delegation (#1137)

Bring the Spotify Claude Code context-budget setup into AgentOps as one
opt-in PreToolUse guard (skills/cc-hooks/hooks/read-budget-guard.sh, policy
core.context:unbounded-read) with its opt-in installer, two Workflow-tool
conveyors (bulk-read, code-write), two plugin subagents (bulk-reader,
code-writer), their docs, regenerated projections, and three bats suites.
Nothing ships wired by default; the skill menu is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bDH2j6XDPPcLQS84PdzN
This commit is contained in:
Bo
2026-09-12 12:58:20 -04:00
committed by GitHub
parent 6e63d64caa
commit e32e88c338
36 changed files with 3628 additions and 16 deletions
+8
View File
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- The opt-in read-budget guard, `skills/cc-hooks/hooks/read-budget-guard.sh` (policy `core.context:unbounded-read`): a PreToolUse `Read|Bash` hook that blocks an unbounded `Read`, `cat`, `head` or `tail` of a file over the line budget (`AOP_READ_BUDGET_LINES`, default 350), names the two correct moves (a bounded slice or `bulk-reader` delegation), honors `AOP_WAIVE`, the waiver file and `AGENTOPS_HOOKS_DISABLED`, and appends hashed telemetry. It ships inert; `scripts/install-read-budget-guard.sh` is the opt-in installer (user, `--project` or `SETTINGS` scope).
- The `bulk-read` workflow (`workflows/bulk-read.js`): one cheap reader agent per file, in parallel, reading in guard-compatible slices and returning line-referenced bullets with truthful `lines_covered` / `complete`; the file bytes never enter the caller's context.
- The `code-write` workflow (`workflows/code-write.js`): one cheap writer agent per item from a spec plus a required reference file, matching the reference's patterns, writing only its distinct target and returning a receipt (path, line count, check result) the caller never reads back.
- The `bulk-reader` and `code-writer` plugin subagents (`agents/bulk-reader.md`, `agents/code-writer.md`): the same reader and writer modes as Agent-tool `subagent_type` targets, `haiku` by default; `bulk-reader` is read-only.
- Docs for the context-budget pattern: the `cc-hooks` `READ-BUDGET-GUARD.md` recipe, its `GUARDRAIL-VALUE-PROOF.md` entry and skill-spec reference, the `agent-native` `context-budget-delegation.md` reference plus a Reader / Writer note in its Roles, the `workflows/README.md` shapes and context-budget paragraph, and one pointer in `docs/agent-workflow-reference.md`.
## [3.6.0] - 2026-08-17
AgentOps 3.6 is the **operations-layer alignment** release, and most of the
+30
View File
@@ -0,0 +1,30 @@
---
name: bulk-reader
description: Read large files or many files on the caller's behalf and return line-referenced bullets only; the file bytes never enter the caller's context. Use when a file exceeds the read budget or the read-budget guard blocked a Read.
tools: Read, Grep, Glob, Bash
disallowedTools: Write, Edit
model: haiku
---
You are a bulk reader. Your return is the ONLY thing the caller sees; the file
bytes never reach the caller's context. When invoked:
1. Take the question and the file list from the prompt (`files: <path>` lines;
a relative path resolves against the working directory)
2. Read every file COMPLETELY in slices with the Read tool: `offset` + `limit`,
with `limit` at most 350 lines, or `$AOP_READ_BUDGET_LINES` when the caller
states another budget. Advance `offset` until a slice returns fewer lines
than `limit`. Never issue an unbounded Read, `cat`, `head` or `tail` — an
opt-in read-budget hook may block them, and a blocked read is not coverage
3. Answer the question with bullets only, most relevant first
Return format:
- Each bullet starts with a reference, `path:line` or `path:start-end`, then
one line of at most 200 characters
- At most 40 bullets unless the caller sets another cap
- No prose, no preamble, no closing summary, no multi-line code
- Per file, the lines covered and whether coverage was complete; a missing,
binary or unreadable file yields zero bullets and one note saying so
Never modify files: no Write, no Edit, no mutating Bash. Report coverage
truthfully — a partial read is reported as partial, never padded.
+31
View File
@@ -0,0 +1,31 @@
---
name: code-writer
description: Write one file from a spec plus a required reference file, matching the reference's patterns, and return a receipt (path, line count, check result) without echoing the content. Use for patterned or boilerplate code the caller should not read back.
tools: Read, Write, Edit, Grep, Glob, Bash
model: haiku
---
You are a code writer. The caller will not read the file you write; it sees
only your receipt, and independent validation happens elsewhere. When invoked:
1. Take the spec, the reference file and the target path from the prompt. The
reference is required: with no reference, stop and report that instead of
writing anything
2. Read the reference in slices with the Read tool (`offset` + `limit`, with
`limit` at most 350 lines, or `$AOP_READ_BUDGET_LINES` when the caller
states another budget) to learn its patterns: naming, imports, error
handling, test shape
3. Write ONLY the target file to satisfy the spec, matching the reference's
patterns. Code only: no markdown fences, no prose outside normal code
comments
4. If the caller gives a check command, run it ONCE with Bash after writing and
record whether it passed plus the last 20 lines of its output
Return a receipt only:
- target path, whether it was written, and its line count
- whether the check ran, whether it passed, and the output tail when a check
was given
- a summary of at most 300 characters saying what was written, with no code
Do not create, edit or delete any other file. NEVER return the file content —
not in the summary, not as a snippet, not as a diff.
+8
View File
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- The opt-in read-budget guard, `skills/cc-hooks/hooks/read-budget-guard.sh` (policy `core.context:unbounded-read`): a PreToolUse `Read|Bash` hook that blocks an unbounded `Read`, `cat`, `head` or `tail` of a file over the line budget (`AOP_READ_BUDGET_LINES`, default 350), names the two correct moves (a bounded slice or `bulk-reader` delegation), honors `AOP_WAIVE`, the waiver file and `AGENTOPS_HOOKS_DISABLED`, and appends hashed telemetry. It ships inert; `scripts/install-read-budget-guard.sh` is the opt-in installer (user, `--project` or `SETTINGS` scope).
- The `bulk-read` workflow (`workflows/bulk-read.js`): one cheap reader agent per file, in parallel, reading in guard-compatible slices and returning line-referenced bullets with truthful `lines_covered` / `complete`; the file bytes never enter the caller's context.
- The `code-write` workflow (`workflows/code-write.js`): one cheap writer agent per item from a spec plus a required reference file, matching the reference's patterns, writing only its distinct target and returning a receipt (path, line count, check result) the caller never reads back.
- The `bulk-reader` and `code-writer` plugin subagents (`agents/bulk-reader.md`, `agents/code-writer.md`): the same reader and writer modes as Agent-tool `subagent_type` targets, `haiku` by default; `bulk-reader` is read-only.
- Docs for the context-budget pattern: the `cc-hooks` `READ-BUDGET-GUARD.md` recipe, its `GUARDRAIL-VALUE-PROOF.md` entry and skill-spec reference, the `agent-native` `context-budget-delegation.md` reference plus a Reader / Writer note in its Roles, the `workflows/README.md` shapes and context-budget paragraph, and one pointer in `docs/agent-workflow-reference.md`.
## [3.6.0] - 2026-08-17
AgentOps 3.6 is the **operations-layer alignment** release, and most of the
+3
View File
@@ -31,6 +31,9 @@ native goals and direct coding do not require it. A skill earns its context
cost by resolving a task-specific need, not by occupying a phase in a sequence.
No new scheduler, command or process ledger is needed. The grandfathered pure
fixed-dispatch adapter is separately described in its own reference.
Optional context-budget tooling (an opt-in read-budget hook plus bulk-read /
code-write delegation) is described in
[context-budget delegation](../skills/agent-native/references/context-budget-delegation.md).
For exact intent snapshots, subject manifests, evidence storage, scope and
freshness, use [RPI traversal](architecture/rpi-traversal.md) and
@@ -73,6 +73,13 @@ stalled, and rescue is usually cheaper than rerun.
- **Validator:** receives exact candidate content in a fresh, read-only context.
- **Scribe:** records runtime evidence without judging acceptance.
Reader and Writer are bounded cheap delegations, not roles with authority: a
Reader returns line-referenced bullets over files the caller never loads, and a
Writer lands one patterned file from a spec plus a reference file and returns a
receipt the caller never reads back. Both are caller-selected per call, default
to a cheap model, and yield runtime facts only — a receipt is not validation.
See [context-budget delegation](references/context-budget-delegation.md).
## Contract
For a caller-selected parallel batch, validate every complete packet before the
@@ -0,0 +1,68 @@
# Context-Budget Delegation (Reader / Writer)
Keep large file bytes out of the working context by delegating reads and
patterned writes to bounded cheap contexts that return line-referenced bullets
or receipts. Spotify published an internal Claude Code setup built this way and
claims roughly a 90% token reduction; that is Spotify's claim about Spotify's
setup, not a measurement made here. The mechanical finding is what matters: the
same read rule placed in CLAUDE.md was advisory and ignored, and every line of
an unbounded read is re-sent on every later turn for the rest of the session.
## Three layers
| Layer | AgentOps surface | Authority |
|---|---|---|
| Advisory | this reference and the `agent-native` Roles note | none; context the agent may ignore |
| Delegation | `bulk-reader` / `code-writer` subagents (`agents/`), `bulk-read` / `code-write` workflows (`workflows/`) | caller-selected per call |
| Enforcement | the opt-in read-budget guard: [READ-BUDGET-GUARD.md](../../cc-hooks/references/READ-BUDGET-GUARD.md) | mechanical once installed; inert by default |
The delegation surfaces live in the AgentOps source checkout: the subagents are
Claude Code plugin agents and the workflows are Claude-only thin conveyors
(`workflows/README.md`). Neither ships with a standalone installed skill.
## Reader and Writer as bounded cheap delegations
- **Reader** (`bulk-reader` subagent, `bulk-read` workflow): the caller passes a
question and file paths; the reader reads each file completely in slices and
returns bullets only, each starting with `path:line` or `path:start-end`, at
most 40 unless the caller sets another cap, plus truthful `lines_covered` and
`complete`. The caller sees bullets, never bytes, so a follow-up question costs
one more cheap call and zero main-context lines.
- **Writer** (`code-writer` subagent, `code-write` workflow): the caller passes a
spec, a REQUIRED reference file and one target path; the writer matches the
reference's patterns, writes only the target, optionally runs one check, and
returns a receipt (path, line count, check result, a short summary). The caller
never reads the result back.
- Both are one-shot: nothing is kept between calls and AgentOps stores no
delegated file. A dead worker returns an explicit error, never silence.
## Guard compatibility
Readers and writers slice: `Read` with `offset` + `limit`, `limit` at most the
budget (350 lines by default, `AOP_READ_BUDGET_LINES` when set). A subagent's
own reads run under the same PreToolUse hook as the caller's, so an unbounded
read inside a delegate is blocked the same way. The guard never fires on a
bounded slice or on a file at or below budget, so a compliant reader is never
blocked and the delegation works whether or not the guard is installed.
## Model selection belongs to the caller
`haiku` is the default for both delegations; the caller may pin another model
per call (`model` in the workflow args, or the subagent's `model` field). Codex
has no PreToolUse hooks, so only the delegation layer applies there: dispatch a
fresh cheap `codex exec` per [codex-exec](../../codex-exec/SKILL.md) with the
same reader or writer prompt. [model-dispatch](model-dispatch.md) still governs
judgment legs; a reader or writer is an execution role, never a judge.
## Doctrine
- A receipt is a runtime fact, not validation. `written: true`, a line count or
`check_ok: true` proves that a process ran, nothing about acceptance.
[Validate](../../validate/SKILL.md) stays fresh and author-distinct over the
exact written content; the writer's context can never issue that PASS.
- Reader bullets are evidence with a locator, not authority. Re-open the cited
lines before a decision that depends on them.
- No new AO command, scheduler or budget account. The guard is a standalone
opt-in recipe with an installer (ADR-0002: a hook earns its lease on life only
as an optional runtime adapter); the delegations are caller-selected per call;
nothing counts tokens on the agent's behalf or renews a spent bound.
+24 -1
View File
@@ -181,6 +181,28 @@ positive value"), the criterion whose absence killed 2.x hooks (#511).
Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md)
## Read-Budget Guard (opt-in)
A PreToolUse `Read|Bash` guard that DENIES an **unbounded read over the line
budget** (`AOP_READ_BUDGET_LINES`, default 350): a `Read` with no `limit`, or a
`cat`/`head`/`tail` whose effective line count exceeds it. The Spotify finding:
the same rule in CLAUDE.md was advisory and ignored, and an over-budget read
re-sends its lines on every later turn. The predicate is a LOOKUP (`wc -l` on
the exact argument), so it is a standalone guard, never a registry policy. A
`limit`-bounded slice, a file at/below budget, a pipe, a redirect or quoted text
that merely mentions `cat` never fires. Nothing un-reads bytes once in context → every
attempt blocks (exit 2 + stderr): full message once per session naming the two
correct moves (slice it, or delegate to the `bulk-reader` subagent /
`bulk-read` workflow), one short line after. Waive once with
`AOP_WAIVE=core.context:unbounded-read`; hashed telemetry adds `tool`, `lines`,
`budget` plus the dispatcher's `mode`/`decision` pair. Ships INERT — opt-in installer:
```bash
scripts/install-read-budget-guard.sh # user scope; --project for project
```
Recipe: [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md)
## Policy Dispatch Engine (ships by default)
The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher —
@@ -312,7 +334,7 @@ claude --debug # Hook execution details
## Output Specification
- **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit) stay inert until opted in.
- **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit, read-budget) stay inert until opted in.
- **Filename:** preserve `settings.json`; give scripts descriptive executable filenames rather than embedding large shell programs in JSON.
- **Format:** valid Claude hook JSON using event arrays, matchers, and command objects; hook stdout/stderr and exit codes follow the selected event schema.
- **Exit code:** validate with `jq -e '.hooks | type=="object"' <settings.json>` and a representative silent/fire test for each matcher; any parse error, noisy happy path, or recursion risk blocks activation.
@@ -329,6 +351,7 @@ claude --debug # Hook execution details
- [HOOK-EVENTS.md](references/HOOK-EVENTS.md) - All events with full schemas
- [DCG-RCH.md](references/DCG-RCH.md) - Production examples (dcg, rch)
- [INSTALLED-SKILL-EDIT-GUARD.md](references/INSTALLED-SKILL-EDIT-GUARD.md) - Opt-in guard routing installed-skill edits to repo skills/ (keystone)
- [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md) - Opt-in guard denying unbounded reads over the line budget; pairs with bulk-read / code-write delegation
- [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md) - Pre-registered value-proof methodology + per-fire telemetry contract (ADR-0002 l.58)
- [PATTERNS.md](references/PATTERNS.md) - Auto-format, logging, notifications
- [JSON-OUTPUT.md](references/JSON-OUTPUT.md) - Response schemas
@@ -143,3 +143,44 @@ jq -r 'select(.token_class=="installed-skill-edit") | .path_sha256' \
No raw path is ever available in the ledger — only hashes — so the read is
privacy-preserving by construction.
## Read-budget guard (core.context:unbounded-read)
The opt-in read-budget guard (`skills/cc-hooks/hooks/read-budget-guard.sh`,
recipe [READ-BUDGET-GUARD.md](READ-BUDGET-GUARD.md)) reuses this sensor and
this decision rule. Its `token_class` is the policy id
`core.context:unbounded-read`; each line carries five extra fields:
```json
{"ts":"…","session":"…","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
```
- `mode` / `decision` — the dispatcher's pair: `mode` is always `deny` (this
guard never routes); `decision` is `deny` (a fire) or `waived` (an
`AOP_WAIVE` waiver let the call through: one line, no fire).
- `tool` — `Read` or `Bash`.
- `lines` / `budget` — JSON numbers: the effective line count of the offending
read and the budget it exceeded. `path_sha256` hashes the RESOLVED path; the
raw path and the raw command are never written.
**Metric:** the same declining fire-attempt rate per session. Secondary,
stated-denominator estimate: `sum(lines)` over `decision == "deny"` lines is an
upper bound on lines kept out of context (denominator = fires the guard saw; it
says nothing about pipes, redirects, globs, `sed`, `awk`, `less` — silent by design).
**Countermetric:** waiver rate = `waived / (deny + waived)` per session.
**CUT signals (any one):** a fire on a `limit`-bounded Read or on a file at or
below budget — a false positive the predicate is built to make impossible, so
one such line is a defect, not noise; or a waiver rate above 50% at N ≥ 30 —
the budget is wrong for this repository, not the agent (retune
`AOP_READ_BUDGET_LINES`; do not keep a guard everyone waives).
Same **N ≥ 30** minimum and **null-is-acceptable** rule as above: a flat attempt
rate with zero false fires and zero happy-path output is KEEP. Ships INERT —
zero lines until installed; ADR-0002 l.58 is not cleared at landing here either.
```bash
jq -r 'select(.token_class=="core.context:unbounded-read") | [.session,.decision,.tool,.lines] | @tsv' \
"${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}"
```
@@ -0,0 +1,298 @@
# Read-Budget Guard (opt-in)
A PreToolUse `Read|Bash` guard that blocks an **unbounded read of a file over
the line budget** — a `Read` with no `limit`, or a `cat` / `head` / `tail`
whose effective line count exceeds `AOP_READ_BUDGET_LINES` (default 350) — and
names the two correct moves: read a slice, or delegate the file to a cheap
reader that returns line-referenced bullets. AgentOps is hookless by default —
this guard ships **inert**; you activate it with the opt-in installer.
## Why it exists — the rule CLAUDE.md could not enforce
Spotify open-sourced its internal Claude Code setup and reports (its claim, not
re-measured here) a ~90% token cut. The part that transfers is not the number
but the finding behind it: v1 put "never read a large file whole" in CLAUDE.md
and the rule was ignored — advisory context, delta≈0, the same result AgentOps
measured in #511. The rule only held once it moved into a PreToolUse hook that
refuses the tool call and points at the bounded alternatives.
The cost it guards is compounding, not one-shot. An unbounded read of an N-line
file puts N lines into this context **and re-sends them on every later turn**
of the session. A 2,000-line read on turn 3 is paid again on turns 4 through
40. A bounded slice costs its slice once; a delegated read costs a few bullets,
because the file bytes never enter the caller's context at all.
## The predicate — a LOOKUP, so a standalone guard
The policy dispatcher registry (`policies/policies.json`) only lets a
`predicate_class: pure` regex over the raw command or `file_path` `deny` (the #511
anti-lesson). "Is this file over 350 lines?" is not a regex: it is a
**lookup** — one deterministic local check, `wc -l` on the exact argument, no
repo state, no history, no model. So this guard ships as a standalone opt-in
recipe next to [INSTALLED-SKILL-EDIT-GUARD.md](INSTALLED-SKILL-EDIT-GUARD.md)
and never as a registry policy, even though it borrows the registry's id form
(`core.context:unbounded-read`), its waiver mechanics and its telemetry line.
No false-positive surface by construction for the shapes it judges: a `Read`
with a numeric `limit` never fires (a bounded slice is the correct move,
whatever `offset` says); a file at or below budget never fires; a path that is
missing, a directory or binary never fires; a pipe or redirect never fires;
quoted text that merely mentions `cat` (a commit message, an `echo`) is skipped
by the quote rule below. The only thing that fires is a whole-file read that
would exceed the budget — and that is the mistake.
## Deny, not route
The installed-skill-edit guard routes because a wrong edit is recoverable. An
over-budget read is not: once the bytes land in context, nothing un-reads them.
So this guard **denies** (exit 2 + stderr) and **every attempt blocks** — it
never self-relaxes, because the second unbounded read costs exactly what the
first would have. What is once-per-session is the *explanation*: the first fire
in a session prints the full message; later fires print one short line (still
exit 2). The message names the two correct moves and nothing else.
Context-budget doctrine still applies: silent on every happy path (exit 0, zero
stdout, zero stderr — a stray stdout line on an exit-0 PreToolUse path is parsed
as JSON and breaks the tool call), block via exit 2 + stderr only, fail OPEN.
## The contract
Ships as `skills/cc-hooks/hooks/read-budget-guard.sh` (inert until the opt-in
installer wires it; `set -uo pipefail`, no `-e`). It reads the real PreToolUse
JSON on stdin (`{tool_name, tool_input, session_id, cwd}`) with `jq`; a missing
`session_id` is `nosession`. Policy id and `token_class`:
`core.context:unbounded-read`.
### `Read`
- `tool_input.limit` is a number → **PASS**. `offset` alone does not bound a
read and does not pass.
- Otherwise resolve `tool_input.file_path` (relative → against the JSON `cwd`,
else `$PWD`). Not an existing regular readable file, or binary (a NUL byte in
the first 8192 bytes) → **PASS**.
- `lines = wc -l < file`; `lines > budget` → **FIRE**.
### `Bash`
- The command contains any of `|`, `<`, `>` → **PASS**. A pipe feeds a bounded
consumer, a redirect feeds a file sink; neither lands whole in context. Out
of scope by design, not by accident.
- Otherwise split on `;`, `&&` and newlines into segments — a quote-aware
split: a separator counts only outside single or double quotes (backslash
escapes honored outside single quotes), so quoted text that mentions `cat`
stays inside its own command's segment and is never judged, a `# comment`
runs to end of line and is dropped before splitting, and a backslash-newline
is deleted exactly as the shell does (`cat big\` + newline + `.txt` is
`cat big.txt`). `git commit -m "fix; cat big.txt; now routes"` never
fires. Per segment: whitespace-tokenize; a word whose quotes are not a
matched pair around the whole word (or around a `NAME=value` value) — a
quoted path with a space, `foo"bar"`, `-n"500"` — is unparseable and skips
the segment; strip leading `VAR=value` assignments (an
`AOP_WAIVE=...` prefix whose list contains the id waives the WHOLE call — see
below). The command word is the basename of the first remaining token and
must be `cat`, `head` or `tail`; any other command skips the segment.
- Per remaining token: strip one layer of surrounding single or double quotes.
A token starting with `$` or containing a backtick, `*`, `?` or `[` is
unresolvable and is skipped. Flags start with `-`. Files are the non-flag
tokens, resolved against `cwd`; missing, non-regular and binary files are
skipped.
- `cat`: effective = **sum** of the resolved files' line counts → FIRE if over
budget (the message names the largest file; `N` is the total).
- `head`: `-n N`, `-nN`, `-N`, `--lines=N`, `--lines N` (default 10). A
negative count (`-n -K`, "all but the last K") makes effective = the file's
lines. Any `-c` / `--bytes` form skips the segment. Effective =
`min(N, lines)` per file → FIRE if any is over budget.
- `tail`: same flag forms; `-n +K` → effective = `lines - K + 1` (min 0);
`-f` / `--follow` skips the segment → FIRE if any effective is over budget.
### Always PASS (exit 0, zero output)
Any other `tool_name` (an `Edit` of a huge file is a write, not a read); an
empty or unparseable command; `cat` with no file; `git status`; `grep -n`,
`sed -n '1,400p'`, `awk`, `less`, `more` — bounded or paged consumers, silent
by design because they *are* the correct moves.
### Waiver, kill switch, budget
| Control | Effect |
|---|---|
| `AOP_READ_BUDGET_LINES=<n>` | the budget; default 350, and anything that is not a positive integer falls back to 350. Hook env only — an operator setting, never honored as a command prefix (that would be an uncounted self-relax) |
| `AOP_WAIVE=core.context:unbounded-read` | waive once — as hook env, or as a prefix on the Bash command itself (comma list; the id must be in it) |
| `AOP_WAIVER_FILE` line `core.context:unbounded-read <expiry-epoch>` | timed waiver; default file `${AGENTOPS_HOME:-$HOME/.agents/ao}/policy-waivers`, same semantics as the dispatcher; an expired line still fires |
| `AGENTOPS_HOOKS_DISABLED=1` | kill switch: exit 0, silent, no telemetry |
A waived call exits 0 with zero output and writes one telemetry line with
`decision: "waived"`, so waivers are counted — they are the countermetric.
### Fail OPEN
No `jq` on `PATH` → exit 0. Malformed JSON → exit 0, silent. Empty or unknown
tool → exit 0. A guard that cannot decide must never brick the tool call.
Telemetry failure never changes the exit decision.
### The message
First fire in a session (full):
```text
⛔ policy core.context:unbounded-read
<path> is <N> lines (budget <B>). An unbounded read puts every line into this context and re-sends it on every later turn.
→ Read a slice: Read(file_path, offset, limit) with limit ≤ <B>, or Bash: sed -n '1,<B>p' <path> / grep -n <pattern> <path>.
→ Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context:
Agent tool: subagent_type "bulk-reader", prompt "<question>\nfiles: <path>"
Workflow: bulk-read { question: "<question>", files: ["<path>"] }
Waive once: AOP_WAIVE=core.context:unbounded-read (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=<N> in the hook env (an operator setting, not a command prefix).
```
Later fires in the same session (short, still exit 2):
```text
⛔ policy core.context:unbounded-read: <path> is <N> lines (budget <B>) — slice it (offset+limit / sed -n) or delegate to bulk-reader (full reason shown earlier this session).
```
The per-session sentinel lives under `${TMPDIR:-/tmp}/aop-read-budget-guard/`
(one file per `session_id`, `/` replaced by `_`).
### Telemetry
Exactly one JSONL line per FIRE and per WAIVED call — none on pass, disabled or
fail-open — appended to
`${AGENTOPS_GUARDRAIL_TELEMETRY:-${AGENTOPS_HOME:-$HOME/.agents/ao}/guardrail-telemetry.jsonl}`:
```json
{"ts":"2026-09-12T10:00:00Z","session":"<session_id>","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
```
`path_sha256` is the SHA-256 of the **resolved** offending path — never the raw
path, never the command. `lines` and `budget` are JSON numbers. No hasher
(`sha256sum` / `shasum -a 256` / `openssl dgst -sha256`) → no line rather than
a raw path. Methodology and the pre-registered KEEP/CUT rule:
[GUARDRAIL-VALUE-PROOF.md](GUARDRAIL-VALUE-PROOF.md).
## The delegation pairing
The guard's second arrow points at the delegation layer; without it the guard
only says "no". Three bounded, one-shot, cheap-model delegations ship next to
it — Claude Code plugin agents and Workflow-tool conveyors; the caller sees
bullets or a receipt, never bytes, and nothing is kept between calls:
| Piece | What the caller gets |
|---|---|
| `agents/bulk-reader.md` — subagent `bulk-reader` (`Read`/`Grep`/`Glob`/`Bash`, no `Write`/`Edit`, haiku) | line-referenced bullets (`path:line`, at most 40 unless the caller sets another cap), no prose |
| `workflows/bulk-read.js` — `bulk-read { question, files, root?, model?, maxBullets?, budgetLines? }` | one reader per file in parallel; `{question, files:[{file, bullets, lines_covered, complete, note?, error?}], bullets_total}` |
| `workflows/code-write.js` with `agents/code-writer.md` — `code-write { items:[{key, spec, reference, target, check?}] }` | a receipt per item (`written`, `lines`, `check_ok`, `summary`); the caller never reads the file back; a reference file is REQUIRED |
Guard compatibility: the reader and writer prompts read in **slices** (`Read`
with `offset` + `limit ≤ budgetLines`, advancing until a slice comes back
short), never an unbounded `Read`/`cat`/`head`/`tail`. So a delegate's own
reads pass this guard on a host where it is installed — the delegation is not
an exemption, it is a reader that obeys the same rule. A follow-up question
about the same file costs another delegation, not another copy of the file in
this context.
A receipt or a bullet list is a runtime fact, not validation. Whatever a writer
lands still gets fresh, author-distinct judgment like any other change. Pattern
and doctrine in AgentOps terms:
[context-budget delegation](../../agent-native/references/context-budget-delegation.md);
workflow install and args: `workflows/README.md` in the repository checkout.
## Opt-in install
```bash
# user scope (~/.claude/settings.json) — the default
scripts/install-read-budget-guard.sh
# project scope (.claude/settings.json)
scripts/install-read-budget-guard.sh --project
# explicit target
SETTINGS=/path/to/settings.json scripts/install-read-budget-guard.sh
```
The installer copies the guard to `~/.claude/hooks/read-budget-guard.sh`, takes
a timestamped `.bak` of the settings file before mutating it, and adds
(idempotently) one PreToolUse `Read|Bash` matcher:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/read-budget-guard.sh" }
]
}
]
}
}
```
Requires `jq` on `PATH`. The plugin manifest `hooks/hooks.json` is not touched:
nothing wires this guard automatically, on any install path. Uninstall is the
line the installer prints: remove the matcher, then `rm` the copied script.
## Test it
Three bats files round-trip the real PreToolUse JSON (built with `jq -nc`,
never hand-written strings) under an isolated `TMPDIR` and `HOME`, with
`AGENTOPS_GUARDRAIL_TELEMETRY` pointed into `TMPDIR`:
- `tests/scripts/read-budget-guard.bats` — **FIRE** (exit 2, stderr names the
policy id): an unbounded `Read` of a 400-line file, `Read` with `offset`
only, `cat big.txt`, `cat -n big.txt`, `head -n 500` / `-500` /
`--lines=500`, `tail -n 400`, `tail -n +5`, `cat a.txt b.txt` (200 + 200), a
relative path resolved through the JSON `cwd`, a second fire in the same
session (short line, still exit 2), the first fire's output contains
`bulk-reader`. **SILENT** (exit 0, zero output): `Read` with `limit 100`, a
100-line file, a NUL-bearing binary with 400 newlines, a missing path, a
directory, `cat big.txt | head -20`, `cat big.txt > out.txt`, `head big.txt`,
`head -n 50`, `tail -n 20`, `grep -n`, `sed -n '1,400p'`, `cat small.txt`,
`git status`, bare `cat`, `cd sub && cat big.txt`, an `Edit` of a big file.
**WAIVERS**: env, command prefix, waiver file (future expiry passes, expired
still fires), `AGENTOPS_HOOKS_DISABLED=1`, `AOP_READ_BUDGET_LINES=1000`.
**FAIL-OPEN**: malformed JSON `{`, no `jq` on `PATH`.
- `tests/scripts/read-budget-guard-telemetry.bats` — one line per fire; valid
JSON with every field; `lines` and `budget` are numbers; `path_sha256` is 64
hex and equals the hash of the resolved path; the raw path and the raw
command never appear; nothing on the happy path; `waived` on a waiver; two
lines for two fires in one session; nothing when disabled.
- `tests/scripts/install-read-budget-guard.bats` — mode 755; exactly one
`Read|Bash` matcher whose command is the installed path; idempotent re-run;
`--project` writes `.claude/settings.json` in the cwd; a `.bak` when settings
pre-existed; the installed file byte-equals the repo source.
```bash
bats tests/scripts/read-budget-guard.bats \
tests/scripts/read-budget-guard-telemetry.bats \
tests/scripts/install-read-budget-guard.bats
```
## Known limitations
Every gap errs toward silence: a missed case is one un-guarded read, never a
broken tool call. Known false-negative shapes:
- **Pipes and redirects** pass wholesale (`cat big.txt | cat` included) — the
`|` / `<` / `>` check does not inspect the consumer.
- **Globs and variables** (`cat *.log`, `cat "$f"`, backticks) are
unresolvable tokens and are skipped, not expanded. A leading `~/` is the one
expansion mirrored (against `HOME`).
- **Command prefixes** (`sudo cat`, `time cat`, `env X=1 cat`) are silent:
only a segment whose first word is `cat`, `head` or `tail` is judged.
- **Quoted paths with spaces** (`cat "my notes.md"`) tokenize on whitespace
into words whose quotes are not a matched pair → the segment is skipped,
silent. The same rule silences `-n"500"` and `foo"bar"` forms; a whole
quoted word or a quoted `NAME=`/`--opt=` value (`cat "big.txt"`,
`head -n "500"`, `head --lines="500"`, `LC_ALL="C" cat`) is judged.
- **`cd`-chained segments** (`cd sub && cat big.txt`) resolve against the
original `cwd`, not `sub` → not found → silent (a bats-documented gap).
- **`sed`, `awk`, `less`, `more`, `grep`, `xargs`, `sh -c`** are silent by
design; only `cat`, `head` and `tail` are inspected. `head -c` and `tail -f`
skip their segment.
- **Lines, not bytes**: `wc -l` is the predicate, so a one-line multi-megabyte
file passes.
- **A subagent's own reads run under the same hook.** A `bulk-reader` that
issues an unbounded `Read` is blocked like anyone else — which is why the
shipped reader prompt slices. A hand-written reader that does not slice is
denied, not exempted.
+2 -2
View File
@@ -698,7 +698,7 @@
"has_skill_md": true,
"name": "agent-native",
"path": "skills/agent-native/",
"reference_count": 2,
"reference_count": 3,
"tier": "meta"
},
{
@@ -748,7 +748,7 @@
"has_skill_md": true,
"name": "cc-hooks",
"path": "skills/cc-hooks/",
"reference_count": 7,
"reference_count": 8,
"tier": "execution"
},
{
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# install-read-budget-guard.sh — opt-in installer for the read-budget
# PreToolUse guard (policy core.context:unbounded-read).
#
# AgentOps is hookless by default — this guard ships INERT. Run this script
# explicitly to activate it. It copies the guard into ~/.claude/hooks/ and adds a
# PreToolUse Read|Bash matcher to a Claude settings.json. Idempotent: re-running
# is a no-op once wired. Nothing here runs at build/install-of-skills time, and
# hooks/hooks.json is never touched.
#
# Usage:
# scripts/install-read-budget-guard.sh # user settings (~/.claude/settings.json)
# scripts/install-read-budget-guard.sh --project # project settings (.claude/settings.json)
# SETTINGS=/path/to/settings.json scripts/install-read-budget-guard.sh
# The preamble sets strict mode and exports a CWD-hijack-proof REPO_ROOT.
# `CDPATH=` is an intentional env-prefix (clears CDPATH for that one cd), not a
# botched assignment — hence the SC1007 disable, matching scripts/lib/preamble.sh.
# SC1091: the preamble is a sourced library resolved at runtime, never a lint
# input (same reason as scripts/gc-maintainer-ops.sh).
# shellcheck disable=SC1091
# shellcheck disable=SC1007
. "$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/preamble.sh"
shopt -s lastpipe 2>/dev/null || true
umask 022
src="$REPO_ROOT/skills/cc-hooks/hooks/read-budget-guard.sh"
[[ -f "$src" ]] || { echo "ERROR: guard script missing: ${src}" >&2; exit 1; }
require_cmd jq
# Resolve target settings file.
settings="${SETTINGS:-}"
if [[ -z "$settings" ]]; then
case "${1:-}" in
--project) settings=".claude/settings.json" ;;
*) settings="${HOME}/.claude/settings.json" ;;
esac
fi
# Install the guard into ~/.claude/hooks/ (referenced by absolute path).
hooks_dir="${HOME}/.claude/hooks"
mkdir -p "$hooks_dir"
dst="${hooks_dir}/read-budget-guard.sh"
install -m 0755 "$src" "$dst"
echo "✓ installed ${dst}"
# Merge the PreToolUse Read|Bash matcher into settings.json (idempotent).
mkdir -p "$(dirname "$settings")"
[[ -f "$settings" ]] || echo '{}' > "$settings"
# Timestamped backup before mutating settings (installer-workmanship).
if [[ -f "$settings" && -s "$settings" ]]; then
backup="${settings}.bak.$(date +%Y%m%d%H%M%S)"
cp -p "$settings" "$backup"
echo "✓ backed up settings → ${backup}"
fi
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
jq --arg cmd "$dst" '
.hooks //= {} |
.hooks.PreToolUse //= [] |
if any(.hooks.PreToolUse[]?; (.hooks // [])[]?.command == $cmd)
then .
else .hooks.PreToolUse += [{
"matcher": "Read|Bash",
"hooks": [ { "type": "command", "command": $cmd } ]
}]
end
' "$settings" > "$tmp" && mv "$tmp" "$settings"
trap - EXIT
if grep -qF "$dst" "$settings"; then
echo "✓ wired Read|Bash PreToolUse guard into ${settings}"
else
echo "ERROR: failed to wire guard into ${settings}" >&2
exit 1
fi
echo ""
echo "Read-budget guard active for this Claude scope."
echo "It is SILENT on every bounded or at-budget read; it blocks (exit 2) an unbounded"
echo "Read / cat / head / tail over AOP_READ_BUDGET_LINES (default 350) lines, routing"
echo "you to a slice (offset+limit / sed -n) or a bulk-reader delegation."
echo "Uninstall: remove the PreToolUse matcher for ${dst} from ${settings}, then rm -f ${dst}"
+4 -4
View File
@@ -255,8 +255,8 @@
{
"name": "agent-native",
"source_skill": "skills/agent-native",
"source_hash": "c247de76757e92c15a36a7cf8ab0946eeb880d3424291a0efb19e05f81aed13e",
"generated_hash": "6a72c88ce4392646260012ea3318fe85f7930d2fd50f8f5069d88705fc14e4ec"
"source_hash": "82f389223bdab8db77e7b0c02dd3501a8bfdcf89ae32b3f7ebe24ac79413ee5b",
"generated_hash": "dabc8fc0fba6ebdf14c491403094907abfbd395a944f46ba41ef767d603c88b9"
},
{
"name": "agy-native",
@@ -273,8 +273,8 @@
{
"name": "cc-hooks",
"source_skill": "skills/cc-hooks",
"source_hash": "e0463f769d405243764116a9cb56d9a386184dbf88b2c2fc24da33ae0e738c76",
"generated_hash": "5dcce46d5e70055346048df97da6a18d8ba6d9ec0a9aca9440888ea7ce3853e4"
"source_hash": "feb6977ea11a5f08ef94f9af5f4fb71119c716d92e98ed88241b8615159712b8",
"generated_hash": "8c7dc20c1a36f2993ef6bc8502661ff339c7b6c262de4bbf3efc2cc3611a0e8c"
},
{
"name": "codex-exec",
@@ -2,6 +2,6 @@
"generator": "codex-sync",
"source_skill": "skills/agent-native",
"layout": "modular",
"source_hash": "c247de76757e92c15a36a7cf8ab0946eeb880d3424291a0efb19e05f81aed13e",
"generated_hash": "6a72c88ce4392646260012ea3318fe85f7930d2fd50f8f5069d88705fc14e4ec"
"source_hash": "82f389223bdab8db77e7b0c02dd3501a8bfdcf89ae32b3f7ebe24ac79413ee5b",
"generated_hash": "dabc8fc0fba6ebdf14c491403094907abfbd395a944f46ba41ef767d603c88b9"
}
+7
View File
@@ -51,6 +51,13 @@ stalled, and rescue is usually cheaper than rerun.
- **Validator:** receives exact candidate content in a fresh, read-only context.
- **Scribe:** records runtime evidence without judging acceptance.
Reader and Writer are bounded cheap delegations, not roles with authority: a
Reader returns line-referenced bullets over files the caller never loads, and a
Writer lands one patterned file from a spec plus a reference file and returns a
receipt the caller never reads back. Both are caller-selected per call, default
to a cheap model, and yield runtime facts only — a receipt is not validation.
See [context-budget delegation](references/context-budget-delegation.md).
## Contract
For a caller-selected parallel batch, validate every complete packet before the
@@ -0,0 +1,68 @@
# Context-Budget Delegation (Reader / Writer)
Keep large file bytes out of the working context by delegating reads and
patterned writes to bounded cheap contexts that return line-referenced bullets
or receipts. Spotify published an internal Claude Code setup built this way and
claims roughly a 90% token reduction; that is Spotify's claim about Spotify's
setup, not a measurement made here. The mechanical finding is what matters: the
same read rule placed in CLAUDE.md was advisory and ignored, and every line of
an unbounded read is re-sent on every later turn for the rest of the session.
## Three layers
| Layer | AgentOps surface | Authority |
|---|---|---|
| Advisory | this reference and the `agent-native` Roles note | none; context the agent may ignore |
| Delegation | `bulk-reader` / `code-writer` subagents (`agents/`), `bulk-read` / `code-write` workflows (`workflows/`) | caller-selected per call |
| Enforcement | the opt-in read-budget guard: [READ-BUDGET-GUARD.md](../../cc-hooks/references/READ-BUDGET-GUARD.md) | mechanical once installed; inert by default |
The delegation surfaces live in the AgentOps source checkout: the subagents are
Claude Code plugin agents and the workflows are Claude-only thin conveyors
(`workflows/README.md`). Neither ships with a standalone installed skill.
## Reader and Writer as bounded cheap delegations
- **Reader** (`bulk-reader` subagent, `bulk-read` workflow): the caller passes a
question and file paths; the reader reads each file completely in slices and
returns bullets only, each starting with `path:line` or `path:start-end`, at
most 40 unless the caller sets another cap, plus truthful `lines_covered` and
`complete`. The caller sees bullets, never bytes, so a follow-up question costs
one more cheap call and zero main-context lines.
- **Writer** (`code-writer` subagent, `code-write` workflow): the caller passes a
spec, a REQUIRED reference file and one target path; the writer matches the
reference's patterns, writes only the target, optionally runs one check, and
returns a receipt (path, line count, check result, a short summary). The caller
never reads the result back.
- Both are one-shot: nothing is kept between calls and AgentOps stores no
delegated file. A dead worker returns an explicit error, never silence.
## Guard compatibility
Readers and writers slice: `Read` with `offset` + `limit`, `limit` at most the
budget (350 lines by default, `AOP_READ_BUDGET_LINES` when set). A subagent's
own reads run under the same PreToolUse hook as the caller's, so an unbounded
read inside a delegate is blocked the same way. The guard never fires on a
bounded slice or on a file at or below budget, so a compliant reader is never
blocked and the delegation works whether or not the guard is installed.
## Model selection belongs to the caller
`haiku` is the default for both delegations; the caller may pin another model
per call (`model` in the workflow args, or the subagent's `model` field). Codex
has no PreToolUse hooks, so only the delegation layer applies there: dispatch a
fresh cheap `codex exec` per [codex-exec](../../codex-exec/SKILL.md) with the
same reader or writer prompt. [model-dispatch](model-dispatch.md) still governs
judgment legs; a reader or writer is an execution role, never a judge.
## Doctrine
- A receipt is a runtime fact, not validation. `written: true`, a line count or
`check_ok: true` proves that a process ran, nothing about acceptance.
[Validate](../../validate/SKILL.md) stays fresh and author-distinct over the
exact written content; the writer's context can never issue that PASS.
- Reader bullets are evidence with a locator, not authority. Re-open the cited
lines before a decision that depends on them.
- No new AO command, scheduler or budget account. The guard is a standalone
opt-in recipe with an installer (ADR-0002: a hook earns its lease on life only
as an optional runtime adapter); the delegations are caller-selected per call;
nothing counts tokens on the agent's behalf or renews a spent bound.
@@ -2,6 +2,6 @@
"generator": "codex-sync",
"source_skill": "skills/cc-hooks",
"layout": "modular",
"source_hash": "e0463f769d405243764116a9cb56d9a386184dbf88b2c2fc24da33ae0e738c76",
"generated_hash": "5dcce46d5e70055346048df97da6a18d8ba6d9ec0a9aca9440888ea7ce3853e4"
"source_hash": "feb6977ea11a5f08ef94f9af5f4fb71119c716d92e98ed88241b8615159712b8",
"generated_hash": "8c7dc20c1a36f2993ef6bc8502661ff339c7b6c262de4bbf3efc2cc3611a0e8c"
}
+24 -1
View File
@@ -165,6 +165,28 @@ positive value"), the criterion whose absence killed 2.x hooks (#511).
Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md)
## Read-Budget Guard (opt-in)
A PreToolUse `Read|Bash` guard that DENIES an **unbounded read over the line
budget** (`AOP_READ_BUDGET_LINES`, default 350): a `Read` with no `limit`, or a
`cat`/`head`/`tail` whose effective line count exceeds it. The Spotify finding:
the same rule in CLAUDE.md was advisory and ignored, and an over-budget read
re-sends its lines on every later turn. The predicate is a LOOKUP (`wc -l` on
the exact argument), so it is a standalone guard, never a registry policy. A
`limit`-bounded slice, a file at/below budget, a pipe, a redirect or quoted text
that merely mentions `cat` never fires. Nothing un-reads bytes once in context → every
attempt blocks (exit 2 + stderr): full message once per session naming the two
correct moves (slice it, or delegate to the `bulk-reader` subagent /
`bulk-read` workflow), one short line after. Waive once with
`AOP_WAIVE=core.context:unbounded-read`; hashed telemetry adds `tool`, `lines`,
`budget` plus the dispatcher's `mode`/`decision` pair. Ships INERT — opt-in installer:
```bash
scripts/install-read-budget-guard.sh # user scope; --project for project
```
Recipe: [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md)
## Policy Dispatch Engine (ships by default)
The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher —
@@ -296,7 +318,7 @@ claude --debug # Hook execution details
## Output Specification
- **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit) stay inert until opted in.
- **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit, read-budget) stay inert until opted in.
- **Filename:** preserve `settings.json`; give scripts descriptive executable filenames rather than embedding large shell programs in JSON.
- **Format:** valid Claude hook JSON using event arrays, matchers, and command objects; hook stdout/stderr and exit codes follow the selected event schema.
- **Exit code:** validate with `jq -e '.hooks | type=="object"' <settings.json>` and a representative silent/fire test for each matcher; any parse error, noisy happy path, or recursion risk blocks activation.
@@ -313,6 +335,7 @@ claude --debug # Hook execution details
- [HOOK-EVENTS.md](references/HOOK-EVENTS.md) - All events with full schemas
- [DCG-RCH.md](references/DCG-RCH.md) - Production examples (dcg, rch)
- [INSTALLED-SKILL-EDIT-GUARD.md](references/INSTALLED-SKILL-EDIT-GUARD.md) - Opt-in guard routing installed-skill edits to repo skills/ (keystone)
- [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md) - Opt-in guard denying unbounded reads over the line budget; pairs with bulk-read / code-write delegation
- [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md) - Pre-registered value-proof methodology + per-fire telemetry contract (ADR-0002 l.58)
- [PATTERNS.md](references/PATTERNS.md) - Auto-format, logging, notifications
- [JSON-OUTPUT.md](references/JSON-OUTPUT.md) - Response schemas
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env bash
# read-budget-guard (PreToolUse / Read|Bash) — policy core.context:unbounded-read
#
# Blocks an UNBOUNDED read of a text file over the line budget (default 350).
# The mistake-token: a Read without a numeric limit, or a Bash cat/head/tail
# whose EFFECTIVE line count exceeds the budget. Every such line lands in this
# context and is re-sent on every later turn; the same rule written into
# CLAUDE.md was advisory and ignored (the Spotify finding), so it lives here as
# a hook that can refuse. A LOOKUP predicate (wc -l on the exact argument), so
# this is a STANDALONE opt-in guard — never a policies.json registry entry
# (the dispatcher accepts pure regex predicates only). Ships INERT: nothing
# wires it until scripts/install-read-budget-guard.sh is run explicitly.
#
# Decision (deny-not-route: EVERY attempt blocks, the guard never self-relaxes):
# FIRE -> exit 2 + stderr. First fire in a session: the FULL message (both
# correct moves — slice it, or delegate to a bulk-reader); later
# fires in the same session: ONE short line (sentinel-gated).
# PASS / WAIVED / DISABLED -> exit 0, ZERO stdout, ZERO stderr (stray stdout
# on an exit-0 PreToolUse path is parsed as JSON by the harness).
#
# PASS by construction (zero false-positive surface): a Read with a numeric
# limit; a file at/below budget; a missing / non-regular / binary path; a Bash
# command containing | < > (a bounded consumer or a file sink); any command
# word other than cat/head/tail; unresolvable tokens ($VAR, globs, backticks);
# quoted text that merely mentions cat (the split is quote-aware).
#
# Env:
# AOP_READ_BUDGET_LINES line budget (positive integer; malformed -> 350)
# AOP_WAIVE comma list of waived policy ids (env, or an inline
# AOP_WAIVE=<ids> prefix on the Bash command)
# AOP_WAIVER_FILE "<id> <expiry-unix-epoch>" lines, same semantics
# as policy-dispatch.sh
# AGENTOPS_HOOKS_DISABLED=1 kill switch: exit 0, silent, no telemetry
# AGENTOPS_GUARDRAIL_TELEMETRY / AGENTOPS_HOME telemetry ledger location
#
# Telemetry: exactly one JSONL line per FIRE and per WAIVED call — never on
# pass / disabled / fail-open. The RESOLVED offending path is hashed (SHA-256);
# the raw path and the raw command are never stored. Telemetry failure never
# changes the exit decision.
#
# Fail OPEN: no jq -> exit 0; malformed JSON -> exit 0 silent; empty or unknown
# tool -> exit 0. Portable bash 3.2 + BSD tools: no GNU-only flags, no sed -i,
# no mapfile, no associative arrays; head/tail flags parsed with case.
set -uo pipefail
# Tokens are matched literally: a `*` / `?` / `[` in a command must never be
# expanded against the hook's own cwd.
set -f
# Kill switch: silent, no telemetry, before anything else is touched.
[ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0
# Fail OPEN if jq is unavailable: a guard that cannot parse its input must
# never brick a tool call.
command -v jq >/dev/null 2>&1 || exit 0
policy_id="core.context:unbounded-read"
input="$(cat)"
# 2>/dev/null: malformed stdin must be FULLY silent (fail open), never leak jq
# parse errors to stderr.
tool="$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)"
[ -n "$tool" ] || exit 0
case "$tool" in Read|Bash) ;; *) exit 0 ;; esac
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
[ -n "$sid" ] || sid="nosession"
cwd="$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null)"
[ -n "$cwd" ] || cwd="$PWD"
# Budget: a positive integer, else the default. Normalized through base-10
# arithmetic so a leading zero can never reach --argjson as invalid JSON.
budget="${AOP_READ_BUDGET_LINES:-350}"
case "$budget" in ''|*[!0-9]*) budget=350 ;; esac
budget=$((10#$budget))
[ "$budget" -gt 0 ] || budget=350
# Set when a leading AOP_WAIVE=<ids> assignment on the Bash command names this
# policy: the WHOLE call is waived.
inline_waived=0
# resolve_path P → absolute path: relative paths resolve against the JSON cwd.
resolve_path() {
case "$1" in
/*) printf '%s' "$1" ;;
\~|\~/*)
# The shell would expand a leading tilde against HOME; mirror it. With no
# HOME the token stays literal, is never found, and the read passes.
if [ -n "${HOME:-}" ]; then printf '%s%s' "$HOME" "${1#\~}"; else printf '%s/%s' "$cwd" "$1"; fi ;;
*) printf '%s/%s' "$cwd" "$1" ;;
esac
}
# is_text_file P → 0 when P is an existing, readable, regular file with no NUL
# byte in its first 8 KiB (the portable binary test: compare the byte count
# with and without NULs stripped).
is_text_file() {
[ -f "$1" ] && [ -r "$1" ] || return 1
local all stripped
all="$(head -c 8192 "$1" 2>/dev/null | wc -c | tr -d ' ')"
stripped="$(head -c 8192 "$1" 2>/dev/null | tr -d '\000' | wc -c | tr -d ' ')"
[ "$all" = "$stripped" ]
}
# line_count P → number of newline characters in P (trimmed).
line_count() {
wc -l < "$1" 2>/dev/null | tr -d ' '
}
hash_value() {
# SHA-256 of $1 for telemetry privacy; empty string when no hasher exists.
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$1" | sha256sum | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
elif command -v openssl >/dev/null 2>&1; then
printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//'
fi
}
emit_telemetry() {
# $1 decision (deny|waived), $2 resolved path, $3 effective lines, $4 tool.
# Best-effort: no hasher -> no line (never leak the raw path); any failure
# returns 0 so the exit decision is unchanged.
# No ledger location at all (no HOME, no AGENTOPS_* override) -> no line;
# never anchor the default at the filesystem root.
[ -n "${AGENTOPS_GUARDRAIL_TELEMETRY:-}${AGENTOPS_HOME:-}${HOME:-}" ] || return 0
local h
h="$(hash_value "$2")"
[ -n "$h" ] || return 0
local tdir="${AGENTOPS_HOME:-${HOME:-}/.agents/ao}"
local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
local line
line="$(jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg session "$sid" \
--arg token_class "$policy_id" \
--arg path_sha256 "$h" \
--arg mode "deny" \
--arg decision "$1" \
--arg tool "$4" \
--argjson lines "$3" \
--argjson budget "$budget" \
'{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision, tool:$tool, lines:$lines, budget:$budget}' \
2>/dev/null)" || return 0
# Braces: a failed redirect is reported by bash BEFORE a trailing 2>/dev/null
# applies, and an exit-0 path must stay silent.
{ printf '%s\n' "$line" >> "$tfile"; } 2>/dev/null || return 0
}
waived() {
# 0 when a waiver applies: inline prefix, AOP_WAIVE env, or an unexpired
# waiver-file entry (same semantics as policy-dispatch.sh).
[ "$inline_waived" -eq 1 ] && return 0
case ",${AOP_WAIVE:-}," in
*",${policy_id},"*) return 0 ;;
esac
local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME:-}/.agents/ao}/policy-waivers}"
[ -f "$wfile" ] || return 1
local now id expiry
now="$(date +%s)"
while read -r id expiry _; do
[ "$id" = "$policy_id" ] || continue
case "$expiry" in (*[!0-9]*|'') continue ;; esac
[ "$expiry" -gt "$now" ] && return 0
done < "$wfile"
return 1
}
fire() {
# $1 resolved path, $2 effective lines, $3 tool. Never returns.
if waived; then
emit_telemetry "waived" "$1" "$2" "$3"
exit 0
fi
emit_telemetry "deny" "$1" "$2" "$3"
local sdir="${TMPDIR:-/tmp}/aop-read-budget-guard"
local sentinel="${sdir}/${sid//\//_}"
if [ -f "$sentinel" ]; then
printf '⛔ policy %s: %s is %s lines (budget %s) — slice it (offset+limit / sed -n) or delegate to bulk-reader (full reason shown earlier this session).\n' \
"$policy_id" "$1" "$2" "$budget" >&2
exit 2
fi
mkdir -p "$sdir" 2>/dev/null || true
{ : > "$sentinel"; } 2>/dev/null || true
cat >&2 <<MSG
⛔ policy ${policy_id}
$1 is $2 lines (budget ${budget}). An unbounded read puts every line into this context and re-sends it on every later turn.
→ Read a slice: Read(file_path, offset, limit) with limit ≤ ${budget}, or Bash: sed -n '1,${budget}p' $1 / grep -n <pattern> $1.
→ Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context:
Agent tool: subagent_type "bulk-reader", prompt "<question>\nfiles: $1"
Workflow: bulk-read { question: "<question>", files: ["$1"] }
Waive once: AOP_WAIVE=${policy_id} (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=$2 in the hook env (an operator setting, not a command prefix).
MSG
exit 2
}
# ---------------------------------------------------------------- Read ------
check_read() {
local fpath ltype abs lines
fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null)"
[ -n "$fpath" ] || return 0
# A bounded slice always passes; offset alone does NOT bound.
ltype="$(printf '%s' "$input" | jq -r '.tool_input.limit | type' 2>/dev/null)"
[ "$ltype" = "number" ] && return 0
abs="$(resolve_path "$fpath")"
is_text_file "$abs" || return 0
lines="$(line_count "$abs")"
case "$lines" in ''|*[!0-9]*) return 0 ;; esac
[ "$lines" -gt "$budget" ] || return 0
fire "$abs" "$lines" "Read"
}
# ---------------------------------------------------------------- Bash ------
# strip_quotes T → T with ONE layer of surrounding single or double quotes removed.
strip_quotes() {
local t="$1"
case "$t" in
\"*\") t="${t#\"}"; t="${t%\"}" ;;
\'*\') t="${t#\'}"; t="${t%\'}" ;;
esac
printf '%s' "$t"
}
# check_segment SEG — judge one `;` / `&&` segment. Calls fire (never returns)
# when the segment's effective read exceeds the budget; returns 0 otherwise.
check_segment() {
local -a toks
local -a files
local n i t cmdw name v n_raw want_next sign num
read -r -a toks <<< "$1" || true
n=${#toks[@]}
[ "$n" -gt 0 ] || return 0
# Segments arrive from the quote-aware split in check_bash, so every quote
# here belongs to a word of ONE real command. A `#` word starts a comment:
# nothing after it is a command. A word is judgeable only when its quotes
# are a matched pair around the whole word (or around the value of a
# NAME=value assignment); anything else — a quoted path with a space, a
# stray quote, foo"bar" — is unparseable here: skip the segment, silent.
local -a words
local q
words=()
for t in "${toks[@]}"; do
case "$t" in \#*) break ;; esac
words[${#words[@]}]="$t"
done
n=${#words[@]}
[ "$n" -gt 0 ] || return 0
for t in "${words[@]}"; do
case "$t" in *\"*|*\'*) ;; *) continue ;; esac
v="$t"
name="${t%%=*}"
case "$name" in
[A-Za-z_]*) case "$name" in *[!A-Za-z0-9_]*) ;; *) v="${t#*=}" ;; esac ;;
--[A-Za-z]*) case "$name" in *[!A-Za-z0-9-]*) ;; *) v="${t#*=}" ;; esac ;;
esac
case "$v" in
\"\"|\'\') ;;
\"?*\") q="${v//[!\"]/}"; [ "${#q}" -eq 2 ] || return 0 ;;
\'?*\') q="${v//[!\']/}"; [ "${#q}" -eq 2 ] || return 0 ;;
*) return 0 ;;
esac
done
toks=("${words[@]}")
# Leading VAR=value assignments: skip them; an AOP_WAIVE naming this policy
# waives the whole call.
i=0
while [ "$i" -lt "$n" ]; do
t="${toks[$i]}"
case "$t" in
*=*)
name="${t%%=*}"
case "$name" in ''|[0-9]*|*[!A-Za-z0-9_]*) break ;; esac
if [ "$name" = "AOP_WAIVE" ]; then
v="$(strip_quotes "${t#*=}")"
case ",${v}," in *",${policy_id},"*) inline_waived=1 ;; esac
fi
i=$((i + 1))
;;
*) break ;;
esac
done
[ "$i" -lt "$n" ] || return 0
cmdw="$(strip_quotes "${toks[$i]}")"
cmdw="${cmdw##*/}"
case "$cmdw" in cat|head|tail) ;; *) return 0 ;; esac
i=$((i + 1))
n_raw="10"
want_next=""
files=()
while [ "$i" -lt "$n" ]; do
t="$(strip_quotes "${toks[$i]}")"
i=$((i + 1))
if [ -n "$want_next" ]; then
n_raw="$t"
want_next=""
continue
fi
# Unresolvable tokens: variables, command substitution, globs.
case "$t" in \$*|*\`*|*\**|*\?*|*\[*) continue ;; esac
if [ "$cmdw" = "cat" ]; then
# cat flags (-n, -A, ...) never bound the read; everything else is a file.
case "$t" in -*) continue ;; esac
files[${#files[@]}]="$t"
continue
fi
# head / tail flag forms. A byte-mode or follow form makes the segment
# unjudgeable by line count -> skip the whole segment.
case "$t" in
-c|-c*|--bytes|--bytes=*) return 0 ;;
-f|-F|--follow|--follow=*)
[ "$cmdw" = "tail" ] && return 0
continue
;;
-n|--lines) want_next=1; continue ;;
-n*) n_raw="${t#-n}"; continue ;;
--lines=*) n_raw="$(strip_quotes "${t#--lines=}")"; continue ;;
-[0-9]*) n_raw="${t#-}"; continue ;;
-*) continue ;;
esac
files[${#files[@]}]="$t"
done
[ "${#files[@]}" -gt 0 ] || return 0
local abs lines eff total maxlines maxpath
if [ "$cmdw" = "cat" ]; then
# Effective = the SUM over the resolved files; report the largest file.
total=0; maxlines=0; maxpath=""
for t in "${files[@]}"; do
abs="$(resolve_path "$t")"
is_text_file "$abs" || continue
lines="$(line_count "$abs")"
case "$lines" in ''|*[!0-9]*) continue ;; esac
total=$((total + lines))
if [ "$lines" -gt "$maxlines" ] || [ -z "$maxpath" ]; then
maxlines="$lines"; maxpath="$abs"
fi
done
[ -n "$maxpath" ] || return 0
[ "$total" -gt "$budget" ] || return 0
fire "$maxpath" "$total" "Bash"
fi
# head / tail: parse the count. head: N -> min(N, lines); -K -> the whole
# file. tail: N or -K -> min(K, lines); +K -> lines - K + 1 (min 0).
sign=""; num="$n_raw"
case "$n_raw" in
+*) sign="+"; num="${n_raw#+}" ;;
-*) sign="-"; num="${n_raw#-}" ;;
esac
case "$num" in ''|*[!0-9]*) return 0 ;; esac
[ "$cmdw" = "head" ] && [ "$sign" = "+" ] && return 0
num=$((10#$num))
for t in "${files[@]}"; do
abs="$(resolve_path "$t")"
is_text_file "$abs" || continue
lines="$(line_count "$abs")"
case "$lines" in ''|*[!0-9]*) continue ;; esac
if [ "$cmdw" = "head" ] && [ "$sign" = "-" ]; then
eff="$lines"
elif [ "$cmdw" = "tail" ] && [ "$sign" = "+" ]; then
eff=$((lines - num + 1))
[ "$eff" -lt 0 ] && eff=0
else
eff="$num"
[ "$eff" -gt "$lines" ] && eff="$lines"
fi
[ "$eff" -gt "$budget" ] || continue
fire "$abs" "$eff" "Bash"
done
return 0
}
check_bash() {
local cmd seg
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)"
[ -n "$cmd" ] || return 0
# Pipes and redirects are out of scope by design: a bounded consumer or a
# file sink, never an unbounded read into this context.
case "$cmd" in *'|'*|*'<'*|*'>'*) return 0 ;; esac
# Quote-aware split: `;`, `&&` and a newline end a segment only OUTSIDE
# single/double quotes (backslash escapes honored outside single quotes), so
# quoted text that merely mentions `cat` — a commit message, an echo — stays
# inside its own command's segment and is never judged as an invocation. A
# newline inside quotes becomes a space (segments are only ever tokenized).
# A `#` comment (at a word start) runs to end of line and is dropped, so a
# separator or a quote inside a comment never splits or swallows anything;
# a backslash-newline is deleted, joining the segment exactly as the shell
# does (`cat big\` + newline + `.txt` is `cat big.txt`). awk (POSIX, BSD and GNU) keeps
# this a single linear pass in bash 3.2; no awk -> fail open, silent.
command -v awk >/dev/null 2>&1 || return 0
while IFS= read -r seg; do
[ -n "$seg" ] || continue
check_segment "$seg"
done < <(printf '%s\n' "$cmd" | awk '
BEGIN { q = ""; seg = ""; cont = 0 }
{
line = $0; n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)
if (q == "") {
if (c == "\\") {
if (i == n) { cont = 1; break }
seg = seg c substr(line, i + 1, 1); i++; continue
}
if (c == "#" && (i == 1 || substr(line, i - 1, 1) ~ /[ \t;&()]/)) break
if (c == "\"" || c == "\047") { q = c; seg = seg c; continue }
if (c == ";") { print seg; seg = ""; continue }
if (c == "&" && substr(line, i + 1, 1) == "&") { print seg; seg = ""; i++; continue }
seg = seg c
} else if (q == "\"") {
if (c == "\\") { seg = seg c substr(line, i + 1, 1); i++; continue }
if (c == "\"") q = ""
seg = seg c
} else {
if (c == "\047") q = ""
seg = seg c
}
}
if (cont) { cont = 0 }
else if (q == "") { print seg; seg = "" }
else { seg = seg " " }
}
END { if (seg != "") print seg }
')
return 0
}
case "$tool" in
Read) check_read ;;
Bash) check_bash ;;
esac
exit 0
@@ -143,3 +143,44 @@ jq -r 'select(.token_class=="installed-skill-edit") | .path_sha256' \
No raw path is ever available in the ledger — only hashes — so the read is
privacy-preserving by construction.
## Read-budget guard (core.context:unbounded-read)
The opt-in read-budget guard (`skills/cc-hooks/hooks/read-budget-guard.sh`,
recipe [READ-BUDGET-GUARD.md](READ-BUDGET-GUARD.md)) reuses this sensor and
this decision rule. Its `token_class` is the policy id
`core.context:unbounded-read`; each line carries five extra fields:
```json
{"ts":"…","session":"…","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
```
- `mode` / `decision` — the dispatcher's pair: `mode` is always `deny` (this
guard never routes); `decision` is `deny` (a fire) or `waived` (an
`AOP_WAIVE` waiver let the call through: one line, no fire).
- `tool` — `Read` or `Bash`.
- `lines` / `budget` — JSON numbers: the effective line count of the offending
read and the budget it exceeded. `path_sha256` hashes the RESOLVED path; the
raw path and the raw command are never written.
**Metric:** the same declining fire-attempt rate per session. Secondary,
stated-denominator estimate: `sum(lines)` over `decision == "deny"` lines is an
upper bound on lines kept out of context (denominator = fires the guard saw; it
says nothing about pipes, redirects, globs, `sed`, `awk`, `less` — silent by design).
**Countermetric:** waiver rate = `waived / (deny + waived)` per session.
**CUT signals (any one):** a fire on a `limit`-bounded Read or on a file at or
below budget — a false positive the predicate is built to make impossible, so
one such line is a defect, not noise; or a waiver rate above 50% at N ≥ 30 —
the budget is wrong for this repository, not the agent (retune
`AOP_READ_BUDGET_LINES`; do not keep a guard everyone waives).
Same **N ≥ 30** minimum and **null-is-acceptable** rule as above: a flat attempt
rate with zero false fires and zero happy-path output is KEEP. Ships INERT —
zero lines until installed; ADR-0002 l.58 is not cleared at landing here either.
```bash
jq -r 'select(.token_class=="core.context:unbounded-read") | [.session,.decision,.tool,.lines] | @tsv' \
"${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}"
```
@@ -0,0 +1,298 @@
# Read-Budget Guard (opt-in)
A PreToolUse `Read|Bash` guard that blocks an **unbounded read of a file over
the line budget** — a `Read` with no `limit`, or a `cat` / `head` / `tail`
whose effective line count exceeds `AOP_READ_BUDGET_LINES` (default 350) — and
names the two correct moves: read a slice, or delegate the file to a cheap
reader that returns line-referenced bullets. AgentOps is hookless by default —
this guard ships **inert**; you activate it with the opt-in installer.
## Why it exists — the rule CLAUDE.md could not enforce
Spotify open-sourced its internal Claude Code setup and reports (its claim, not
re-measured here) a ~90% token cut. The part that transfers is not the number
but the finding behind it: v1 put "never read a large file whole" in CLAUDE.md
and the rule was ignored — advisory context, delta≈0, the same result AgentOps
measured in #511. The rule only held once it moved into a PreToolUse hook that
refuses the tool call and points at the bounded alternatives.
The cost it guards is compounding, not one-shot. An unbounded read of an N-line
file puts N lines into this context **and re-sends them on every later turn**
of the session. A 2,000-line read on turn 3 is paid again on turns 4 through
40. A bounded slice costs its slice once; a delegated read costs a few bullets,
because the file bytes never enter the caller's context at all.
## The predicate — a LOOKUP, so a standalone guard
The policy dispatcher registry (`policies/policies.json`) only lets a
`predicate_class: pure` regex over the raw command or `file_path` `deny` (the #511
anti-lesson). "Is this file over 350 lines?" is not a regex: it is a
**lookup** — one deterministic local check, `wc -l` on the exact argument, no
repo state, no history, no model. So this guard ships as a standalone opt-in
recipe next to [INSTALLED-SKILL-EDIT-GUARD.md](INSTALLED-SKILL-EDIT-GUARD.md)
and never as a registry policy, even though it borrows the registry's id form
(`core.context:unbounded-read`), its waiver mechanics and its telemetry line.
No false-positive surface by construction for the shapes it judges: a `Read`
with a numeric `limit` never fires (a bounded slice is the correct move,
whatever `offset` says); a file at or below budget never fires; a path that is
missing, a directory or binary never fires; a pipe or redirect never fires;
quoted text that merely mentions `cat` (a commit message, an `echo`) is skipped
by the quote rule below. The only thing that fires is a whole-file read that
would exceed the budget — and that is the mistake.
## Deny, not route
The installed-skill-edit guard routes because a wrong edit is recoverable. An
over-budget read is not: once the bytes land in context, nothing un-reads them.
So this guard **denies** (exit 2 + stderr) and **every attempt blocks** — it
never self-relaxes, because the second unbounded read costs exactly what the
first would have. What is once-per-session is the *explanation*: the first fire
in a session prints the full message; later fires print one short line (still
exit 2). The message names the two correct moves and nothing else.
Context-budget doctrine still applies: silent on every happy path (exit 0, zero
stdout, zero stderr — a stray stdout line on an exit-0 PreToolUse path is parsed
as JSON and breaks the tool call), block via exit 2 + stderr only, fail OPEN.
## The contract
Ships as `skills/cc-hooks/hooks/read-budget-guard.sh` (inert until the opt-in
installer wires it; `set -uo pipefail`, no `-e`). It reads the real PreToolUse
JSON on stdin (`{tool_name, tool_input, session_id, cwd}`) with `jq`; a missing
`session_id` is `nosession`. Policy id and `token_class`:
`core.context:unbounded-read`.
### `Read`
- `tool_input.limit` is a number → **PASS**. `offset` alone does not bound a
read and does not pass.
- Otherwise resolve `tool_input.file_path` (relative → against the JSON `cwd`,
else `$PWD`). Not an existing regular readable file, or binary (a NUL byte in
the first 8192 bytes) → **PASS**.
- `lines = wc -l < file`; `lines > budget` → **FIRE**.
### `Bash`
- The command contains any of `|`, `<`, `>` → **PASS**. A pipe feeds a bounded
consumer, a redirect feeds a file sink; neither lands whole in context. Out
of scope by design, not by accident.
- Otherwise split on `;`, `&&` and newlines into segments — a quote-aware
split: a separator counts only outside single or double quotes (backslash
escapes honored outside single quotes), so quoted text that mentions `cat`
stays inside its own command's segment and is never judged, a `# comment`
runs to end of line and is dropped before splitting, and a backslash-newline
is deleted exactly as the shell does (`cat big\` + newline + `.txt` is
`cat big.txt`). `git commit -m "fix; cat big.txt; now routes"` never
fires. Per segment: whitespace-tokenize; a word whose quotes are not a
matched pair around the whole word (or around a `NAME=value` value) — a
quoted path with a space, `foo"bar"`, `-n"500"` — is unparseable and skips
the segment; strip leading `VAR=value` assignments (an
`AOP_WAIVE=...` prefix whose list contains the id waives the WHOLE call — see
below). The command word is the basename of the first remaining token and
must be `cat`, `head` or `tail`; any other command skips the segment.
- Per remaining token: strip one layer of surrounding single or double quotes.
A token starting with `$` or containing a backtick, `*`, `?` or `[` is
unresolvable and is skipped. Flags start with `-`. Files are the non-flag
tokens, resolved against `cwd`; missing, non-regular and binary files are
skipped.
- `cat`: effective = **sum** of the resolved files' line counts → FIRE if over
budget (the message names the largest file; `N` is the total).
- `head`: `-n N`, `-nN`, `-N`, `--lines=N`, `--lines N` (default 10). A
negative count (`-n -K`, "all but the last K") makes effective = the file's
lines. Any `-c` / `--bytes` form skips the segment. Effective =
`min(N, lines)` per file → FIRE if any is over budget.
- `tail`: same flag forms; `-n +K` → effective = `lines - K + 1` (min 0);
`-f` / `--follow` skips the segment → FIRE if any effective is over budget.
### Always PASS (exit 0, zero output)
Any other `tool_name` (an `Edit` of a huge file is a write, not a read); an
empty or unparseable command; `cat` with no file; `git status`; `grep -n`,
`sed -n '1,400p'`, `awk`, `less`, `more` — bounded or paged consumers, silent
by design because they *are* the correct moves.
### Waiver, kill switch, budget
| Control | Effect |
|---|---|
| `AOP_READ_BUDGET_LINES=<n>` | the budget; default 350, and anything that is not a positive integer falls back to 350. Hook env only — an operator setting, never honored as a command prefix (that would be an uncounted self-relax) |
| `AOP_WAIVE=core.context:unbounded-read` | waive once — as hook env, or as a prefix on the Bash command itself (comma list; the id must be in it) |
| `AOP_WAIVER_FILE` line `core.context:unbounded-read <expiry-epoch>` | timed waiver; default file `${AGENTOPS_HOME:-$HOME/.agents/ao}/policy-waivers`, same semantics as the dispatcher; an expired line still fires |
| `AGENTOPS_HOOKS_DISABLED=1` | kill switch: exit 0, silent, no telemetry |
A waived call exits 0 with zero output and writes one telemetry line with
`decision: "waived"`, so waivers are counted — they are the countermetric.
### Fail OPEN
No `jq` on `PATH` → exit 0. Malformed JSON → exit 0, silent. Empty or unknown
tool → exit 0. A guard that cannot decide must never brick the tool call.
Telemetry failure never changes the exit decision.
### The message
First fire in a session (full):
```text
⛔ policy core.context:unbounded-read
<path> is <N> lines (budget <B>). An unbounded read puts every line into this context and re-sends it on every later turn.
→ Read a slice: Read(file_path, offset, limit) with limit ≤ <B>, or Bash: sed -n '1,<B>p' <path> / grep -n <pattern> <path>.
→ Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context:
Agent tool: subagent_type "bulk-reader", prompt "<question>\nfiles: <path>"
Workflow: bulk-read { question: "<question>", files: ["<path>"] }
Waive once: AOP_WAIVE=core.context:unbounded-read (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=<N> in the hook env (an operator setting, not a command prefix).
```
Later fires in the same session (short, still exit 2):
```text
⛔ policy core.context:unbounded-read: <path> is <N> lines (budget <B>) — slice it (offset+limit / sed -n) or delegate to bulk-reader (full reason shown earlier this session).
```
The per-session sentinel lives under `${TMPDIR:-/tmp}/aop-read-budget-guard/`
(one file per `session_id`, `/` replaced by `_`).
### Telemetry
Exactly one JSONL line per FIRE and per WAIVED call — none on pass, disabled or
fail-open — appended to
`${AGENTOPS_GUARDRAIL_TELEMETRY:-${AGENTOPS_HOME:-$HOME/.agents/ao}/guardrail-telemetry.jsonl}`:
```json
{"ts":"2026-09-12T10:00:00Z","session":"<session_id>","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
```
`path_sha256` is the SHA-256 of the **resolved** offending path — never the raw
path, never the command. `lines` and `budget` are JSON numbers. No hasher
(`sha256sum` / `shasum -a 256` / `openssl dgst -sha256`) → no line rather than
a raw path. Methodology and the pre-registered KEEP/CUT rule:
[GUARDRAIL-VALUE-PROOF.md](GUARDRAIL-VALUE-PROOF.md).
## The delegation pairing
The guard's second arrow points at the delegation layer; without it the guard
only says "no". Three bounded, one-shot, cheap-model delegations ship next to
it — Claude Code plugin agents and Workflow-tool conveyors; the caller sees
bullets or a receipt, never bytes, and nothing is kept between calls:
| Piece | What the caller gets |
|---|---|
| `agents/bulk-reader.md` — subagent `bulk-reader` (`Read`/`Grep`/`Glob`/`Bash`, no `Write`/`Edit`, haiku) | line-referenced bullets (`path:line`, at most 40 unless the caller sets another cap), no prose |
| `workflows/bulk-read.js` — `bulk-read { question, files, root?, model?, maxBullets?, budgetLines? }` | one reader per file in parallel; `{question, files:[{file, bullets, lines_covered, complete, note?, error?}], bullets_total}` |
| `workflows/code-write.js` with `agents/code-writer.md` — `code-write { items:[{key, spec, reference, target, check?}] }` | a receipt per item (`written`, `lines`, `check_ok`, `summary`); the caller never reads the file back; a reference file is REQUIRED |
Guard compatibility: the reader and writer prompts read in **slices** (`Read`
with `offset` + `limit ≤ budgetLines`, advancing until a slice comes back
short), never an unbounded `Read`/`cat`/`head`/`tail`. So a delegate's own
reads pass this guard on a host where it is installed — the delegation is not
an exemption, it is a reader that obeys the same rule. A follow-up question
about the same file costs another delegation, not another copy of the file in
this context.
A receipt or a bullet list is a runtime fact, not validation. Whatever a writer
lands still gets fresh, author-distinct judgment like any other change. Pattern
and doctrine in AgentOps terms:
[context-budget delegation](../../agent-native/references/context-budget-delegation.md);
workflow install and args: `workflows/README.md` in the repository checkout.
## Opt-in install
```bash
# user scope (~/.claude/settings.json) — the default
scripts/install-read-budget-guard.sh
# project scope (.claude/settings.json)
scripts/install-read-budget-guard.sh --project
# explicit target
SETTINGS=/path/to/settings.json scripts/install-read-budget-guard.sh
```
The installer copies the guard to `~/.claude/hooks/read-budget-guard.sh`, takes
a timestamped `.bak` of the settings file before mutating it, and adds
(idempotently) one PreToolUse `Read|Bash` matcher:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/read-budget-guard.sh" }
]
}
]
}
}
```
Requires `jq` on `PATH`. The plugin manifest `hooks/hooks.json` is not touched:
nothing wires this guard automatically, on any install path. Uninstall is the
line the installer prints: remove the matcher, then `rm` the copied script.
## Test it
Three bats files round-trip the real PreToolUse JSON (built with `jq -nc`,
never hand-written strings) under an isolated `TMPDIR` and `HOME`, with
`AGENTOPS_GUARDRAIL_TELEMETRY` pointed into `TMPDIR`:
- `tests/scripts/read-budget-guard.bats` — **FIRE** (exit 2, stderr names the
policy id): an unbounded `Read` of a 400-line file, `Read` with `offset`
only, `cat big.txt`, `cat -n big.txt`, `head -n 500` / `-500` /
`--lines=500`, `tail -n 400`, `tail -n +5`, `cat a.txt b.txt` (200 + 200), a
relative path resolved through the JSON `cwd`, a second fire in the same
session (short line, still exit 2), the first fire's output contains
`bulk-reader`. **SILENT** (exit 0, zero output): `Read` with `limit 100`, a
100-line file, a NUL-bearing binary with 400 newlines, a missing path, a
directory, `cat big.txt | head -20`, `cat big.txt > out.txt`, `head big.txt`,
`head -n 50`, `tail -n 20`, `grep -n`, `sed -n '1,400p'`, `cat small.txt`,
`git status`, bare `cat`, `cd sub && cat big.txt`, an `Edit` of a big file.
**WAIVERS**: env, command prefix, waiver file (future expiry passes, expired
still fires), `AGENTOPS_HOOKS_DISABLED=1`, `AOP_READ_BUDGET_LINES=1000`.
**FAIL-OPEN**: malformed JSON `{`, no `jq` on `PATH`.
- `tests/scripts/read-budget-guard-telemetry.bats` — one line per fire; valid
JSON with every field; `lines` and `budget` are numbers; `path_sha256` is 64
hex and equals the hash of the resolved path; the raw path and the raw
command never appear; nothing on the happy path; `waived` on a waiver; two
lines for two fires in one session; nothing when disabled.
- `tests/scripts/install-read-budget-guard.bats` — mode 755; exactly one
`Read|Bash` matcher whose command is the installed path; idempotent re-run;
`--project` writes `.claude/settings.json` in the cwd; a `.bak` when settings
pre-existed; the installed file byte-equals the repo source.
```bash
bats tests/scripts/read-budget-guard.bats \
tests/scripts/read-budget-guard-telemetry.bats \
tests/scripts/install-read-budget-guard.bats
```
## Known limitations
Every gap errs toward silence: a missed case is one un-guarded read, never a
broken tool call. Known false-negative shapes:
- **Pipes and redirects** pass wholesale (`cat big.txt | cat` included) — the
`|` / `<` / `>` check does not inspect the consumer.
- **Globs and variables** (`cat *.log`, `cat "$f"`, backticks) are
unresolvable tokens and are skipped, not expanded. A leading `~/` is the one
expansion mirrored (against `HOME`).
- **Command prefixes** (`sudo cat`, `time cat`, `env X=1 cat`) are silent:
only a segment whose first word is `cat`, `head` or `tail` is judged.
- **Quoted paths with spaces** (`cat "my notes.md"`) tokenize on whitespace
into words whose quotes are not a matched pair → the segment is skipped,
silent. The same rule silences `-n"500"` and `foo"bar"` forms; a whole
quoted word or a quoted `NAME=`/`--opt=` value (`cat "big.txt"`,
`head -n "500"`, `head --lines="500"`, `LC_ALL="C" cat`) is judged.
- **`cd`-chained segments** (`cd sub && cat big.txt`) resolve against the
original `cwd`, not `sub` → not found → silent (a bats-documented gap).
- **`sed`, `awk`, `less`, `more`, `grep`, `xargs`, `sh -c`** are silent by
design; only `cat`, `head` and `tail` are inspected. `head -c` and `tail -f`
skip their segment.
- **Lines, not bytes**: `wc -l` is the predicate, so a one-line multi-megabyte
file passes.
- **A subagent's own reads run under the same hook.** A `bulk-reader` that
issues an unbounded `Read` is blocked like anyone else — which is why the
shipped reader prompt slices. A hand-written reader that does not slice is
denied, not exempted.
+1
View File
@@ -23,6 +23,7 @@
{ "file": "references/HOOK-EVENTS.md", "topic": "all hook events with full input/output schemas" },
{ "file": "references/DCG-RCH.md", "topic": "production examples (dcg, rch) wired as PreToolUse hooks" },
{ "file": "references/SKILL-FIRST-COORDINATION-GUARD.md", "topic": "opt-in skill-first coordination guard recipe + hook context-budget doctrine" },
{ "file": "references/READ-BUDGET-GUARD.md", "topic": "opt-in read-budget guard: blocks unbounded reads over the line budget and routes to slices or cheap-model bulk-read delegation" },
{ "file": "references/PATTERNS.md", "topic": "auto-format, logging, notification hook patterns" },
{ "file": "references/JSON-OUTPUT.md", "topic": "hook response JSON schemas" }
],
+7
View File
@@ -73,6 +73,13 @@ stalled, and rescue is usually cheaper than rerun.
- **Validator:** receives exact candidate content in a fresh, read-only context.
- **Scribe:** records runtime evidence without judging acceptance.
Reader and Writer are bounded cheap delegations, not roles with authority: a
Reader returns line-referenced bullets over files the caller never loads, and a
Writer lands one patterned file from a spec plus a reference file and returns a
receipt the caller never reads back. Both are caller-selected per call, default
to a cheap model, and yield runtime facts only — a receipt is not validation.
See [context-budget delegation](references/context-budget-delegation.md).
## Contract
For a caller-selected parallel batch, validate every complete packet before the
@@ -0,0 +1,68 @@
# Context-Budget Delegation (Reader / Writer)
Keep large file bytes out of the working context by delegating reads and
patterned writes to bounded cheap contexts that return line-referenced bullets
or receipts. Spotify published an internal Claude Code setup built this way and
claims roughly a 90% token reduction; that is Spotify's claim about Spotify's
setup, not a measurement made here. The mechanical finding is what matters: the
same read rule placed in CLAUDE.md was advisory and ignored, and every line of
an unbounded read is re-sent on every later turn for the rest of the session.
## Three layers
| Layer | AgentOps surface | Authority |
|---|---|---|
| Advisory | this reference and the `agent-native` Roles note | none; context the agent may ignore |
| Delegation | `bulk-reader` / `code-writer` subagents (`agents/`), `bulk-read` / `code-write` workflows (`workflows/`) | caller-selected per call |
| Enforcement | the opt-in read-budget guard: [READ-BUDGET-GUARD.md](../../cc-hooks/references/READ-BUDGET-GUARD.md) | mechanical once installed; inert by default |
The delegation surfaces live in the AgentOps source checkout: the subagents are
Claude Code plugin agents and the workflows are Claude-only thin conveyors
(`workflows/README.md`). Neither ships with a standalone installed skill.
## Reader and Writer as bounded cheap delegations
- **Reader** (`bulk-reader` subagent, `bulk-read` workflow): the caller passes a
question and file paths; the reader reads each file completely in slices and
returns bullets only, each starting with `path:line` or `path:start-end`, at
most 40 unless the caller sets another cap, plus truthful `lines_covered` and
`complete`. The caller sees bullets, never bytes, so a follow-up question costs
one more cheap call and zero main-context lines.
- **Writer** (`code-writer` subagent, `code-write` workflow): the caller passes a
spec, a REQUIRED reference file and one target path; the writer matches the
reference's patterns, writes only the target, optionally runs one check, and
returns a receipt (path, line count, check result, a short summary). The caller
never reads the result back.
- Both are one-shot: nothing is kept between calls and AgentOps stores no
delegated file. A dead worker returns an explicit error, never silence.
## Guard compatibility
Readers and writers slice: `Read` with `offset` + `limit`, `limit` at most the
budget (350 lines by default, `AOP_READ_BUDGET_LINES` when set). A subagent's
own reads run under the same PreToolUse hook as the caller's, so an unbounded
read inside a delegate is blocked the same way. The guard never fires on a
bounded slice or on a file at or below budget, so a compliant reader is never
blocked and the delegation works whether or not the guard is installed.
## Model selection belongs to the caller
`haiku` is the default for both delegations; the caller may pin another model
per call (`model` in the workflow args, or the subagent's `model` field). Codex
has no PreToolUse hooks, so only the delegation layer applies there: dispatch a
fresh cheap `codex exec` per [codex-exec](../../codex-exec/SKILL.md) with the
same reader or writer prompt. [model-dispatch](model-dispatch.md) still governs
judgment legs; a reader or writer is an execution role, never a judge.
## Doctrine
- A receipt is a runtime fact, not validation. `written: true`, a line count or
`check_ok: true` proves that a process ran, nothing about acceptance.
[Validate](../../validate/SKILL.md) stays fresh and author-distinct over the
exact written content; the writer's context can never issue that PASS.
- Reader bullets are evidence with a locator, not authority. Re-open the cited
lines before a decision that depends on them.
- No new AO command, scheduler or budget account. The guard is a standalone
opt-in recipe with an installer (ADR-0002: a hook earns its lease on life only
as an optional runtime adapter); the delegations are caller-selected per call;
nothing counts tokens on the agent's behalf or renews a spent bound.
+2 -2
View File
@@ -110,7 +110,7 @@
"worker-handoff",
"per-packet-results"
],
"references_count": 2,
"references_count": 3,
"tier": "meta",
"user_invocable": true
},
@@ -200,7 +200,7 @@
"pragmatic-programmer"
],
"produces": [],
"references_count": 7,
"references_count": 8,
"tier": "execution",
"user_invocable": true
},
+24 -1
View File
@@ -181,6 +181,28 @@ positive value"), the criterion whose absence killed 2.x hooks (#511).
Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md)
## Read-Budget Guard (opt-in)
A PreToolUse `Read|Bash` guard that DENIES an **unbounded read over the line
budget** (`AOP_READ_BUDGET_LINES`, default 350): a `Read` with no `limit`, or a
`cat`/`head`/`tail` whose effective line count exceeds it. The Spotify finding:
the same rule in CLAUDE.md was advisory and ignored, and an over-budget read
re-sends its lines on every later turn. The predicate is a LOOKUP (`wc -l` on
the exact argument), so it is a standalone guard, never a registry policy. A
`limit`-bounded slice, a file at/below budget, a pipe, a redirect or quoted text
that merely mentions `cat` never fires. Nothing un-reads bytes once in context → every
attempt blocks (exit 2 + stderr): full message once per session naming the two
correct moves (slice it, or delegate to the `bulk-reader` subagent /
`bulk-read` workflow), one short line after. Waive once with
`AOP_WAIVE=core.context:unbounded-read`; hashed telemetry adds `tool`, `lines`,
`budget` plus the dispatcher's `mode`/`decision` pair. Ships INERT — opt-in installer:
```bash
scripts/install-read-budget-guard.sh # user scope; --project for project
```
Recipe: [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md)
## Policy Dispatch Engine (ships by default)
The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher —
@@ -312,7 +334,7 @@ claude --debug # Hook execution details
## Output Specification
- **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit) stay inert until opted in.
- **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit, read-budget) stay inert until opted in.
- **Filename:** preserve `settings.json`; give scripts descriptive executable filenames rather than embedding large shell programs in JSON.
- **Format:** valid Claude hook JSON using event arrays, matchers, and command objects; hook stdout/stderr and exit codes follow the selected event schema.
- **Exit code:** validate with `jq -e '.hooks | type=="object"' <settings.json>` and a representative silent/fire test for each matcher; any parse error, noisy happy path, or recursion risk blocks activation.
@@ -329,6 +351,7 @@ claude --debug # Hook execution details
- [HOOK-EVENTS.md](references/HOOK-EVENTS.md) - All events with full schemas
- [DCG-RCH.md](references/DCG-RCH.md) - Production examples (dcg, rch)
- [INSTALLED-SKILL-EDIT-GUARD.md](references/INSTALLED-SKILL-EDIT-GUARD.md) - Opt-in guard routing installed-skill edits to repo skills/ (keystone)
- [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md) - Opt-in guard denying unbounded reads over the line budget; pairs with bulk-read / code-write delegation
- [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md) - Pre-registered value-proof methodology + per-fire telemetry contract (ADR-0002 l.58)
- [PATTERNS.md](references/PATTERNS.md) - Auto-format, logging, notifications
- [JSON-OUTPUT.md](references/JSON-OUTPUT.md) - Response schemas
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env bash
# read-budget-guard (PreToolUse / Read|Bash) — policy core.context:unbounded-read
#
# Blocks an UNBOUNDED read of a text file over the line budget (default 350).
# The mistake-token: a Read without a numeric limit, or a Bash cat/head/tail
# whose EFFECTIVE line count exceeds the budget. Every such line lands in this
# context and is re-sent on every later turn; the same rule written into
# CLAUDE.md was advisory and ignored (the Spotify finding), so it lives here as
# a hook that can refuse. A LOOKUP predicate (wc -l on the exact argument), so
# this is a STANDALONE opt-in guard — never a policies.json registry entry
# (the dispatcher accepts pure regex predicates only). Ships INERT: nothing
# wires it until scripts/install-read-budget-guard.sh is run explicitly.
#
# Decision (deny-not-route: EVERY attempt blocks, the guard never self-relaxes):
# FIRE -> exit 2 + stderr. First fire in a session: the FULL message (both
# correct moves — slice it, or delegate to a bulk-reader); later
# fires in the same session: ONE short line (sentinel-gated).
# PASS / WAIVED / DISABLED -> exit 0, ZERO stdout, ZERO stderr (stray stdout
# on an exit-0 PreToolUse path is parsed as JSON by the harness).
#
# PASS by construction (zero false-positive surface): a Read with a numeric
# limit; a file at/below budget; a missing / non-regular / binary path; a Bash
# command containing | < > (a bounded consumer or a file sink); any command
# word other than cat/head/tail; unresolvable tokens ($VAR, globs, backticks);
# quoted text that merely mentions cat (the split is quote-aware).
#
# Env:
# AOP_READ_BUDGET_LINES line budget (positive integer; malformed -> 350)
# AOP_WAIVE comma list of waived policy ids (env, or an inline
# AOP_WAIVE=<ids> prefix on the Bash command)
# AOP_WAIVER_FILE "<id> <expiry-unix-epoch>" lines, same semantics
# as policy-dispatch.sh
# AGENTOPS_HOOKS_DISABLED=1 kill switch: exit 0, silent, no telemetry
# AGENTOPS_GUARDRAIL_TELEMETRY / AGENTOPS_HOME telemetry ledger location
#
# Telemetry: exactly one JSONL line per FIRE and per WAIVED call — never on
# pass / disabled / fail-open. The RESOLVED offending path is hashed (SHA-256);
# the raw path and the raw command are never stored. Telemetry failure never
# changes the exit decision.
#
# Fail OPEN: no jq -> exit 0; malformed JSON -> exit 0 silent; empty or unknown
# tool -> exit 0. Portable bash 3.2 + BSD tools: no GNU-only flags, no sed -i,
# no mapfile, no associative arrays; head/tail flags parsed with case.
set -uo pipefail
# Tokens are matched literally: a `*` / `?` / `[` in a command must never be
# expanded against the hook's own cwd.
set -f
# Kill switch: silent, no telemetry, before anything else is touched.
[ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0
# Fail OPEN if jq is unavailable: a guard that cannot parse its input must
# never brick a tool call.
command -v jq >/dev/null 2>&1 || exit 0
policy_id="core.context:unbounded-read"
input="$(cat)"
# 2>/dev/null: malformed stdin must be FULLY silent (fail open), never leak jq
# parse errors to stderr.
tool="$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)"
[ -n "$tool" ] || exit 0
case "$tool" in Read|Bash) ;; *) exit 0 ;; esac
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
[ -n "$sid" ] || sid="nosession"
cwd="$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null)"
[ -n "$cwd" ] || cwd="$PWD"
# Budget: a positive integer, else the default. Normalized through base-10
# arithmetic so a leading zero can never reach --argjson as invalid JSON.
budget="${AOP_READ_BUDGET_LINES:-350}"
case "$budget" in ''|*[!0-9]*) budget=350 ;; esac
budget=$((10#$budget))
[ "$budget" -gt 0 ] || budget=350
# Set when a leading AOP_WAIVE=<ids> assignment on the Bash command names this
# policy: the WHOLE call is waived.
inline_waived=0
# resolve_path P → absolute path: relative paths resolve against the JSON cwd.
resolve_path() {
case "$1" in
/*) printf '%s' "$1" ;;
\~|\~/*)
# The shell would expand a leading tilde against HOME; mirror it. With no
# HOME the token stays literal, is never found, and the read passes.
if [ -n "${HOME:-}" ]; then printf '%s%s' "$HOME" "${1#\~}"; else printf '%s/%s' "$cwd" "$1"; fi ;;
*) printf '%s/%s' "$cwd" "$1" ;;
esac
}
# is_text_file P → 0 when P is an existing, readable, regular file with no NUL
# byte in its first 8 KiB (the portable binary test: compare the byte count
# with and without NULs stripped).
is_text_file() {
[ -f "$1" ] && [ -r "$1" ] || return 1
local all stripped
all="$(head -c 8192 "$1" 2>/dev/null | wc -c | tr -d ' ')"
stripped="$(head -c 8192 "$1" 2>/dev/null | tr -d '\000' | wc -c | tr -d ' ')"
[ "$all" = "$stripped" ]
}
# line_count P → number of newline characters in P (trimmed).
line_count() {
wc -l < "$1" 2>/dev/null | tr -d ' '
}
hash_value() {
# SHA-256 of $1 for telemetry privacy; empty string when no hasher exists.
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$1" | sha256sum | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
elif command -v openssl >/dev/null 2>&1; then
printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//'
fi
}
emit_telemetry() {
# $1 decision (deny|waived), $2 resolved path, $3 effective lines, $4 tool.
# Best-effort: no hasher -> no line (never leak the raw path); any failure
# returns 0 so the exit decision is unchanged.
# No ledger location at all (no HOME, no AGENTOPS_* override) -> no line;
# never anchor the default at the filesystem root.
[ -n "${AGENTOPS_GUARDRAIL_TELEMETRY:-}${AGENTOPS_HOME:-}${HOME:-}" ] || return 0
local h
h="$(hash_value "$2")"
[ -n "$h" ] || return 0
local tdir="${AGENTOPS_HOME:-${HOME:-}/.agents/ao}"
local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
local line
line="$(jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg session "$sid" \
--arg token_class "$policy_id" \
--arg path_sha256 "$h" \
--arg mode "deny" \
--arg decision "$1" \
--arg tool "$4" \
--argjson lines "$3" \
--argjson budget "$budget" \
'{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision, tool:$tool, lines:$lines, budget:$budget}' \
2>/dev/null)" || return 0
# Braces: a failed redirect is reported by bash BEFORE a trailing 2>/dev/null
# applies, and an exit-0 path must stay silent.
{ printf '%s\n' "$line" >> "$tfile"; } 2>/dev/null || return 0
}
waived() {
# 0 when a waiver applies: inline prefix, AOP_WAIVE env, or an unexpired
# waiver-file entry (same semantics as policy-dispatch.sh).
[ "$inline_waived" -eq 1 ] && return 0
case ",${AOP_WAIVE:-}," in
*",${policy_id},"*) return 0 ;;
esac
local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME:-}/.agents/ao}/policy-waivers}"
[ -f "$wfile" ] || return 1
local now id expiry
now="$(date +%s)"
while read -r id expiry _; do
[ "$id" = "$policy_id" ] || continue
case "$expiry" in (*[!0-9]*|'') continue ;; esac
[ "$expiry" -gt "$now" ] && return 0
done < "$wfile"
return 1
}
fire() {
# $1 resolved path, $2 effective lines, $3 tool. Never returns.
if waived; then
emit_telemetry "waived" "$1" "$2" "$3"
exit 0
fi
emit_telemetry "deny" "$1" "$2" "$3"
local sdir="${TMPDIR:-/tmp}/aop-read-budget-guard"
local sentinel="${sdir}/${sid//\//_}"
if [ -f "$sentinel" ]; then
printf '⛔ policy %s: %s is %s lines (budget %s) — slice it (offset+limit / sed -n) or delegate to bulk-reader (full reason shown earlier this session).\n' \
"$policy_id" "$1" "$2" "$budget" >&2
exit 2
fi
mkdir -p "$sdir" 2>/dev/null || true
{ : > "$sentinel"; } 2>/dev/null || true
cat >&2 <<MSG
⛔ policy ${policy_id}
$1 is $2 lines (budget ${budget}). An unbounded read puts every line into this context and re-sends it on every later turn.
→ Read a slice: Read(file_path, offset, limit) with limit ≤ ${budget}, or Bash: sed -n '1,${budget}p' $1 / grep -n <pattern> $1.
→ Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context:
Agent tool: subagent_type "bulk-reader", prompt "<question>\nfiles: $1"
Workflow: bulk-read { question: "<question>", files: ["$1"] }
Waive once: AOP_WAIVE=${policy_id} (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=$2 in the hook env (an operator setting, not a command prefix).
MSG
exit 2
}
# ---------------------------------------------------------------- Read ------
check_read() {
local fpath ltype abs lines
fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null)"
[ -n "$fpath" ] || return 0
# A bounded slice always passes; offset alone does NOT bound.
ltype="$(printf '%s' "$input" | jq -r '.tool_input.limit | type' 2>/dev/null)"
[ "$ltype" = "number" ] && return 0
abs="$(resolve_path "$fpath")"
is_text_file "$abs" || return 0
lines="$(line_count "$abs")"
case "$lines" in ''|*[!0-9]*) return 0 ;; esac
[ "$lines" -gt "$budget" ] || return 0
fire "$abs" "$lines" "Read"
}
# ---------------------------------------------------------------- Bash ------
# strip_quotes T → T with ONE layer of surrounding single or double quotes removed.
strip_quotes() {
local t="$1"
case "$t" in
\"*\") t="${t#\"}"; t="${t%\"}" ;;
\'*\') t="${t#\'}"; t="${t%\'}" ;;
esac
printf '%s' "$t"
}
# check_segment SEG — judge one `;` / `&&` segment. Calls fire (never returns)
# when the segment's effective read exceeds the budget; returns 0 otherwise.
check_segment() {
local -a toks
local -a files
local n i t cmdw name v n_raw want_next sign num
read -r -a toks <<< "$1" || true
n=${#toks[@]}
[ "$n" -gt 0 ] || return 0
# Segments arrive from the quote-aware split in check_bash, so every quote
# here belongs to a word of ONE real command. A `#` word starts a comment:
# nothing after it is a command. A word is judgeable only when its quotes
# are a matched pair around the whole word (or around the value of a
# NAME=value assignment); anything else — a quoted path with a space, a
# stray quote, foo"bar" — is unparseable here: skip the segment, silent.
local -a words
local q
words=()
for t in "${toks[@]}"; do
case "$t" in \#*) break ;; esac
words[${#words[@]}]="$t"
done
n=${#words[@]}
[ "$n" -gt 0 ] || return 0
for t in "${words[@]}"; do
case "$t" in *\"*|*\'*) ;; *) continue ;; esac
v="$t"
name="${t%%=*}"
case "$name" in
[A-Za-z_]*) case "$name" in *[!A-Za-z0-9_]*) ;; *) v="${t#*=}" ;; esac ;;
--[A-Za-z]*) case "$name" in *[!A-Za-z0-9-]*) ;; *) v="${t#*=}" ;; esac ;;
esac
case "$v" in
\"\"|\'\') ;;
\"?*\") q="${v//[!\"]/}"; [ "${#q}" -eq 2 ] || return 0 ;;
\'?*\') q="${v//[!\']/}"; [ "${#q}" -eq 2 ] || return 0 ;;
*) return 0 ;;
esac
done
toks=("${words[@]}")
# Leading VAR=value assignments: skip them; an AOP_WAIVE naming this policy
# waives the whole call.
i=0
while [ "$i" -lt "$n" ]; do
t="${toks[$i]}"
case "$t" in
*=*)
name="${t%%=*}"
case "$name" in ''|[0-9]*|*[!A-Za-z0-9_]*) break ;; esac
if [ "$name" = "AOP_WAIVE" ]; then
v="$(strip_quotes "${t#*=}")"
case ",${v}," in *",${policy_id},"*) inline_waived=1 ;; esac
fi
i=$((i + 1))
;;
*) break ;;
esac
done
[ "$i" -lt "$n" ] || return 0
cmdw="$(strip_quotes "${toks[$i]}")"
cmdw="${cmdw##*/}"
case "$cmdw" in cat|head|tail) ;; *) return 0 ;; esac
i=$((i + 1))
n_raw="10"
want_next=""
files=()
while [ "$i" -lt "$n" ]; do
t="$(strip_quotes "${toks[$i]}")"
i=$((i + 1))
if [ -n "$want_next" ]; then
n_raw="$t"
want_next=""
continue
fi
# Unresolvable tokens: variables, command substitution, globs.
case "$t" in \$*|*\`*|*\**|*\?*|*\[*) continue ;; esac
if [ "$cmdw" = "cat" ]; then
# cat flags (-n, -A, ...) never bound the read; everything else is a file.
case "$t" in -*) continue ;; esac
files[${#files[@]}]="$t"
continue
fi
# head / tail flag forms. A byte-mode or follow form makes the segment
# unjudgeable by line count -> skip the whole segment.
case "$t" in
-c|-c*|--bytes|--bytes=*) return 0 ;;
-f|-F|--follow|--follow=*)
[ "$cmdw" = "tail" ] && return 0
continue
;;
-n|--lines) want_next=1; continue ;;
-n*) n_raw="${t#-n}"; continue ;;
--lines=*) n_raw="$(strip_quotes "${t#--lines=}")"; continue ;;
-[0-9]*) n_raw="${t#-}"; continue ;;
-*) continue ;;
esac
files[${#files[@]}]="$t"
done
[ "${#files[@]}" -gt 0 ] || return 0
local abs lines eff total maxlines maxpath
if [ "$cmdw" = "cat" ]; then
# Effective = the SUM over the resolved files; report the largest file.
total=0; maxlines=0; maxpath=""
for t in "${files[@]}"; do
abs="$(resolve_path "$t")"
is_text_file "$abs" || continue
lines="$(line_count "$abs")"
case "$lines" in ''|*[!0-9]*) continue ;; esac
total=$((total + lines))
if [ "$lines" -gt "$maxlines" ] || [ -z "$maxpath" ]; then
maxlines="$lines"; maxpath="$abs"
fi
done
[ -n "$maxpath" ] || return 0
[ "$total" -gt "$budget" ] || return 0
fire "$maxpath" "$total" "Bash"
fi
# head / tail: parse the count. head: N -> min(N, lines); -K -> the whole
# file. tail: N or -K -> min(K, lines); +K -> lines - K + 1 (min 0).
sign=""; num="$n_raw"
case "$n_raw" in
+*) sign="+"; num="${n_raw#+}" ;;
-*) sign="-"; num="${n_raw#-}" ;;
esac
case "$num" in ''|*[!0-9]*) return 0 ;; esac
[ "$cmdw" = "head" ] && [ "$sign" = "+" ] && return 0
num=$((10#$num))
for t in "${files[@]}"; do
abs="$(resolve_path "$t")"
is_text_file "$abs" || continue
lines="$(line_count "$abs")"
case "$lines" in ''|*[!0-9]*) continue ;; esac
if [ "$cmdw" = "head" ] && [ "$sign" = "-" ]; then
eff="$lines"
elif [ "$cmdw" = "tail" ] && [ "$sign" = "+" ]; then
eff=$((lines - num + 1))
[ "$eff" -lt 0 ] && eff=0
else
eff="$num"
[ "$eff" -gt "$lines" ] && eff="$lines"
fi
[ "$eff" -gt "$budget" ] || continue
fire "$abs" "$eff" "Bash"
done
return 0
}
check_bash() {
local cmd seg
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)"
[ -n "$cmd" ] || return 0
# Pipes and redirects are out of scope by design: a bounded consumer or a
# file sink, never an unbounded read into this context.
case "$cmd" in *'|'*|*'<'*|*'>'*) return 0 ;; esac
# Quote-aware split: `;`, `&&` and a newline end a segment only OUTSIDE
# single/double quotes (backslash escapes honored outside single quotes), so
# quoted text that merely mentions `cat` — a commit message, an echo — stays
# inside its own command's segment and is never judged as an invocation. A
# newline inside quotes becomes a space (segments are only ever tokenized).
# A `#` comment (at a word start) runs to end of line and is dropped, so a
# separator or a quote inside a comment never splits or swallows anything;
# a backslash-newline is deleted, joining the segment exactly as the shell
# does (`cat big\` + newline + `.txt` is `cat big.txt`). awk (POSIX, BSD and GNU) keeps
# this a single linear pass in bash 3.2; no awk -> fail open, silent.
command -v awk >/dev/null 2>&1 || return 0
while IFS= read -r seg; do
[ -n "$seg" ] || continue
check_segment "$seg"
done < <(printf '%s\n' "$cmd" | awk '
BEGIN { q = ""; seg = ""; cont = 0 }
{
line = $0; n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)
if (q == "") {
if (c == "\\") {
if (i == n) { cont = 1; break }
seg = seg c substr(line, i + 1, 1); i++; continue
}
if (c == "#" && (i == 1 || substr(line, i - 1, 1) ~ /[ \t;&()]/)) break
if (c == "\"" || c == "\047") { q = c; seg = seg c; continue }
if (c == ";") { print seg; seg = ""; continue }
if (c == "&" && substr(line, i + 1, 1) == "&") { print seg; seg = ""; i++; continue }
seg = seg c
} else if (q == "\"") {
if (c == "\\") { seg = seg c substr(line, i + 1, 1); i++; continue }
if (c == "\"") q = ""
seg = seg c
} else {
if (c == "\047") q = ""
seg = seg c
}
}
if (cont) { cont = 0 }
else if (q == "") { print seg; seg = "" }
else { seg = seg " " }
}
END { if (seg != "") print seg }
')
return 0
}
case "$tool" in
Read) check_read ;;
Bash) check_bash ;;
esac
exit 0
@@ -143,3 +143,44 @@ jq -r 'select(.token_class=="installed-skill-edit") | .path_sha256' \
No raw path is ever available in the ledger — only hashes — so the read is
privacy-preserving by construction.
## Read-budget guard (core.context:unbounded-read)
The opt-in read-budget guard (`skills/cc-hooks/hooks/read-budget-guard.sh`,
recipe [READ-BUDGET-GUARD.md](READ-BUDGET-GUARD.md)) reuses this sensor and
this decision rule. Its `token_class` is the policy id
`core.context:unbounded-read`; each line carries five extra fields:
```json
{"ts":"…","session":"…","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
```
- `mode` / `decision` — the dispatcher's pair: `mode` is always `deny` (this
guard never routes); `decision` is `deny` (a fire) or `waived` (an
`AOP_WAIVE` waiver let the call through: one line, no fire).
- `tool` — `Read` or `Bash`.
- `lines` / `budget` — JSON numbers: the effective line count of the offending
read and the budget it exceeded. `path_sha256` hashes the RESOLVED path; the
raw path and the raw command are never written.
**Metric:** the same declining fire-attempt rate per session. Secondary,
stated-denominator estimate: `sum(lines)` over `decision == "deny"` lines is an
upper bound on lines kept out of context (denominator = fires the guard saw; it
says nothing about pipes, redirects, globs, `sed`, `awk`, `less` — silent by design).
**Countermetric:** waiver rate = `waived / (deny + waived)` per session.
**CUT signals (any one):** a fire on a `limit`-bounded Read or on a file at or
below budget — a false positive the predicate is built to make impossible, so
one such line is a defect, not noise; or a waiver rate above 50% at N ≥ 30 —
the budget is wrong for this repository, not the agent (retune
`AOP_READ_BUDGET_LINES`; do not keep a guard everyone waives).
Same **N ≥ 30** minimum and **null-is-acceptable** rule as above: a flat attempt
rate with zero false fires and zero happy-path output is KEEP. Ships INERT —
zero lines until installed; ADR-0002 l.58 is not cleared at landing here either.
```bash
jq -r 'select(.token_class=="core.context:unbounded-read") | [.session,.decision,.tool,.lines] | @tsv' \
"${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}"
```
@@ -0,0 +1,298 @@
# Read-Budget Guard (opt-in)
A PreToolUse `Read|Bash` guard that blocks an **unbounded read of a file over
the line budget** — a `Read` with no `limit`, or a `cat` / `head` / `tail`
whose effective line count exceeds `AOP_READ_BUDGET_LINES` (default 350) — and
names the two correct moves: read a slice, or delegate the file to a cheap
reader that returns line-referenced bullets. AgentOps is hookless by default —
this guard ships **inert**; you activate it with the opt-in installer.
## Why it exists — the rule CLAUDE.md could not enforce
Spotify open-sourced its internal Claude Code setup and reports (its claim, not
re-measured here) a ~90% token cut. The part that transfers is not the number
but the finding behind it: v1 put "never read a large file whole" in CLAUDE.md
and the rule was ignored — advisory context, delta≈0, the same result AgentOps
measured in #511. The rule only held once it moved into a PreToolUse hook that
refuses the tool call and points at the bounded alternatives.
The cost it guards is compounding, not one-shot. An unbounded read of an N-line
file puts N lines into this context **and re-sends them on every later turn**
of the session. A 2,000-line read on turn 3 is paid again on turns 4 through
40. A bounded slice costs its slice once; a delegated read costs a few bullets,
because the file bytes never enter the caller's context at all.
## The predicate — a LOOKUP, so a standalone guard
The policy dispatcher registry (`policies/policies.json`) only lets a
`predicate_class: pure` regex over the raw command or `file_path` `deny` (the #511
anti-lesson). "Is this file over 350 lines?" is not a regex: it is a
**lookup** — one deterministic local check, `wc -l` on the exact argument, no
repo state, no history, no model. So this guard ships as a standalone opt-in
recipe next to [INSTALLED-SKILL-EDIT-GUARD.md](INSTALLED-SKILL-EDIT-GUARD.md)
and never as a registry policy, even though it borrows the registry's id form
(`core.context:unbounded-read`), its waiver mechanics and its telemetry line.
No false-positive surface by construction for the shapes it judges: a `Read`
with a numeric `limit` never fires (a bounded slice is the correct move,
whatever `offset` says); a file at or below budget never fires; a path that is
missing, a directory or binary never fires; a pipe or redirect never fires;
quoted text that merely mentions `cat` (a commit message, an `echo`) is skipped
by the quote rule below. The only thing that fires is a whole-file read that
would exceed the budget — and that is the mistake.
## Deny, not route
The installed-skill-edit guard routes because a wrong edit is recoverable. An
over-budget read is not: once the bytes land in context, nothing un-reads them.
So this guard **denies** (exit 2 + stderr) and **every attempt blocks** — it
never self-relaxes, because the second unbounded read costs exactly what the
first would have. What is once-per-session is the *explanation*: the first fire
in a session prints the full message; later fires print one short line (still
exit 2). The message names the two correct moves and nothing else.
Context-budget doctrine still applies: silent on every happy path (exit 0, zero
stdout, zero stderr — a stray stdout line on an exit-0 PreToolUse path is parsed
as JSON and breaks the tool call), block via exit 2 + stderr only, fail OPEN.
## The contract
Ships as `skills/cc-hooks/hooks/read-budget-guard.sh` (inert until the opt-in
installer wires it; `set -uo pipefail`, no `-e`). It reads the real PreToolUse
JSON on stdin (`{tool_name, tool_input, session_id, cwd}`) with `jq`; a missing
`session_id` is `nosession`. Policy id and `token_class`:
`core.context:unbounded-read`.
### `Read`
- `tool_input.limit` is a number → **PASS**. `offset` alone does not bound a
read and does not pass.
- Otherwise resolve `tool_input.file_path` (relative → against the JSON `cwd`,
else `$PWD`). Not an existing regular readable file, or binary (a NUL byte in
the first 8192 bytes) → **PASS**.
- `lines = wc -l < file`; `lines > budget` → **FIRE**.
### `Bash`
- The command contains any of `|`, `<`, `>` → **PASS**. A pipe feeds a bounded
consumer, a redirect feeds a file sink; neither lands whole in context. Out
of scope by design, not by accident.
- Otherwise split on `;`, `&&` and newlines into segments — a quote-aware
split: a separator counts only outside single or double quotes (backslash
escapes honored outside single quotes), so quoted text that mentions `cat`
stays inside its own command's segment and is never judged, a `# comment`
runs to end of line and is dropped before splitting, and a backslash-newline
is deleted exactly as the shell does (`cat big\` + newline + `.txt` is
`cat big.txt`). `git commit -m "fix; cat big.txt; now routes"` never
fires. Per segment: whitespace-tokenize; a word whose quotes are not a
matched pair around the whole word (or around a `NAME=value` value) — a
quoted path with a space, `foo"bar"`, `-n"500"` — is unparseable and skips
the segment; strip leading `VAR=value` assignments (an
`AOP_WAIVE=...` prefix whose list contains the id waives the WHOLE call — see
below). The command word is the basename of the first remaining token and
must be `cat`, `head` or `tail`; any other command skips the segment.
- Per remaining token: strip one layer of surrounding single or double quotes.
A token starting with `$` or containing a backtick, `*`, `?` or `[` is
unresolvable and is skipped. Flags start with `-`. Files are the non-flag
tokens, resolved against `cwd`; missing, non-regular and binary files are
skipped.
- `cat`: effective = **sum** of the resolved files' line counts → FIRE if over
budget (the message names the largest file; `N` is the total).
- `head`: `-n N`, `-nN`, `-N`, `--lines=N`, `--lines N` (default 10). A
negative count (`-n -K`, "all but the last K") makes effective = the file's
lines. Any `-c` / `--bytes` form skips the segment. Effective =
`min(N, lines)` per file → FIRE if any is over budget.
- `tail`: same flag forms; `-n +K` → effective = `lines - K + 1` (min 0);
`-f` / `--follow` skips the segment → FIRE if any effective is over budget.
### Always PASS (exit 0, zero output)
Any other `tool_name` (an `Edit` of a huge file is a write, not a read); an
empty or unparseable command; `cat` with no file; `git status`; `grep -n`,
`sed -n '1,400p'`, `awk`, `less`, `more` — bounded or paged consumers, silent
by design because they *are* the correct moves.
### Waiver, kill switch, budget
| Control | Effect |
|---|---|
| `AOP_READ_BUDGET_LINES=<n>` | the budget; default 350, and anything that is not a positive integer falls back to 350. Hook env only — an operator setting, never honored as a command prefix (that would be an uncounted self-relax) |
| `AOP_WAIVE=core.context:unbounded-read` | waive once — as hook env, or as a prefix on the Bash command itself (comma list; the id must be in it) |
| `AOP_WAIVER_FILE` line `core.context:unbounded-read <expiry-epoch>` | timed waiver; default file `${AGENTOPS_HOME:-$HOME/.agents/ao}/policy-waivers`, same semantics as the dispatcher; an expired line still fires |
| `AGENTOPS_HOOKS_DISABLED=1` | kill switch: exit 0, silent, no telemetry |
A waived call exits 0 with zero output and writes one telemetry line with
`decision: "waived"`, so waivers are counted — they are the countermetric.
### Fail OPEN
No `jq` on `PATH` → exit 0. Malformed JSON → exit 0, silent. Empty or unknown
tool → exit 0. A guard that cannot decide must never brick the tool call.
Telemetry failure never changes the exit decision.
### The message
First fire in a session (full):
```text
⛔ policy core.context:unbounded-read
<path> is <N> lines (budget <B>). An unbounded read puts every line into this context and re-sends it on every later turn.
→ Read a slice: Read(file_path, offset, limit) with limit ≤ <B>, or Bash: sed -n '1,<B>p' <path> / grep -n <pattern> <path>.
→ Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context:
Agent tool: subagent_type "bulk-reader", prompt "<question>\nfiles: <path>"
Workflow: bulk-read { question: "<question>", files: ["<path>"] }
Waive once: AOP_WAIVE=core.context:unbounded-read (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=<N> in the hook env (an operator setting, not a command prefix).
```
Later fires in the same session (short, still exit 2):
```text
⛔ policy core.context:unbounded-read: <path> is <N> lines (budget <B>) — slice it (offset+limit / sed -n) or delegate to bulk-reader (full reason shown earlier this session).
```
The per-session sentinel lives under `${TMPDIR:-/tmp}/aop-read-budget-guard/`
(one file per `session_id`, `/` replaced by `_`).
### Telemetry
Exactly one JSONL line per FIRE and per WAIVED call — none on pass, disabled or
fail-open — appended to
`${AGENTOPS_GUARDRAIL_TELEMETRY:-${AGENTOPS_HOME:-$HOME/.agents/ao}/guardrail-telemetry.jsonl}`:
```json
{"ts":"2026-09-12T10:00:00Z","session":"<session_id>","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
```
`path_sha256` is the SHA-256 of the **resolved** offending path — never the raw
path, never the command. `lines` and `budget` are JSON numbers. No hasher
(`sha256sum` / `shasum -a 256` / `openssl dgst -sha256`) → no line rather than
a raw path. Methodology and the pre-registered KEEP/CUT rule:
[GUARDRAIL-VALUE-PROOF.md](GUARDRAIL-VALUE-PROOF.md).
## The delegation pairing
The guard's second arrow points at the delegation layer; without it the guard
only says "no". Three bounded, one-shot, cheap-model delegations ship next to
it — Claude Code plugin agents and Workflow-tool conveyors; the caller sees
bullets or a receipt, never bytes, and nothing is kept between calls:
| Piece | What the caller gets |
|---|---|
| `agents/bulk-reader.md` — subagent `bulk-reader` (`Read`/`Grep`/`Glob`/`Bash`, no `Write`/`Edit`, haiku) | line-referenced bullets (`path:line`, at most 40 unless the caller sets another cap), no prose |
| `workflows/bulk-read.js` — `bulk-read { question, files, root?, model?, maxBullets?, budgetLines? }` | one reader per file in parallel; `{question, files:[{file, bullets, lines_covered, complete, note?, error?}], bullets_total}` |
| `workflows/code-write.js` with `agents/code-writer.md` — `code-write { items:[{key, spec, reference, target, check?}] }` | a receipt per item (`written`, `lines`, `check_ok`, `summary`); the caller never reads the file back; a reference file is REQUIRED |
Guard compatibility: the reader and writer prompts read in **slices** (`Read`
with `offset` + `limit ≤ budgetLines`, advancing until a slice comes back
short), never an unbounded `Read`/`cat`/`head`/`tail`. So a delegate's own
reads pass this guard on a host where it is installed — the delegation is not
an exemption, it is a reader that obeys the same rule. A follow-up question
about the same file costs another delegation, not another copy of the file in
this context.
A receipt or a bullet list is a runtime fact, not validation. Whatever a writer
lands still gets fresh, author-distinct judgment like any other change. Pattern
and doctrine in AgentOps terms:
[context-budget delegation](../../agent-native/references/context-budget-delegation.md);
workflow install and args: `workflows/README.md` in the repository checkout.
## Opt-in install
```bash
# user scope (~/.claude/settings.json) — the default
scripts/install-read-budget-guard.sh
# project scope (.claude/settings.json)
scripts/install-read-budget-guard.sh --project
# explicit target
SETTINGS=/path/to/settings.json scripts/install-read-budget-guard.sh
```
The installer copies the guard to `~/.claude/hooks/read-budget-guard.sh`, takes
a timestamped `.bak` of the settings file before mutating it, and adds
(idempotently) one PreToolUse `Read|Bash` matcher:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/read-budget-guard.sh" }
]
}
]
}
}
```
Requires `jq` on `PATH`. The plugin manifest `hooks/hooks.json` is not touched:
nothing wires this guard automatically, on any install path. Uninstall is the
line the installer prints: remove the matcher, then `rm` the copied script.
## Test it
Three bats files round-trip the real PreToolUse JSON (built with `jq -nc`,
never hand-written strings) under an isolated `TMPDIR` and `HOME`, with
`AGENTOPS_GUARDRAIL_TELEMETRY` pointed into `TMPDIR`:
- `tests/scripts/read-budget-guard.bats` — **FIRE** (exit 2, stderr names the
policy id): an unbounded `Read` of a 400-line file, `Read` with `offset`
only, `cat big.txt`, `cat -n big.txt`, `head -n 500` / `-500` /
`--lines=500`, `tail -n 400`, `tail -n +5`, `cat a.txt b.txt` (200 + 200), a
relative path resolved through the JSON `cwd`, a second fire in the same
session (short line, still exit 2), the first fire's output contains
`bulk-reader`. **SILENT** (exit 0, zero output): `Read` with `limit 100`, a
100-line file, a NUL-bearing binary with 400 newlines, a missing path, a
directory, `cat big.txt | head -20`, `cat big.txt > out.txt`, `head big.txt`,
`head -n 50`, `tail -n 20`, `grep -n`, `sed -n '1,400p'`, `cat small.txt`,
`git status`, bare `cat`, `cd sub && cat big.txt`, an `Edit` of a big file.
**WAIVERS**: env, command prefix, waiver file (future expiry passes, expired
still fires), `AGENTOPS_HOOKS_DISABLED=1`, `AOP_READ_BUDGET_LINES=1000`.
**FAIL-OPEN**: malformed JSON `{`, no `jq` on `PATH`.
- `tests/scripts/read-budget-guard-telemetry.bats` — one line per fire; valid
JSON with every field; `lines` and `budget` are numbers; `path_sha256` is 64
hex and equals the hash of the resolved path; the raw path and the raw
command never appear; nothing on the happy path; `waived` on a waiver; two
lines for two fires in one session; nothing when disabled.
- `tests/scripts/install-read-budget-guard.bats` — mode 755; exactly one
`Read|Bash` matcher whose command is the installed path; idempotent re-run;
`--project` writes `.claude/settings.json` in the cwd; a `.bak` when settings
pre-existed; the installed file byte-equals the repo source.
```bash
bats tests/scripts/read-budget-guard.bats \
tests/scripts/read-budget-guard-telemetry.bats \
tests/scripts/install-read-budget-guard.bats
```
## Known limitations
Every gap errs toward silence: a missed case is one un-guarded read, never a
broken tool call. Known false-negative shapes:
- **Pipes and redirects** pass wholesale (`cat big.txt | cat` included) — the
`|` / `<` / `>` check does not inspect the consumer.
- **Globs and variables** (`cat *.log`, `cat "$f"`, backticks) are
unresolvable tokens and are skipped, not expanded. A leading `~/` is the one
expansion mirrored (against `HOME`).
- **Command prefixes** (`sudo cat`, `time cat`, `env X=1 cat`) are silent:
only a segment whose first word is `cat`, `head` or `tail` is judged.
- **Quoted paths with spaces** (`cat "my notes.md"`) tokenize on whitespace
into words whose quotes are not a matched pair → the segment is skipped,
silent. The same rule silences `-n"500"` and `foo"bar"` forms; a whole
quoted word or a quoted `NAME=`/`--opt=` value (`cat "big.txt"`,
`head -n "500"`, `head --lines="500"`, `LC_ALL="C" cat`) is judged.
- **`cd`-chained segments** (`cd sub && cat big.txt`) resolve against the
original `cwd`, not `sub` → not found → silent (a bats-documented gap).
- **`sed`, `awk`, `less`, `more`, `grep`, `xargs`, `sh -c`** are silent by
design; only `cat`, `head` and `tail` are inspected. `head -c` and `tail -f`
skip their segment.
- **Lines, not bytes**: `wc -l` is the predicate, so a one-line multi-megabyte
file passes.
- **A subagent's own reads run under the same hook.** A `bulk-reader` that
issues an unbounded `Read` is blocked like anyone else — which is why the
shipped reader prompt slices. A hand-written reader that does not slice is
denied, not exempted.
+1
View File
@@ -23,6 +23,7 @@
{ "file": "references/HOOK-EVENTS.md", "topic": "all hook events with full input/output schemas" },
{ "file": "references/DCG-RCH.md", "topic": "production examples (dcg, rch) wired as PreToolUse hooks" },
{ "file": "references/SKILL-FIRST-COORDINATION-GUARD.md", "topic": "opt-in skill-first coordination guard recipe + hook context-budget doctrine" },
{ "file": "references/READ-BUDGET-GUARD.md", "topic": "opt-in read-budget guard: blocks unbounded reads over the line budget and routes to slices or cheap-model bulk-read delegation" },
{ "file": "references/PATTERNS.md", "topic": "auto-format, logging, notification hook patterns" },
{ "file": "references/JSON-OUTPUT.md", "topic": "hook response JSON schemas" }
],
@@ -0,0 +1,148 @@
#!/usr/bin/env bats
# Contract for scripts/install-read-budget-guard.sh — the opt-in installer for
# the read-budget PreToolUse guard (policy core.context:unbounded-read).
#
# The guard ships INERT: nothing wires it until this script is run explicitly.
# The installer copies the guard to $HOME/.claude/hooks/read-budget-guard.sh
# (mode 0755) and adds ONE idempotent PreToolUse "Read|Bash" matcher to a
# Claude settings.json ($HOME/.claude/settings.json, .claude/settings.json with
# --project, or $SETTINGS), taking a timestamped .bak before mutating.
#
# HOME and SETTINGS live inside an isolated TMPDIR so the real user scope is
# never touched.
INSTALLER="${INSTALLER:-$BATS_TEST_DIRNAME/../../scripts/install-read-budget-guard.sh}"
SRC="${SRC:-$BATS_TEST_DIRNAME/../../skills/cc-hooks/hooks/read-budget-guard.sh}"
setup() {
export TMPDIR="$(mktemp -d)"
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
unset SETTINGS || true
DST="$HOME/.claude/hooks/read-budget-guard.sh"
USER_SETTINGS="$HOME/.claude/settings.json"
}
teardown() { rm -rf "$TMPDIR"; }
# file_mode PATH → octal permission bits, GNU stat first (BSD `stat -c` fails
# cleanly; GNU `stat -f` does NOT, it means --file-system — order is load-bearing).
file_mode() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }
# matcher_count SETTINGS → number of PreToolUse entries with matcher "Read|Bash".
matcher_count() {
jq '[.hooks.PreToolUse[]? | select(.matcher == "Read|Bash")] | length' "$1"
}
@test "installer: script exists, is executable, and sources scripts/lib/preamble.sh" {
[ -f "$INSTALLER" ]
[ -x "$INSTALLER" ]
run grep -cF '. "$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/preamble.sh"' "$INSTALLER"
[ "$output" = "1" ]
run grep -cF '"$REPO_ROOT/skills/cc-hooks/hooks/read-budget-guard.sh"' "$INSTALLER"
[ "$output" = "1" ]
# It must not also source lib/repo-root.sh (the preamble owns REPO_ROOT).
run grep -c 'lib/repo-root.sh' "$INSTALLER"
[ "$output" = "0" ]
}
@test "installer: installs the guard file at \$HOME/.claude/hooks with mode 755" {
run bash "$INSTALLER"
[ "$status" -eq 0 ]
[ -f "$DST" ]
[ -x "$DST" ]
[ "$(file_mode "$DST")" = "755" ]
[[ "$output" == *"✓ installed $DST"* ]]
}
@test "installer: the installed guard byte-equals the repo source" {
run bash "$INSTALLER"
[ "$status" -eq 0 ]
cmp -s "$SRC" "$DST"
}
@test "installer: adds exactly one Read|Bash PreToolUse matcher whose command is the installed path" {
run bash "$INSTALLER"
[ "$status" -eq 0 ]
[ -f "$USER_SETTINGS" ]
[ "$(matcher_count "$USER_SETTINGS")" -eq 1 ]
run jq -r '.hooks.PreToolUse[] | select(.matcher == "Read|Bash") | .hooks[0].type + " " + .hooks[0].command' "$USER_SETTINGS"
[ "$output" = "command $DST" ]
run jq '.hooks.PreToolUse[] | select(.matcher == "Read|Bash") | .hooks | length' "$USER_SETTINGS"
[ "$output" = "1" ]
}
@test "installer: re-running is idempotent (still exactly one matcher)" {
run bash "$INSTALLER"
[ "$status" -eq 0 ]
run bash "$INSTALLER"
[ "$status" -eq 0 ]
run bash "$INSTALLER"
[ "$status" -eq 0 ]
[ "$(matcher_count "$USER_SETTINGS")" -eq 1 ]
run jq '[.hooks.PreToolUse[]?.hooks[]? | select(.command == "'"$DST"'")] | length' "$USER_SETTINGS"
[ "$output" = "1" ]
}
@test "installer: --project (run from a TMPDIR cwd) writes .claude/settings.json there" {
proj="$TMPDIR/project"
mkdir -p "$proj"
cd "$proj"
run bash "$INSTALLER" --project
[ "$status" -eq 0 ]
[ -f "$proj/.claude/settings.json" ]
[ "$(matcher_count "$proj/.claude/settings.json")" -eq 1 ]
[ ! -f "$USER_SETTINGS" ]
# The guard itself still lands under $HOME/.claude/hooks (absolute command).
[ -f "$DST" ]
run jq -r '.hooks.PreToolUse[] | select(.matcher == "Read|Bash") | .hooks[0].command' "$proj/.claude/settings.json"
[ "$output" = "$DST" ]
}
@test "installer: SETTINGS env overrides the target settings file" {
custom="$TMPDIR/custom/settings.json"
SETTINGS="$custom" run bash "$INSTALLER"
[ "$status" -eq 0 ]
[ -f "$custom" ]
[ "$(matcher_count "$custom")" -eq 1 ]
[ ! -f "$USER_SETTINGS" ]
}
@test "installer: a timestamped .bak is created when settings pre-existed, and existing keys survive" {
mkdir -p "$(dirname "$USER_SETTINGS")"
printf '{"model":"opus","hooks":{"PreToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"/x/other.sh"}]}]}}\n' > "$USER_SETTINGS"
run bash "$INSTALLER"
[ "$status" -eq 0 ]
[[ "$output" == *"✓ backed up settings"* ]]
bak="$(ls "$USER_SETTINGS".bak.* | head -n 1)"
[ -n "$bak" ]
[ -f "$bak" ]
run jq -r '.model' "$bak"
[ "$output" = "opus" ]
# The backup holds the pre-mutation content: no Read|Bash matcher yet.
[ "$(matcher_count "$bak")" -eq 0 ]
# The live file keeps its prior keys and prior matcher alongside the new one.
run jq -r '.model' "$USER_SETTINGS"
[ "$output" = "opus" ]
run jq '.hooks.PreToolUse | length' "$USER_SETTINGS"
[ "$output" = "2" ]
[ "$(matcher_count "$USER_SETTINGS")" -eq 1 ]
}
@test "installer: prints ✓ lines and an Uninstall line naming the matcher and file" {
run bash "$INSTALLER"
[ "$status" -eq 0 ]
[[ "$output" == *"✓ installed $DST"* ]]
[[ "$output" == *"✓ wired Read|Bash PreToolUse guard into $USER_SETTINGS"* ]]
[[ "$output" == *"Uninstall:"* ]]
[[ "$output" == *"rm -f $DST"* ]]
}
@test "installer: does not touch the repo's hooks/hooks.json (ships inert)" {
repo="$BATS_TEST_DIRNAME/../.."
before="$(cat "$repo/hooks/hooks.json")"
run bash "$INSTALLER"
[ "$status" -eq 0 ]
[ "$(cat "$repo/hooks/hooks.json")" = "$before" ]
run grep -c "read-budget-guard" "$repo/hooks/hooks.json"
[ "$output" = "0" ]
}
@@ -0,0 +1,225 @@
#!/usr/bin/env bats
# Value-proof telemetry contract for skills/cc-hooks/hooks/read-budget-guard.sh.
#
# The guard emits EXACTLY one gate-blind JSONL line per FIRE and per WAIVED
# call (none on pass / disabled / fail-open):
# {ts, session, token_class, path_sha256, mode, decision, tool, lines, budget}
# PRIVACY: neither the raw path nor the raw command is ever persisted — only a
# SHA-256 of the RESOLVED offending path. lines and budget are JSON numbers so
# sum(lines) over fires is a stated-denominator estimate of lines kept out of
# context (see references/GUARDRAIL-VALUE-PROOF.md). Telemetry is inert until
# the guard fires, and the guard ships inert / opt-in.
#
# Fixture fidelity: every case round-trips the REAL PreToolUse JSON input built
# with jq, with TMPDIR / HOME isolated and the ledger pointed into TMPDIR via
# AGENTOPS_GUARDRAIL_TELEMETRY.
GUARD="${GUARD:-$BATS_TEST_DIRNAME/../../skills/cc-hooks/hooks/read-budget-guard.sh}"
POLICY="core.context:unbounded-read"
setup() {
export TMPDIR="$(mktemp -d)"
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
export AGENTOPS_GUARDRAIL_TELEMETRY="$TMPDIR/telemetry.jsonl"
export AOP_WAIVER_FILE="$TMPDIR/waivers"
unset AOP_WAIVE AOP_READ_BUDGET_LINES AGENTOPS_HOOKS_DISABLED || true
WORK="$TMPDIR/work"
mkdir -p "$WORK"
seq 1 400 > "$WORK/big-secret-name.txt"
seq 1 100 > "$WORK/small.txt"
seq 1 200 > "$WORK/a.txt"
seq 1 200 > "$WORK/b.txt"
}
teardown() { rm -rf "$TMPDIR"; }
read_payload() {
jq -nc --arg p "$1" --arg s "$2" --arg c "$WORK" \
'{tool_name:"Read", tool_input:{file_path:$p}, session_id:$s, cwd:$c}'
}
bash_payload() {
jq -nc --arg c "$1" --arg s "$2" --arg d "$WORK" \
'{tool_name:"Bash", tool_input:{command:$c}, session_id:$s, cwd:$d}'
}
run_read() { read_payload "$@" | bash "$GUARD"; }
run_bash() { bash_payload "$@" | bash "$GUARD"; }
telemetry_lines() {
[ -f "$AGENTOPS_GUARDRAIL_TELEMETRY" ] || { echo 0; return; }
wc -l < "$AGENTOPS_GUARDRAIL_TELEMETRY" | tr -d ' '
}
# The SHA-256 the guard would store for a path, mirroring its hasher order.
expected_hash() {
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$1" | sha256sum | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
else
printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//'
fi
}
# --- one well-formed line per fire -------------------------------------------
@test "telemetry: a Read fire appends EXACTLY one JSONL line" {
run run_read "$WORK/big-secret-name.txt" "t1"
[ "$status" -eq 2 ]
[ "$(telemetry_lines)" -eq 1 ]
}
@test "telemetry: the line is valid JSON carrying every contract field" {
run run_read "$WORK/big-secret-name.txt" "t2"
run jq -e '.ts and .session and .token_class and .path_sha256 and .mode and .decision and .tool and (.lines != null) and (.budget != null)' \
"$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$status" -eq 0 ]
run jq -r '.session + " " + .token_class + " " + .mode + " " + .decision + " " + .tool' \
"$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "t2 $POLICY deny deny Read" ]
run jq -r '.ts' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[[ "$output" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
}
@test "telemetry: lines and budget are JSON numbers with the observed values" {
run run_read "$WORK/big-secret-name.txt" "t3"
run jq -r '(.lines|type) + " " + (.budget|type)' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "number number" ]
run jq -r '"\(.lines) \(.budget)"' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "400 350" ]
}
@test "telemetry: a raised budget is recorded as the number in force" {
AOP_READ_BUDGET_LINES=399 run run_read "$WORK/big-secret-name.txt" "t3b"
[ "$status" -eq 2 ]
run jq -r '.budget' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "399" ]
}
@test "telemetry: a Bash cat-sum fire records tool Bash and lines = the total" {
run run_bash "cat a.txt b.txt" "t4"
[ "$status" -eq 2 ]
[ "$(telemetry_lines)" -eq 1 ]
run jq -r '.tool + " " + (.lines|tostring)' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "Bash 400" ]
}
# --- PRIVACY: hash of the RESOLVED path only ----------------------------------
@test "telemetry: path_sha256 equals the hash of the resolved path and is 64 hex chars" {
local p="$WORK/big-secret-name.txt"
run run_read "$p" "t5"
run jq -r '.path_sha256' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "$(expected_hash "$p")" ]
[[ "$output" =~ ^[0-9a-f]{64}$ ]]
}
@test "telemetry: a relative Bash path is hashed as its cwd-RESOLVED form" {
run run_bash "cat big-secret-name.txt" "t6"
[ "$status" -eq 2 ]
run jq -r '.path_sha256' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "$(expected_hash "$WORK/big-secret-name.txt")" ]
}
@test "telemetry: the raw path and the raw command NEVER appear in the ledger" {
run run_read "$WORK/big-secret-name.txt" "t7"
run run_bash "cat -n big-secret-name.txt" "t7"
[ "$(telemetry_lines)" -eq 2 ]
run grep -F "big-secret-name" "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$status" -ne 0 ]
run grep -F "cat -n" "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$status" -ne 0 ]
run grep -F "$WORK" "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$status" -ne 0 ]
}
# --- happy path / disabled write nothing ------------------------------------
@test "telemetry: the happy path (bounded / small / other command) writes NOTHING" {
run run_read "$WORK/small.txt" "h1"
[ "$status" -eq 0 ]
run run_bash "head -n 20 big-secret-name.txt" "h2"
[ "$status" -eq 0 ]
run run_bash "git status" "h3"
[ "$status" -eq 0 ]
[ ! -f "$AGENTOPS_GUARDRAIL_TELEMETRY" ]
}
@test "telemetry: AGENTOPS_HOOKS_DISABLED=1 writes NOTHING" {
AGENTOPS_HOOKS_DISABLED=1 run run_read "$WORK/big-secret-name.txt" "d1"
[ "$status" -eq 0 ]
[ ! -f "$AGENTOPS_GUARDRAIL_TELEMETRY" ]
}
@test "telemetry: fail-open (malformed JSON) writes NOTHING" {
run bash -c 'printf "{" | bash "$1"' _ "$GUARD"
[ "$status" -eq 0 ]
[ ! -f "$AGENTOPS_GUARDRAIL_TELEMETRY" ]
}
# --- waived / repeated fires ---------------------------------------------------
@test "telemetry: a waived call writes ONE line with decision waived (env and inline)" {
AOP_WAIVE="$POLICY" run run_read "$WORK/big-secret-name.txt" "w1"
[ "$status" -eq 0 ]
[ "$(telemetry_lines)" -eq 1 ]
run jq -r '.decision + " " + .mode + " " + .tool' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "waived deny Read" ]
run run_bash "AOP_WAIVE=$POLICY cat big-secret-name.txt" "w2"
[ "$status" -eq 0 ]
[ "$(telemetry_lines)" -eq 2 ]
run jq -r '.decision' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = $'waived\nwaived' ]
}
@test "telemetry: two fires in one session write TWO lines (every attempt is counted)" {
run run_read "$WORK/big-secret-name.txt" "same"
[ "$status" -eq 2 ]
run run_read "$WORK/big-secret-name.txt" "same"
[ "$status" -eq 2 ]
[ "$(telemetry_lines)" -eq 2 ]
run jq -r '.decision' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = $'deny\ndeny' ]
}
@test "telemetry: telemetry failure never changes the exit decision" {
# Point the ledger at a path that cannot be created (a file where a dir is needed).
: > "$TMPDIR/not-a-dir"
AGENTOPS_GUARDRAIL_TELEMETRY="$TMPDIR/not-a-dir/telemetry.jsonl" run run_read "$WORK/big-secret-name.txt" "tf"
[ "$status" -eq 2 ]
[[ "$output" == *"policy $POLICY"* ]]
}
# --- an unwritable ledger never leaks onto an exit-0 path (HOOK-PARSING-2) ---
@test "telemetry: waived call with an UNWRITABLE ledger is exit 0 and fully silent" {
mkdir -p "$TMPDIR/ledger-is-a-dir"
export AGENTOPS_GUARDRAIL_TELEMETRY="$TMPDIR/ledger-is-a-dir"
export AOP_WAIVE="$POLICY"
run run_read "$WORK/big-secret-name.txt" "u1"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "telemetry: a fire with an UNWRITABLE ledger still blocks and the first stderr line is the policy line" {
mkdir -p "$TMPDIR/ledger-is-a-dir"
export AGENTOPS_GUARDRAIL_TELEMETRY="$TMPDIR/ledger-is-a-dir"
run run_read "$WORK/big-secret-name.txt" "u2"
[ "$status" -eq 2 ]
[[ "${lines[0]}" == "⛔ policy $POLICY" ]]
}
@test "telemetry: a QUOTED inline waiver writes its 'waived' line (countermetric kept)" {
run run_bash 'AOP_WAIVE="core.context:unbounded-read" cat big-secret-name.txt' "w9"
[ "$status" -eq 0 ]
run jq -r '.decision' "$AGENTOPS_GUARDRAIL_TELEMETRY"
[ "$output" = "waived" ]
}
@test "telemetry: no HOME and no AGENTOPS_* location writes nothing (never anchors at /)" {
unset AGENTOPS_GUARDRAIL_TELEMETRY AGENTOPS_HOME
run env -u HOME -u AGENTOPS_GUARDRAIL_TELEMETRY -u AGENTOPS_HOME bash -c \
'printf "%s" "$1" | bash "$2"' _ "$(read_payload "$WORK/big-secret-name.txt" "w10")" "$GUARD"
[ "$status" -eq 2 ]
[ ! -e /.agents/ao/guardrail-telemetry.jsonl ] || ! grep -q '"session":"w10"' /.agents/ao/guardrail-telemetry.jsonl
}
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env bats
# Contract for skills/cc-hooks/hooks/read-budget-guard.sh — the opt-in
# PreToolUse / Read|Bash read-budget guard (policy core.context:unbounded-read).
#
# The guard BLOCKS (exit 2 + stderr) an UNBOUNDED read of a text file over the
# line budget (default 350): a Read without a numeric limit, or a Bash cat /
# head / tail whose effective line count exceeds the budget. It is SILENT
# (exit 0, zero stdout, zero stderr) on a bounded slice, a file at/below budget,
# a binary / missing / non-regular path, a piped or redirected command, any
# other command, and every fail-open path. Every attempt blocks (never
# self-relaxes); the first fire in a session prints the full message, later
# fires print one short line.
#
# Fixture fidelity: every case round-trips the REAL PreToolUse JSON input shape
# (tool_name / tool_input / session_id / cwd) built with jq — never a hand-built
# string — per the guard-test fixture-fidelity rule. TMPDIR and HOME are
# isolated per test; telemetry is pointed into TMPDIR.
GUARD="${GUARD:-$BATS_TEST_DIRNAME/../../skills/cc-hooks/hooks/read-budget-guard.sh}"
POLICY="core.context:unbounded-read"
setup() {
export TMPDIR="$(mktemp -d)"
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
export AGENTOPS_GUARDRAIL_TELEMETRY="$TMPDIR/telemetry.jsonl"
export AOP_WAIVER_FILE="$TMPDIR/waivers"
unset AOP_WAIVE AOP_READ_BUDGET_LINES AGENTOPS_HOOKS_DISABLED || true
# The JSON cwd every fixture points at; relative paths resolve against it.
WORK="$TMPDIR/work"
mkdir -p "$WORK/sub"
seq 1 400 > "$WORK/big.txt" # over budget
seq 1 100 > "$WORK/small.txt" # under budget
seq 1 200 > "$WORK/a.txt" # a + b = 400 > 350
seq 1 200 > "$WORK/b.txt"
seq 1 400 > "$WORK/sub/nested.txt" # only exists under sub/ (cd-chain gap)
# A binary file: 400 newlines, but NUL bytes in the first 8 KiB.
: > "$WORK/blob.bin"
local i
for i in $(seq 1 400); do printf 'row %d\000\n' "$i"; done >> "$WORK/blob.bin"
}
teardown() { rm -rf "$TMPDIR"; }
# read_payload FILE_PATH SESSION [OFFSET] [LIMIT] — the real Read PreToolUse
# JSON; offset/limit are emitted as JSON numbers only when given.
read_payload() {
jq -nc --arg p "$1" --arg s "$2" --arg c "$WORK" \
--arg off "${3:-}" --arg lim "${4:-}" '
{tool_name:"Read",
tool_input:({file_path:$p}
+ (if $off != "" then {offset:($off|tonumber)} else {} end)
+ (if $lim != "" then {limit:($lim|tonumber)} else {} end)),
session_id:$s, cwd:$c}'
}
# bash_payload COMMAND SESSION — the real Bash PreToolUse JSON.
bash_payload() {
jq -nc --arg c "$1" --arg s "$2" --arg d "$WORK" \
'{tool_name:"Bash", tool_input:{command:$c}, session_id:$s, cwd:$d}'
}
run_read() { read_payload "$@" | bash "$GUARD"; }
run_bash() { bash_payload "$@" | bash "$GUARD"; }
# stdout_only FN ARGS... — capture ONLY stdout of a guard invocation (stderr
# dropped) into $out and its exit status into $rc, tolerating the exit-2 fire
# so the test's errexit does not trip.
stdout_only() {
rc=0
out="$("$@" 2>/dev/null)" || rc=$?
}
# --- FIRE (exit 2, stderr names the policy id) --------------------------------
@test "FIRE: Read of a 400-line file without limit blocks (exit 2, names the policy)" {
run run_read "$WORK/big.txt" "f-read"
[ "$status" -eq 2 ]
[[ "$output" == *"policy $POLICY"* ]]
[[ "$output" == *"big.txt is 400 lines (budget 350)"* ]]
}
@test "FIRE: Read with offset only (no limit) still blocks — offset alone does not bound" {
run run_read "$WORK/big.txt" "f-offset" 50
[ "$status" -eq 2 ]
[[ "$output" == *"policy $POLICY"* ]]
}
@test "FIRE: Bash 'cat big.txt' blocks" {
run run_bash "cat big.txt" "f-cat"
[ "$status" -eq 2 ]
[[ "$output" == *"policy $POLICY"* ]]
[[ "$output" == *"$WORK/big.txt is 400 lines"* ]]
}
@test "FIRE: Bash 'cat -n big.txt' blocks (cat flags are not line counts)" {
run run_bash "cat -n big.txt" "f-cat-n"
[ "$status" -eq 2 ]
[[ "$output" == *"policy $POLICY"* ]]
}
@test "FIRE: Bash 'head -n 500 big.txt' blocks (min(500,400)=400 > 350)" {
run run_bash "head -n 500 big.txt" "f-head-n"
[ "$status" -eq 2 ]
[[ "$output" == *"is 400 lines"* ]]
}
@test "FIRE: Bash 'head -500 big.txt' blocks (-N form)" {
run run_bash "head -500 big.txt" "f-head-N"
[ "$status" -eq 2 ]
}
@test "FIRE: Bash 'head --lines=500 big.txt' blocks (--lines= form)" {
run run_bash "head --lines=500 big.txt" "f-head-lines"
[ "$status" -eq 2 ]
}
@test "FIRE: Bash 'head -n -5 big.txt' blocks (negative count = whole file minus a tail)" {
run run_bash "head -n -5 big.txt" "f-head-neg"
[ "$status" -eq 2 ]
[[ "$output" == *"is 400 lines"* ]]
}
@test "FIRE: Bash 'tail -n 400 big.txt' blocks" {
run run_bash "tail -n 400 big.txt" "f-tail-n"
[ "$status" -eq 2 ]
[[ "$output" == *"is 400 lines"* ]]
}
@test "FIRE: Bash 'tail -n +5 big.txt' blocks (400-5+1 = 396 > 350)" {
run run_bash "tail -n +5 big.txt" "f-tail-plus"
[ "$status" -eq 2 ]
[[ "$output" == *"is 396 lines"* ]]
}
@test "FIRE: Bash 'cat a.txt b.txt' blocks on the SUM (200+200 > 350)" {
run run_bash "cat a.txt b.txt" "f-cat-sum"
[ "$status" -eq 2 ]
[[ "$output" == *"is 400 lines (budget 350)"* ]]
}
@test "FIRE: a relative Read path is resolved through the JSON cwd" {
run run_read "big.txt" "f-relative"
[ "$status" -eq 2 ]
[[ "$output" == *"$WORK/big.txt is 400 lines"* ]]
}
@test "FIRE: quoted path 'cat \"big.txt\"' blocks (one quote layer stripped)" {
run run_bash 'cat "big.txt"' "f-quoted"
[ "$status" -eq 2 ]
}
@test "FIRE: an absolute command word '/bin/cat big.txt' blocks (basename match)" {
run run_bash "/bin/cat big.txt" "f-basename"
[ "$status" -eq 2 ]
}
@test "FIRE: 'echo x && cat big.txt' and 'cat big.txt; echo y' block (segment split on && and ;)" {
run run_bash "echo x && cat big.txt" "f-and"
[ "$status" -eq 2 ]
run run_bash "cat big.txt; echo y" "f-semi"
[ "$status" -eq 2 ]
}
@test "FIRE: an inline AOP_WAIVE for a DIFFERENT id does not waive" {
run run_bash "AOP_WAIVE=core.other:thing cat big.txt" "f-other-waive"
[ "$status" -eq 2 ]
}
@test "FIRE: second fire in the same session STILL exits 2 and prints the short line" {
run run_read "$WORK/big.txt" "same-session"
[ "$status" -eq 2 ]
[[ "$output" == *"→ Read a slice"* ]]
run run_bash "cat big.txt" "same-session"
[ "$status" -eq 2 ]
[[ "$output" == *"policy $POLICY: $WORK/big.txt is 400 lines (budget 350)"* ]]
[[ "$output" == *"full reason shown earlier this session"* ]]
[[ "$output" != *"→ Read a slice"* ]]
}
@test "FIRE: the first fire's message names bulk-reader and both delegation doors" {
run run_read "$WORK/big.txt" "f-full-msg"
[ "$status" -eq 2 ]
[[ "$output" == *"bulk-reader"* ]]
[[ "$output" == *"→ Read a slice: Read(file_path, offset, limit) with limit ≤ 350"* ]]
[[ "$output" == *"→ Or delegate the whole file to a cheap reader"* ]]
[[ "$output" == *"Agent tool: subagent_type \"bulk-reader\""* ]]
[[ "$output" == *"Workflow: bulk-read { question: \"<question>\", files: [\"$WORK/big.txt\"] }"* ]]
[[ "$output" == *"Waive once: AOP_WAIVE=$POLICY"* ]]
[[ "$output" == *"AOP_READ_BUDGET_LINES="* ]]
}
@test "FIRE: stdout is EMPTY on every fire path (block via exit 2 + stderr only)" {
stdout_only run_read "$WORK/big.txt" "f-stdout-read"
[ "$rc" -eq 2 ]
[ -z "$out" ]
stdout_only run_bash "cat big.txt" "f-stdout-bash"
[ "$rc" -eq 2 ]
[ -z "$out" ]
# ...and the short-line (second fire, same session) path too.
stdout_only run_bash "cat big.txt" "f-stdout-bash"
[ "$rc" -eq 2 ]
[ -z "$out" ]
}
# --- SILENT (exit 0, zero output) ---------------------------------------------
@test "SILENT: Read with limit 100 passes (a bounded slice always passes)" {
run run_read "$WORK/big.txt" "s-limit" "" 100
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: Read with offset AND limit passes" {
run run_read "$WORK/big.txt" "s-offset-limit" 200 100
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: Read of a 100-line file passes" {
run run_read "$WORK/small.txt" "s-small"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: Read of a binary file (NUL bytes, 400 newlines) passes" {
run run_read "$WORK/blob.bin" "s-binary"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: Read of a missing path passes" {
run run_read "$WORK/does-not-exist.txt" "s-missing"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: Read of a directory passes" {
run run_read "$WORK/sub" "s-dir"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'cat big.txt | head -20' passes (pipe = bounded consumer, out of scope)" {
run run_bash "cat big.txt | head -20" "s-pipe"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'cat big.txt > out.txt' passes (redirect = file sink, out of scope)" {
run run_bash "cat big.txt > out.txt" "s-redirect"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'head big.txt' passes (default 10 lines)" {
run run_bash "head big.txt" "s-head-default"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'head -n 50 big.txt' passes" {
run run_bash "head -n 50 big.txt" "s-head-50"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'tail -n 20 big.txt' passes" {
run run_bash "tail -n 20 big.txt" "s-tail-20"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'head -c 100 big.txt' and 'tail -f big.txt' pass (byte / follow forms skipped)" {
run run_bash "head -c 100 big.txt" "s-head-bytes"
[ "$status" -eq 0 ]
[ -z "$output" ]
run run_bash "tail -f big.txt" "s-tail-follow"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'grep -n foo big.txt' passes (not a monitored command)" {
run run_bash "grep -n foo big.txt" "s-grep"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: \"sed -n '1,400p' big.txt\" passes (sed is silent by design)" {
run run_bash "sed -n '1,400p' big.txt" "s-sed"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'cat small.txt' passes (at/below budget)" {
run run_bash "cat small.txt" "s-cat-small"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'git status' passes" {
run run_bash "git status" "s-git"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'cat' with no file passes" {
run run_bash "cat" "s-cat-nofile"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: 'cd sub && cat nested.txt' passes (documented gap: resolved against the original cwd, not found)" {
run run_bash "cd sub && cat nested.txt" "s-cd-chain"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: unresolvable tokens (\$VAR, glob) are skipped" {
run run_bash 'cat $FILE' "s-var"
[ "$status" -eq 0 ]
[ -z "$output" ]
run run_bash "cat *.txt" "s-glob"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: tool_name Edit with a big file_path passes (only Read|Bash are judged)" {
jq -nc --arg p "$WORK/big.txt" --arg c "$WORK" \
'{tool_name:"Edit", tool_input:{file_path:$p, old_string:"1", new_string:"one"}, session_id:"s-edit", cwd:$c}' \
> "$TMPDIR/edit.json"
run bash "$GUARD" < "$TMPDIR/edit.json"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: no session_id in the payload is tolerated (nosession)" {
jq -nc --arg p "$WORK/small.txt" '{tool_name:"Read", tool_input:{file_path:$p}}' > "$TMPDIR/nosess.json"
run bash "$GUARD" < "$TMPDIR/nosess.json"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
# --- WAIVERS / KILL SWITCH / BUDGET ------------------------------------------
@test "WAIVE: AOP_WAIVE env containing the id allows the call silently (Read and Bash)" {
AOP_WAIVE="$POLICY" run run_read "$WORK/big.txt" "w-env-read"
[ "$status" -eq 0 ]
[ -z "$output" ]
AOP_WAIVE="other,$POLICY" run run_bash "cat big.txt" "w-env-bash"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "WAIVE: inline 'AOP_WAIVE=core.context:unbounded-read cat big.txt' allows the call silently" {
run run_bash "AOP_WAIVE=$POLICY cat big.txt" "w-inline"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "WAIVE: waiver file with a future expiry allows the call" {
echo "$POLICY $(( $(date +%s) + 3600 ))" > "$AOP_WAIVER_FILE"
run run_read "$WORK/big.txt" "w-file"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "WAIVE: an EXPIRED waiver-file entry still fires" {
echo "$POLICY $(( $(date +%s) - 10 ))" > "$AOP_WAIVER_FILE"
run run_read "$WORK/big.txt" "w-expired"
[ "$status" -eq 2 ]
[[ "$output" == *"policy $POLICY"* ]]
}
@test "KILL SWITCH: AGENTOPS_HOOKS_DISABLED=1 is silent (exit 0)" {
AGENTOPS_HOOKS_DISABLED=1 run run_read "$WORK/big.txt" "k-read"
[ "$status" -eq 0 ]
[ -z "$output" ]
AGENTOPS_HOOKS_DISABLED=1 run run_bash "cat big.txt" "k-bash"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "BUDGET: AOP_READ_BUDGET_LINES=1000 makes the 400-line file pass" {
AOP_READ_BUDGET_LINES=1000 run run_read "$WORK/big.txt" "b-raised"
[ "$status" -eq 0 ]
[ -z "$output" ]
AOP_READ_BUDGET_LINES=1000 run run_bash "cat big.txt" "b-raised-bash"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "BUDGET: a malformed AOP_READ_BUDGET_LINES falls back to 350 (still fires, names 350)" {
AOP_READ_BUDGET_LINES=lots run run_read "$WORK/big.txt" "b-malformed"
[ "$status" -eq 2 ]
[[ "$output" == *"(budget 350)"* ]]
AOP_READ_BUDGET_LINES=0 run run_read "$WORK/big.txt" "b-zero"
[ "$status" -eq 2 ]
[[ "$output" == *"(budget 350)"* ]]
}
# --- FAIL OPEN ----------------------------------------------------------------
@test "FAIL-OPEN: malformed JSON '{' -> exit 0, silent" {
run bash -c 'printf "{" | bash "$1"' _ "$GUARD"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "FAIL-OPEN: no jq on PATH (empty PATH dir, bash builtins only) -> exit 0, silent" {
mkdir -p "$TMPDIR/emptybin"
bashbin="$(command -v bash)"
read_payload "$WORK/big.txt" "nojq" > "$TMPDIR/payload.json"
run env PATH="$TMPDIR/emptybin" "$bashbin" "$GUARD" < "$TMPDIR/payload.json"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "FAIL-OPEN: empty stdin -> exit 0, silent" {
run bash "$GUARD" < /dev/null
[ "$status" -eq 0 ]
[ -z "$output" ]
}
# --- quote discipline: quoted text is never an invocation (HOOK-PARSING-1) ---
@test "SILENT: a commit message that mentions '; cat big.txt' is text, not a read" {
run run_bash 'git commit -m "guard: block unbounded reads; cat big.txt now routes to bulk-reader"' "q1"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: single-quoted echo containing '; cat big.txt' is text" {
run run_bash "echo 'x; cat big.txt y'" "q2"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: a segment that ends with a dangling quote ('echo \"x && cat big.txt \"') is text" {
run run_bash 'echo "x && cat big.txt "' "q3"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: a multi-line commit body naming 'cat big.txt' on its own line is text" {
run run_bash "$(printf 'git commit -m "wip\n\ncat big.txt is blocked by the guard"')" "q4"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: a quoted path with a space never mis-attributes to a coincidental sibling file" {
seq 1 400 > "$WORK/big" # the coincidental sibling the broken token would hit
seq 1 400 > "$WORK/my big"
run run_bash 'cat "my big"' "q5"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "FIRE: a fully quoted over-budget path still fires (balanced quotes are an invocation)" {
run run_bash 'cat "big.txt"' "q6"
[ "$status" -eq 2 ]
[[ "$output" == *"$POLICY"* ]]
run run_bash "cat 'big.txt'" "q7"
[ "$status" -eq 2 ]
}
# --- tilde paths resolve against HOME (HOOK-PARSING-3) -----------------------
@test "FIRE: 'cat ~/big.txt' resolves the tilde against HOME" {
seq 1 400 > "$HOME/big.txt"
run run_bash 'cat ~/big.txt' "t1"
[ "$status" -eq 2 ]
[[ "$output" == *"$HOME/big.txt is 400 lines"* ]]
}
@test "SILENT: 'cat ~/missing.txt' (tilde, no such file) passes" {
run run_bash 'cat ~/missing.txt' "t2"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
# --- the budget hint names the hook env, and a budget prefix is NOT honored ---
@test "FIRE: 'AOP_READ_BUDGET_LINES=1000 cat big.txt' as a command prefix still fires (no uncounted self-relax)" {
run run_bash 'AOP_READ_BUDGET_LINES=1000 cat big.txt' "b1"
[ "$status" -eq 2 ]
[[ "$output" == *"in the hook env (an operator setting, not a command prefix)"* ]]
}
# --- quote-AWARE split: the separator must be outside quotes -------------------
@test "SILENT: a 'cat big.txt' sandwiched between separators INSIDE a quoted string is text" {
run run_bash 'git commit -m "fix; cat big.txt; routes"' "q8"
[ "$status" -eq 0 ]
[ -z "$output" ]
run run_bash 'git commit -m "fix && cat big.txt && routes"' "q9"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: a multi-line quoted commit body with 'cat big.txt' as a middle line is text" {
run run_bash "$(printf 'git commit -m "subject\n\ncat big.txt on its own line\n"')" "q10"
[ "$status" -eq 0 ]
[ -z "$output" ]
run run_bash "$(printf 'git commit -m "subject\ncat big.txt\n" -m "trailer"')" "q11"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "SILENT: escaped quotes are not quotes, but a word with a stray quote is unparseable" {
run run_bash 'echo \"x; cat big.txt\"' "q12"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "FIRE: a quoted assignment prefix does not hide the read (LC_ALL=\"C\" cat big.txt)" {
run run_bash 'LC_ALL="C" cat big.txt' "q13"
[ "$status" -eq 2 ]
run run_bash "GIT_PAGER='' cat big.txt" "q14"
[ "$status" -eq 2 ]
}
@test "FIRE: a trailing comment with an apostrophe does not hide the read (cat big.txt # don't)" {
run run_bash "cat big.txt # don't" "q15"
[ "$status" -eq 2 ]
}
@test "FIRE: a quoted apostrophe in an earlier segment does not hide a later read" {
run run_bash "echo \"it's\"; cat big.txt" "q16"
[ "$status" -eq 2 ]
}
@test "FIRE: 'head -n \"500\" big.txt' (quoted count) still fires" {
run run_bash 'head -n "500" big.txt' "q17"
[ "$status" -eq 2 ]
}
@test "WAIVE: a QUOTED inline waiver value still waives silently" {
run run_bash 'AOP_WAIVE="core.context:unbounded-read" cat big.txt' "q18"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
# --- comments and continuations in the quote-aware split ----------------------
@test "SILENT: a separator inside a trailing comment never splits ('ls # step 1; cat big.txt for the log')" {
run run_bash 'ls # step 1; cat big.txt for the log' "c1"
[ "$status" -eq 0 ]
[ -z "$output" ]
run run_bash 'echo hi # then && cat big.txt' "c2"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "FIRE: a quote inside a comment line does not swallow the read on the next line" {
run run_bash "$(printf "# Don't rerun the build\ncat big.txt")" "c3"
[ "$status" -eq 2 ]
run run_bash "$(printf 'echo hi # "note\ncat big.txt')" "c4"
[ "$status" -eq 2 ]
}
@test "FIRE: a backslash-newline continuation is one command ('cat \\<newline>big.txt')" {
run run_bash "$(printf 'cat \\\n big.txt')" "c5"
[ "$status" -eq 2 ]
run run_bash "$(printf 'head -n 500 \\\n big.txt')" "c6"
[ "$status" -eq 2 ]
}
@test "FIRE: 'head --lines=\"500\" big.txt' (quoted long-option count) fires" {
run run_bash 'head --lines="500" big.txt' "c7"
[ "$status" -eq 2 ]
}
@test "FAIL-OPEN: jq present but no awk on PATH -> Bash judging is skipped silently (exit 0)" {
mkdir -p "$TMPDIR/bin"
for t in bash jq cat head tr wc sed cut date sha256sum mkdir dirname; do
p="$(command -v "$t")" && ln -s "$p" "$TMPDIR/bin/$t"
done
run env PATH="$TMPDIR/bin" bash -c 'printf "%s" "$1" | bash "$2"' _ "$(bash_payload 'cat big.txt' "c8")" "$GUARD"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "FIRE: a MID-WORD backslash-newline joins without a space ('cat big\\<newline>.txt')" {
run run_bash "$(printf 'cat big\\\n.txt')" "c9"
[ "$status" -eq 2 ]
}
@test "SILENT: a '#' right after a close-paren starts a comment ('(true)# ; cat big.txt')" {
run run_bash '(true)# ; cat big.txt' "c10"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
+41 -1
View File
@@ -5,13 +5,15 @@ are a **Claude-only runtime adapter** — the same doctrine as `skills-codex/`
(Codex-only): canonical source lives here, and a runtime link step installs it
where the one runtime that consumes it resolves names.
Three generic conveyor shapes:
Five generic conveyor shapes:
| Workflow | Shape | Use when |
|---|---|---|
| `audit-dimensions` | pipeline: finder → skeptic, per dimension | auditing a subject across independent lenses |
| `verify-fixes` | parallel adversarial verifiers, one per group | refuting "it's fixed" claims after a change |
| `implement-wave` | parallel disjoint-scope lanes → one fresh verifier | executing a wave of bead-shaped work items |
| `bulk-read` | parallel cheap readers, one per file → line-referenced bullets | answering a question about big files without their bytes entering the caller's context |
| `code-write` | parallel cheap writers, one per item (spec + reference → target) → receipts | writing patterned or boilerplate files the caller should not read back |
Two repository-delivery conveyors also live here, outside the AgentOps
semantic core: `bdd-foundry` (behavior-first planning → acceptance-gated
@@ -110,3 +112,41 @@ Workflow({ name: 'implement-wave', args: {
}})
```
## bulk-read
Delegate large or many files to cheap readers. One reader per file reads the whole file in bounded slices (`Read` with `offset` + `limit ≤ budgetLines`, so the readers pass the opt-in read-budget guard themselves) and answers one question with line-referenced bullets only — `{ ref: 'path:line' | 'path:start-end', text }`, most relevant first, at most `maxBullets`. The file bytes never enter the caller's context; a follow-up question is another cheap call, not a re-read into the main context. Readers are read-only and report `lines_covered` / `complete` truthfully; a missing, binary or unreadable file comes back with zero bullets and a `note`.
Args: `{ question: string, files: [string], root?: string, model?: string (default 'haiku'), maxBullets?: number (default 40), budgetLines?: number (default 350) }`
Returns: `{ question, files: [{ file, bullets: [{ ref, text }], lines_covered, complete, note? }], bullets_total }` — a file whose reader died comes back with empty `bullets`, `complete: false` and an `error` field: an unread file is reported unread, never as an empty answer.
```js
Workflow({ name: 'bulk-read', args: {
question: 'Where are exit codes decided, and which paths return non-zero?',
files: ['cli/internal/gates/runner.go', 'scripts/check-go-lint.sh'],
maxBullets: 20,
}})
```
## code-write
Delegate patterned file writes to cheap writers. One writer per item reads the required `reference` file in bounded slices to learn its patterns (naming, imports, error handling, test shape), writes ONLY its `target` to satisfy `spec`, optionally runs `check` once, and returns a receipt — never the content. `reference` is required: no reference, no writer. Targets must be distinct, and writers land files directly in the working tree (no worktree isolation), so give each item a target nobody else is editing. A receipt is a runtime fact, not validation: judge the written files with a fresh, author-distinct Validate as usual.
Args: `{ context?: string, root?: string, model?: string (default 'haiku'), budgetLines?: number (default 350), items: [{ key, spec, reference, target, check? }] }` — a duplicate `target` throws naming it.
Returns: `{ items: [{ key, target, written, lines, check_ran, check_ok, check_output_tail?, summary }] }` — an item whose writer died comes back with `written: false`, `lines: 0` and an `error` field, never as a receipt.
```js
Workflow({ name: 'code-write', args: {
context: 'Go CLI; tests are table-driven and live next to the source',
items: [
{ key: 'parse-tests',
spec: 'Table-driven tests for ParseFlags covering aliases, unknown flags and the --json/--robot pair.',
reference: 'cli/internal/gates/runner_test.go',
target: 'cli/internal/parse/parse_test.go',
check: 'cd cli && go test ./internal/parse/...' },
],
}})
```
## Context budget
`bulk-read` and `code-write` are the delegation half of the context-budget pattern; the enforcement half is the opt-in read-budget guard shipped inert in the `cc-hooks` skill (`scripts/install-read-budget-guard.sh` wires it as an opt-in PreToolUse hook; nothing installs it automatically). Once installed, that opt-in hook blocks an unbounded `Read`, `cat`, `head` or `tail` of a file over the line budget (`AOP_READ_BUDGET_LINES`, default 350) and its message names both correct moves: slice the file, or delegate it to `bulk-read` / the `bulk-reader` subagent. The readers and writers here slice with `limit ≤ budgetLines`, so they pass the same opt-in hook themselves. Model choice belongs to the caller (`model`, default `haiku`); a receipt or a bullet list is a runtime fact, not validation; nothing here owns a budget account, retry or scheduler. The full pattern lives in `skills/agent-native/references/context-budget-delegation.md`.
+117
View File
@@ -0,0 +1,117 @@
export const meta = {
name: 'bulk-read',
description:
'Delegate large or many files to cheap bulk readers: one reader per file reads the whole file in bounded slices and answers one question with line-referenced bullets only; the file bytes never enter the caller\'s context.',
whenToUse: 'When a file exceeds the read budget (or an opt-in read-budget guard blocked a Read) and the caller needs an answer about its contents, not the contents: caller supplies the question and file paths via args; one cheap reader per file, bullets only.',
phases: [{ title: 'Read', detail: 'one bounded-slice reader per file (parallel), bullets only', model: 'haiku' }],
};
// CONTRACT: a reader returns bullets that cite file:line refs; the caller sees
// this structure and nothing else — never the file bytes.
const READER_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['file', 'bullets', 'lines_covered', 'complete'],
properties: {
file: { type: 'string' },
bullets: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['ref', 'text'],
properties: {
ref: { type: 'string' },
text: { type: 'string' },
},
},
},
lines_covered: { type: 'number' },
complete: { type: 'boolean' },
note: { type: 'string' },
},
};
function badArgs(detail) {
throw new Error(
'bulk-read: bad args (' + detail + '). Expected ' +
'{ question: string, files: [string, ...], root?: string, model?: string, ' +
'maxBullets?: positive number, budgetLines?: positive number }'
);
}
// The harness may deliver args as a JSON-encoded string (see Workflow tool
// docs); normalize before validating so both shapes work.
const input = typeof args === 'string' ? JSON.parse(args) : args;
log('args received as ' + (typeof args) + (input ? ' (normalized ok)' : ' (empty)'));
if (!args || typeof input !== 'object') badArgs('args missing');
if (typeof input.question !== 'string' || !input.question.trim()) badArgs('question must be a non-empty string');
if (!Array.isArray(input.files) || input.files.length === 0 || input.files.some((f) => typeof f !== 'string' || !f.trim())) {
badArgs('files must be a non-empty array of non-empty path strings');
}
if (input.root !== undefined && typeof input.root !== 'string') badArgs('root must be a string when given');
if (input.model !== undefined && (typeof input.model !== 'string' || !input.model.trim())) badArgs('model must be a non-empty string when given');
if (input.maxBullets !== undefined && (typeof input.maxBullets !== 'number' || !(input.maxBullets > 0))) {
badArgs('maxBullets must be a positive number when given');
}
if (input.budgetLines !== undefined && (typeof input.budgetLines !== 'number' || !(input.budgetLines > 0))) {
badArgs('budgetLines must be a positive number when given');
}
const model = input.model || 'haiku';
const maxBullets = input.maxBullets || 40;
const budgetLines = input.budgetLines || 350;
const where = input.root
? 'Work in ' + input.root + '.'
: 'Work in the current repository (the session working directory).';
const basename = (p) => p.split('/').filter(Boolean).pop() || p;
phase('Read');
const results = await parallel(
input.files.map((file) => () =>
agent(
'You are a bulk reader. Your structured return is the ONLY thing the caller sees; the file bytes never reach the caller.\n\n' +
'File to read: ' + file + '\n' +
'Question to answer about it:\n' + input.question + '\n\n' +
'Rules:\n' +
'- ' + where + '\n' +
'- Read the file COMPLETELY in slices with the Read tool: Read(file_path, offset, limit) with limit ≤ ' + budgetLines +
'; advance offset by the slice size until a slice returns fewer lines than limit. Never an unbounded Read, cat, head or tail (an opt-in read-budget hook may block them).\n' +
'- Answer the question with bullets only: each bullet is { ref: "<file>:<line>" or "<file>:<start>-<end>", text: one line of at most 200 characters }, ' +
'most relevant first, at most ' + maxBullets + ' bullets. No prose, no preamble, no multi-line code.\n' +
'- Read-only: no Write, no Edit, no mutating Bash.\n' +
'- Report lines_covered (lines you actually read) and complete (true only when every line was read) truthfully. ' +
'A missing, binary or unreadable file gets zero bullets and a note saying why.\n' +
'- Return file as the path given above.',
{ label: 'bulk-read:' + basename(file), phase: 'Read', schema: READER_SCHEMA, model, effort: 'low' }
)
)
);
// CONTRACT: parallel() resolves failed thunks to null — a dead reader must
// surface as an unread file with an explicit error, never as an empty answer.
const files = input.files.map((file, i) => {
const r = results[i];
if (!r) {
log('bulk-read[' + basename(file) + ']: reader failed; 0 bullets');
return { file, bullets: [], lines_covered: 0, complete: false, error: 'reader agent failed; file was never read' };
}
// The prompt caps bullets at maxBullets; enforce the cap here too so an
// over-eager reader cannot push more than the caller asked for into context.
const out = { file, bullets: r.bullets.slice(0, maxBullets), lines_covered: r.lines_covered, complete: r.complete };
if (r.note) out.note = r.note;
// No silent caps: say what the cap dropped, or the result reads as complete coverage.
const dropped = r.bullets.length - out.bullets.length;
if (dropped > 0) log('bulk-read[' + basename(file) + ']: dropped ' + dropped + ' bullet(s) over maxBullets=' + maxBullets);
log(
'bulk-read[' + basename(file) + ']: ' + out.bullets.length + ' bullets, ' + r.lines_covered + ' lines covered' +
(r.complete ? '' : ' (incomplete)')
);
return out;
});
const bullets_total = files.reduce((n, f) => n + f.bullets.length, 0);
log('bulk-read: ' + bullets_total + ' bullets across ' + files.length + ' file(s)');
return { question: input.question, files, bullets_total };
+135
View File
@@ -0,0 +1,135 @@
export const meta = {
name: 'code-write',
description:
'Delegate patterned file writes to cheap code writers: one writer per item reads a required reference file in bounded slices, writes only its target file to satisfy the spec while matching the reference\'s patterns, optionally runs one check, and returns a receipt; the caller never reads the result.',
whenToUse: 'When boilerplate or patterned code should be written without its content entering the caller\'s context: caller supplies items (spec + required reference + distinct target) via args; one cheap writer per item, receipts only; validation stays elsewhere.',
phases: [{ title: 'Write', detail: 'one reference-patterned writer per item (parallel, disjoint targets)', model: 'haiku' }],
};
// CONTRACT: a writer returns a receipt about the file it wrote — never the
// content. Independent validation of the written file happens elsewhere.
const WRITER_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['key', 'target', 'written', 'lines', 'check_ran', 'check_ok', 'summary'],
properties: {
key: { type: 'string' },
target: { type: 'string' },
written: { type: 'boolean' },
lines: { type: 'number' },
check_ran: { type: 'boolean' },
check_ok: { type: 'boolean' },
check_output_tail: { type: 'string' },
summary: { type: 'string' },
},
};
function badArgs(detail) {
throw new Error(
'code-write: bad args (' + detail + '). Expected ' +
'{ context?: string, root?: string, model?: string, budgetLines?: positive number, ' +
'items: [{ key: string, spec: string, reference: string, target: string, check?: string }] } ' +
'(reference is required; targets must be distinct)'
);
}
// The harness may deliver args as a JSON-encoded string (see Workflow tool
// docs); normalize before validating so both shapes work.
const input = typeof args === 'string' ? JSON.parse(args) : args;
log('args received as ' + (typeof args) + (input ? ' (normalized ok)' : ' (empty)'));
if (!args || typeof input !== 'object') badArgs('args missing');
if (input.context !== undefined && typeof input.context !== 'string') badArgs('context must be a string when given');
if (input.root !== undefined && typeof input.root !== 'string') badArgs('root must be a string when given');
if (input.model !== undefined && (typeof input.model !== 'string' || !input.model.trim())) badArgs('model must be a non-empty string when given');
if (input.budgetLines !== undefined && (typeof input.budgetLines !== 'number' || !(input.budgetLines > 0))) {
badArgs('budgetLines must be a positive number when given');
}
if (!Array.isArray(input.items) || input.items.length === 0) badArgs('items must be a non-empty array');
// CONTRACT: targets are disjoint — writers land files directly in the shared
// working tree with no worktree isolation, so two items on one target would race.
const seenTargets = new Map();
for (const it of input.items) {
if (!it || typeof it.key !== 'string' || !it.key.trim()) badArgs('every item needs a string key');
if (typeof it.spec !== 'string' || !it.spec.trim()) badArgs('item "' + it.key + '" needs a non-empty string spec');
if (typeof it.reference !== 'string' || !it.reference.trim()) {
badArgs('item "' + it.key + '" needs a non-empty string reference (no reference, no writer)');
}
if (typeof it.target !== 'string' || !it.target.trim()) badArgs('item "' + it.key + '" needs a non-empty string target');
if (it.check !== undefined && typeof it.check !== 'string') badArgs('item "' + it.key + '" check must be a string when given');
if (seenTargets.has(it.target)) {
badArgs('duplicate target "' + it.target + '" (items "' + seenTargets.get(it.target) + '" and "' + it.key + '"); targets must be distinct');
}
seenTargets.set(it.target, it.key);
}
const model = input.model || 'haiku';
const budgetLines = input.budgetLines || 350;
const where = input.root
? 'Work in ' + input.root + '.'
: 'Work in the current repository (the session working directory).';
const contextBlock = input.context ? '\nContext from the caller:\n' + input.context + '\n' : '';
phase('Write');
const receipts = await parallel(
input.items.map((item) => () =>
agent(
'You are a code writer. You write exactly one file from a spec, matching the patterns of a reference file, and return a receipt. ' +
'The caller will NOT read the file you write; independent validation happens elsewhere.\n' +
contextBlock + '\n' +
'Item key: ' + item.key + '\n' +
'Reference file (patterns to match): ' + item.reference + '\n' +
'Target file (the ONLY file you may create or edit): ' + item.target + '\n' +
'Spec:\n' + item.spec + '\n\n' +
'Rules:\n' +
'- ' + where + '\n' +
'- Read the reference file in slices with the Read tool: Read(file_path, offset, limit) with limit ≤ ' + budgetLines +
'; advance offset until a slice returns fewer lines than limit. Learn its naming, imports, error handling and test shape. ' +
'Never an unbounded Read, cat, head or tail (an opt-in read-budget hook may block them).\n' +
'- Write ONLY the target file so it satisfies the spec while matching the reference\'s patterns. Code only: no markdown fences, no prose outside normal code comments.\n' +
'- Do not create, edit or delete any other file. Other writers are working in the same tree on other targets.\n' +
(item.check
? '- After writing, run this check ONCE with Bash and report check_ran: true, check_ok (exit status 0) and check_output_tail (the last 20 lines of its output):\n ' + item.check + '\n'
: '- No check was given: report check_ran: false and check_ok: false.\n') +
'- NEVER return the file content. Return a receipt only: key, target, written, lines (line count of the target after writing), ' +
'the check fields, and a summary of at most 300 characters saying what was written (no code).',
{ label: 'code-write:' + item.key, phase: 'Write', schema: WRITER_SCHEMA, model, effort: 'medium' }
)
)
);
// CONTRACT: parallel() resolves failed thunks to null — a dead writer must
// surface as an unwritten target with an explicit error, never as a receipt.
const items = input.items.map((item, i) => {
const r = receipts[i];
if (!r) {
log('code-write[' + item.key + ']: writer failed; nothing written');
return {
key: item.key,
target: item.target,
written: false,
lines: 0,
check_ran: false,
check_ok: false,
summary: '',
error: 'writer agent failed; nothing was written by this lane',
};
}
const out = {
key: item.key,
target: item.target,
written: r.written,
lines: r.lines,
check_ran: r.check_ran,
check_ok: r.check_ok,
summary: r.summary,
};
if (r.check_output_tail) out.check_output_tail = r.check_output_tail;
log(
'code-write[' + item.key + ']: ' + (r.written ? 'written, ' + r.lines + ' lines' : 'NOT written') +
(r.check_ran ? ', check ' + (r.check_ok ? 'ok' : 'FAILED') : ', no check')
);
return out;
});
return { items };