feat(bus): hop guards, per-role rate limit, reserved roles, inbox --peek (#344)#351
Merged
Merged
Conversation
…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>
cdfea05 to
e0279a9
Compare
Merged
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Out of scope (RFC §11 puts these in later milestones)
Test plan
🤖 Generated with Claude Code