Skip to content

Implement fan-out subagent guard middleware#226

Open
KunjShah95 wants to merge 3 commits into
andrewyng:mainfrom
KunjShah95:feature/fanout-subagent-guardrails
Open

Implement fan-out subagent guard middleware#226
KunjShah95 wants to merge 3 commits into
andrewyng:mainfrom
KunjShah95:feature/fanout-subagent-guardrails

Conversation

@KunjShah95

@KunjShah95 KunjShah95 commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Implement guard middleware for fan-out subagent tool calls to enforce safety policies, limit concurrent subagents, and log all decisions for audit. Implements the plan in docs/superpowers/plans/2026-07-26-fanout-subagent-guardrails.md.

Changes

New Files

  • coworker/guard/ruleset.py - GuardRuleSet: load YAML rules, evaluate tool calls with command match patterns (contains/prefix/regex), argument matching, and fan-out concurrency limits with is_fanout_block for robust detection
  • coworker/guard/logger.py - GuardLogger: dedicated audit log with timestamped ALLOW/DENY entries
  • coworker/guard/config_loader.py - GuardConfigLoader: load/cache YAML with file-change detection for hot reload
  • coworker/guard/middleware.py - GuardMiddleware: layered authorization combining PermissionEngine, GuardRuleSet, and GuardLogger; fail-safe on exceptions (deny + needs_user)
  • config/guard.yaml - Default guard configuration with safety rules (block recursive deletes, chmod -R, mkfs; limit explorer fanout to 3 concurrent)
  • tests/test_guard.py - 31 unit tests for all guard components

Modified Files

  • coworker/engine.py - Added guard_middleware parameter; routes _authorize() through middleware; tracks start/end for fan-out counting; eager track_start now denies tools that exceed fanout limit mid-execution
  • coworker/agent.py - Wires GuardMiddleware for code agents; config path overridable via COWORKER_GUARD_CONFIG env var
  • coworker/CLAUDE.md - Added guardrails subsystem documentation section
  • .gitignore - Added guard.log to ignore list

Integration Tests (test_engine.py)

  • Refactored _engine() and _bare_engine() to accept mode= and guard_middleware= kwargs
  • _guard_engine() and _guard_bare_engine() delegate to base helpers (eliminated ~50% boilerplate)
  • Barrier-based concurrent fanout test: 3 concurrent tools, max_concurrent=2, first two succeed concurrently, third denied by fanout limit
  • Fail-safe exception test: broken ruleset raises -> middleware returns needs_user=True -> PERMISSION_REQUIRED event -> tool denied

Architecture

The GuardMiddleware layers on top of the existing PermissionEngine:

  1. Base PermissionEngine evaluates the tool (mode, path scoping, session allowlists)
  2. Guard rules overlay apply deny/allow/fanout-limit policies from YAML config
  3. Eager track_start: after authorization, track_start checks the runtime fanout count BEFORE incrementing. If at limit, the tool is denied immediately and never executes
  4. All decisions are logged to a dedicated guard.log for audit trails
  5. On exception, fails safe (deny + needs_user) so no dangerous call slips through

Testing

All 31 unit tests + multiple engine integration tests pass covering: empty ruleset, deny/allow rules, command match patterns, argument matching, fan-out limits (block after max, concurrent Barrier test, serial tracking), YAML loading, log output, config caching, middleware integration, fail-safe on exceptions, config hot reload, and refactored engine helpers.

KunjShah95 and others added 3 commits July 26, 2026 22:58
The small file-backed stores (Inbox, inbox routing, mention threads, channel
subscriptions, unrouted/dead-letter, unattended flags, per-persona/per-session
connection maps, and user risk overrides) each loaded their JSON file with an
unguarded json.loads inside __init__ — and every one is constructed eagerly in
SessionManager.__init__. So a single truncated or malformed file turned into an
exception that aborted server startup entirely, recoverable only by finding and
deleting the file by hand. Several of the same stores also wrote their files
non-atomically (write_text in place), which is exactly what produces the corrupt
file after a crash/kill/disk-full mid-write.

This generalizes the resilient pattern already used inline by ChannelBuffer and
WorkspaceTrustStore into two shared helpers (coworker/jsonstore.py):

- read_json(): missing/corrupt file -> caller default, moving the bad file aside
  to <name>.corrupt so it is neither lost nor re-hit on the next boot.
- write_json_atomic(): temp sibling + os.replace, atomic on POSIX and Windows.

Every affected store now uses them. Adds tests covering the helpers and each
store's construct-from-corrupt-file path plus a save round-trip.

Related to the WakeStore-specific report in andrewyng#158 (that fix, andrewyng#159, covers only
selfwake.py); this addresses the same failure mode across the rest of the
persistence layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add GuardMiddleware layer in the engine's authorization flow to enforce
safety policies for tool calls, limit concurrent subagents, and log all
decisions for audit.

- GuardRuleSet: load YAML rules, evaluate tool calls with command match
  patterns (contains/prefix/regex), argument matching, and fan-out limits
- GuardLogger: dedicated audit log with timestamped ALLOW/DENY entries
- GuardConfigLoader: load/cache YAML config with change detection for
  hot reload
- GuardMiddleware: layered authorization combining PermissionEngine,
  GuardRuleSet, and GuardLogger; fail-safe on exceptions
- Engine integration: inject guard_middleware parameter into TurnEngine,
  route tool authorization through middleware, track start/end for
  fan-out counting
- Agent wiring: build GuardMiddleware for code agents with config path
  configurable via COWORKER_GUARD_CONFIG env var
- Default config/guard.yaml with safety rules (block recursive deletes,
  limit explorer fanout to 3 concurrent)
- 31 unit tests for all guard components (all passing)
… tests

- track_start() now returns bool: checks the fanout limit BEFORE incrementing
  the counter, returns False if max_concurrent would be exceeded
- GuardMiddleware.track_start() returns bool, logs denied decisions for audit
- Engine concurrent/serial paths check track_start return value and deny
  tools that exceed the fanout limit (TOOL_FINISHED with status="denied")
- Added is_fanout_block field to GuardDecision for robust fanout detection
- New integration test: 3 concurrent tools, max_concurrent=2, threading.Barrier
  proves first two run concurrently while third is denied by fanout limit
- New integration test: broken ruleset raises during evaluate(), middleware
  fails safe (needs_user=True), engine emits PERMISSION_REQUIRED
- Fixed _BrokenRuleset in test_guard.py (duplicate methods, missing return)
- Added fail-safe integration test for middleware exception path
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.

1 participant