A Tour of the Harness · v4 · M17

Capability broker

Every tool call answers to what the human actually asked for, and the record of what was allowed and refused is signed. Four stops, about twenty minutes.

Built M17, v4
Kept in sync by a test

A ticket is what an intent may do, carrying its own chain of custody. A subagent spawned under one can only narrow its caveats, never loosen them — the same "humans widen, machines tighten" rule (invariant 2) applied to scope rather than to permission mode (ADR-0039).

flowchart TD
    typed["a typed line
PromptTyped, depth 0"] --> intent["Intent
the why"] scope(["--scope / /scope
KEY=VALUE"]) --> ticket["Ticket
caveats: tools, paths, hosts, calls, until"] task(["task tool
delegation"]) -->|"attenuate() only adds"| ticket ticket --> gate["authorize()
pre_tool, one step before permissions"] gate -->|"refuse"| refuse["ScopeRefused —
an error tool result, caveat named"] gate -->|"allow"| run["the call runs"] intent --> receipt[("Receipts
signed, hash-chained JSONL")] ticket --> receipt refuse --> receipt run --> receipt

1Intent and the ticket

broker/intent.py broker/ticket.py broker/caveats.py

Look for: Intents Ticket attenuate verify_chain Caveat parse_scope

Intents is the bus subscriber that opens an intent from a typed line — the same "the boundary is the subscription" shape as learning's own subscribers: it reads exactly one event kind, PromptTyped at depth 0, and nothing a subagent, a tool result or an @path body produces ever reaches it (MEM-8, MEM-9). parse_scope turns human-typed KEY=VALUE pairs — tools=, paths=, hosts=, calls=, until= — into Caveats, resolving a relative until=10m to an absolute instant right away, so a ticket's expiry never depends on when someone later checks it.

  • attenuate() is the only way to get a child ticket. A list-shaped caveat can only gain items; calls and until can only tighten — so "merge" means "narrow" for the two of them.
  • verify_chain() walks the parent links and fails the moment a child's caveats are not at least as strict as its parent's. It compares what each kind means (a list superset, or a tighter limit), never raw equality — a naive set comparison flags even an honest attenuation as a forgery, since attenuate() legitimately rewrites a caveat's value string (tools=a + tools=b → tools=a,b). A hypothesis property test caught that the first time round.

2The veto, one step before permissions

broker/guard.py broker/authorize.py

Look for: TicketGuard check narrowed authorize Refusal

authorize() is pure and property-tested, like permissions.decide() on the Core tour: the same ticket, the same call, always refuses or allows the same way. tools/execute.py's pipeline is validate → pre_tool hooks → ticket → permission → run → spill — the ticket check runs first, on the same resolved Subject the permission engine reuses, so a refused call never even reaches the mode's own rules. TicketGuard is the mutable adapter execute() actually calls; it matches tools/base.py's Broker Protocol structurally, so Core never imports this file, only its shape.

  • A paths or hosts caveat can only check a tool whose subject resolves to a path or a URL. shell, and any command tool not marked read-only, has neither — it is refused outright unless named in tools=. That is the deliberate, visible cost of scoping to files or hosts: a paths=-only ticket does not, by itself, stop a URL-shaped call from reaching an outside host — that needs hosts=.
  • A call the ticket allows counts toward calls=; a refused one does not, since it never happened as far as the ticket's own budget is concerned.
  • narrowed() is task's delegation seam: agents/spawn.py calls it with the agent definition's own tools: and any model-given scope argument, both folded in through attenuate() — never a drop.

3The signed receipt

broker/receipt.py

Look for: Receipts append sign verify load_or_create_key Break

Every decision the broker makes — intent, ticket, delegation, refusal, permission — is one line in an append-only JSONL log at .edgar/sessions/<id>/receipt.jsonl. Each line signs its own prev/kind/data with HMAC-SHA256; prev is the SHA-256 of the previous line's exact text, so tampering with an earlier line breaks every hash after it, and tampering with a line's own content breaks its own signature. verify() checks both, in order, and stops at the first Break.

  • The key lives at ~/.edgar/receipt.key, 32 random bytes made once, mode 0600 from the first byte (on Windows, which has no modes, the user profile's own permissions guard it) — a hard-layer credential path the model's own tools can never read, the same protection permissions.matcher already gives real credentials.
  • Receipts is a bus subscriber, the same shape as Intents: one line per depth-0 PromptTyped (intent), depth>0 TurnStarted (delegate), ScopeRefused (refuse), and PermissionResolved (permission); the ticket itself is written once, directly, when a session starts one.

4edgar receipt, and the wiring

broker/cli.py broker/__init__.py

Look for: command attach from_scope describe

command is edgar receipt [ID] [--refused] [--verify]'s whole implementation: with no flag it tells the story, one line per entry; --refused narrows to what was refused; --verify replays the chain and exits 1 at the first break. attach() is the one function that wires both subscribers onto a real bus, called from cli/setup.py's _broker() — the fourth seam reached by name through import_module, the same way cli/setup.py reaches escalation and the controller, so nothing outside broker/ ever imports it directly (NFR-12).

  • from_scope() and describe() are what --scope and /scope actually call — building a live ticket from typed KEY=VALUE pairs, and summarising one for a bare /scope.
  • [broker] enabled = false (config/schema.py, default true) turns off both the veto and the receipt through this same seam. A setting that read fine and did nothing would be exactly the hidden behaviour this harness promises not to have.

Sizes

Lines of code: blank lines and comments do not count, docstrings do. just loc prints the current totals.

FileLines of codeWhat it is
broker/intent.py~27Opens an intent from a typed line, and nothing else
broker/caveats.py~37The five caveat kinds, and parsing KEY=VALUE
broker/ticket.py~57Attenuation, and chain verification
broker/guard.py~35The mutable adapter the pipeline actually calls
broker/authorize.py~72The pure veto, property-tested
broker/receipt.py~78The hash-chained, signed log
broker/cli.py~33edgar receipt's whole implementation
broker/__init__.py~26The one seam cli/setup.py reaches by name

Source: just loc, which counts with tests/support/budget.py. Rounded; the tour's test fails if a figure drifts more than 25 lines of code from the code.