Skip to content

feat(bus): hop guards, per-role rate limit, reserved roles, inbox --peek (#344)#351

Merged
mercurialsolo merged 1 commit into
mainfrom
feat/supervisor-pr2-bus-guards-peek
Jun 9, 2026
Merged

feat(bus): hop guards, per-role rate limit, reserved roles, inbox --peek (#344)#351
mercurialsolo merged 1 commit into
mainfrom
feat/supervisor-pr2-bus-guards-peek

Conversation

@mercurialsolo

Copy link
Copy Markdown
Owner

PR2 of the supervisor RFC (#342). Gating dependency — the supervisor lives on this fabric, and without these guards "leave it running overnight" ships an unbounded ping-pong burn loop and a hostile-cwd escalation intercept. Closes #344.

Why now

PR1 (#350) aligned defaults so cargo and brew users get the same binary. This PR makes that binary safe to leave running with the supervisor on top of it (#345 onward).

What ships

Hop guard

`messages` gains a `hop_count` column via idempotent migration. `publish` takes a new `parent_hop` arg and writes `parent_hop + 1`; values above `policy::DEFAULT_MAX_HOPS` (8) are refused with a structured error. `bus send` starts forwards at hop 1. Cap of 8 matches Claude Code's own Stop-hook 8-block runtime cap so the ninth attempt becomes an escalation event, not a silent retry.

Per-sender rate limit

New `src/bus/rate_limit.rs`. In-process token bucket keyed on `sender_role` — the bus MCP server is a single process, so cross-process coordination is unnecessary. Default 60 messages/minute per role, refilling continuously (no background timer). `Instant` is injected so tests drive the clock without sleeping. Anonymous sends bypass the limiter; the reserved-role guard shields the privileged endpoints.

Reserved roles

`store::upsert_role` validates the name against `policy::RESERVED_ROLES` (`supervisor`, `operator`) — case-insensitive. Addressing these roles stays open (escalations must work from any sender); only binding is locked down.

`read_inbox` peek mode

`ReadInboxArgs { peek: bool }` and new `store::peek_inbox`. Shares the SQL query with `drain_inbox` so the two paths never diverge on "what counts as pending." Supervisor uses this for exactly-once assignment: peek to decide, then drain at commit. CLI flag: `bus inbox --peek`. The Stop-hook path must still drain — leaving messages pending would redeliver them every turn boundary.

Divergence from the issue text

#344 originally said hop count would be a JSON body header (`x-hop: N`). I switched to a dedicated column — it keeps the body opaque (arbitrary user text) and the query/index trivial. The behavior the issue specifies (refuse forwards over the cap, log + escalate) is unchanged.

Verification

```
cargo check --features bus ✓
cargo clippy --features bus -- -D warnings ✓
cargo fmt --all -- --check ✓
cargo test --features bus 627 + 635 + 78 + 8 passed
```

Covered:

  • `hop_count` round-trips through insert/drain
  • peek twice returns same set; drain after peek still drains; peek after drain is empty
  • reserved-role bind rejected with no partial write
  • rate limiter: burst-to-capacity, refill-at-steady-state, per-role isolation, capacity-as-hard-ceiling
  • hop validator: 0/max/over-max boundary cases

Out of scope (RFC §11 puts these in later milestones)

  • Multi-process rate-limit coordination (single-process is correct for stdio MCP)
  • Address-time ACLs (publishing to a reserved role stays open)
  • In-flight loop detection beyond hop counting

Test plan

  • Confirm `cargo install` on PR1's branch + this PR can run `claudectl bus inbox --peek` cleanly.
  • Manually try `claudectl bus role bind supervisor /tmp` — should fail with the reserved-role error.
  • Manually try `claudectl bus send supervisor 'test' --from anywhere` — should still succeed (addressing reserved roles stays open).

🤖 Generated with Claude Code

…eek (#344)

PR2 of the supervisor RFC (#342). Gating dependency: the supervisor lives
on this fabric, and without these guards "leave it running overnight"
ships an unbounded ping-pong burn loop and a hostile-cwd escalation
intercept. Closes #344.

## Hop guard (`policy::DEFAULT_MAX_HOPS = 8`)

`messages` gains a `hop_count` column via idempotent migration. `publish`
takes a new `parent_hop` arg and writes `parent_hop + 1`; values above
the cap are refused with a structured error. `bus send` (CLI) starts
forwards at hop 1.

8 was picked to match Claude Code's own Stop-hook 8-block cap — the
runtime gives us a guarantee that the ninth attempt becomes an escalation
event, not a silent retry.

## Per-sender-role rate limit (token bucket, 60/min default)

New `src/bus/rate_limit.rs`. In-process, keyed on `sender_role`, because
the bus MCP server is a single process. Anonymous sends bypass the
limiter — the reserved-role guard shields the privileged endpoints, and
anon traffic isn't the burn-loop vector.

Drained buckets refill continuously without a background timer; the
Instant is injected so tests drive the clock without sleeping.

## Reserved roles (`supervisor`, `operator`)

`store::upsert_role` now calls `policy::validate_role_name` and refuses
any case-insensitive match against the reserved list. *Addressing* these
roles stays open (escalation must work from any sender); only *binding*
is locked down. Without this, a hostile cwd could claim to be the
supervisor and intercept escalations.

## `read_inbox` peek mode

`ReadInboxArgs` gains `peek: bool`. New `store::peek_inbox` shares the
SQL with `drain_inbox` (one query, no divergence on "what counts as
pending") and never mutates status. Supervisor uses this for
exactly-once assignment: peek to decide, then drain at commit.

`bus inbox --peek` CLI flag added with the same semantics. The Stop-hook
path must still drain (leaving messages pending would redeliver them
every turn boundary).

## Verification

cargo check / clippy / test all green with `--features bus`. Includes:

- hop_count round-trips through insert/drain
- peek twice returns same set; drain after peek still drains; peek after
  drain is empty
- reserved-role bind rejected with no partial write
- rate limiter: burst-to-capacity, refill-at-steady-state,
  per-role isolation, capacity-as-hard-ceiling

The deferred work — multi-process rate-limit coordination, address-time
ACLs, in-flight loop detection — is out of scope. This PR ships the
minimum the supervisor needs to be safe overnight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mercurialsolo mercurialsolo force-pushed the feat/supervisor-pr2-bus-guards-peek branch from cdfea05 to e0279a9 Compare June 9, 2026 22:14
@mercurialsolo mercurialsolo merged commit 4772c8f into main Jun 9, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

supervisor M1/PR2: bus flow guards + reserved roles + inbox --peek (gating)

1 participant