Skip to content

feat: add require_approval action with async approval workflow#2

Closed
L1AD wants to merge 11 commits intomainfrom
feat/require-approval
Closed

feat: add require_approval action with async approval workflow#2
L1AD wants to merge 11 commits intomainfrom
feat/require-approval

Conversation

@L1AD
Copy link
Copy Markdown
Contributor

@L1AD L1AD commented Mar 27, 2026

Summary

Adds require_approval as a third action type for Intercept policy rules. When a tool call matches a require_approval rule, the proxy returns a structured error. A human approves via CLI or local HTTP API, then the agent retries and the call goes through exactly once.

Design: async model (no hold-open), fingerprint-based dedup, one-time consumption with rollback on upstream failure.

What's included

  • Config: action: require_approval, approval_timeout, top-level approvals: block with webhook config
  • Engine: DecisionRequireApproval type with RuleHash for fingerprinting
  • Approval store: internal/approvals/ -- Store interface, SQLite backend, canonical JSON fingerprinting, dedup, one-time atomic consumption, rollback
  • Proxy integration: async approval flow in handleApproval, JSON-RPC -32003 error response with human_instructions field, bounded webhook goroutines
  • CLI: intercept approvals list/show/approve/deny/expire
  • Admin API: 4 HTTP endpoints (pending, pending/:id, approve/:id, deny/:id), opt-in via --enable-admin-api, loopback-only
  • Webhook: HMAC-SHA256 signed POST on approval_requested, fire-and-forget
  • Events: 6 approval event types in JSONL audit trail
  • Docs: POLICY.md and USAGE.md updated

What's NOT included

  • No hosted approval queue or web UI
  • No team auth, SSO, roles
  • No Slack/Teams integration (webhook is the primitive)
  • No multi-step approval chains
  • No hold-open synchronous approval (async only)

Key design decisions

  1. Async over hold-open -- MCP clients have short timeouts; holding requests open creates retries, duplicates, and undefined behaviour
  2. Fingerprint = tool + canonical JSON args + rule hash + policy revision -- policy hot-reload invalidates stale approvals automatically
  3. One-time consumption with rollback -- matches existing two-phase reserve/commit pattern for rate limits
  4. Admin API opt-in -- --enable-admin-api flag, loopback-only; even localhost is an attack surface
  5. Approval instructions in error message -- Claude Code doesn't surface structured error data, so the CLI command is in the message string itself

Testing

  • 13 packages pass (go test ./...)
  • Manually tested end-to-end: trigger approval, approve via CLI, retry, verify one-time consumption, verify different args require new approval

Test plan

  • make test passes
  • make build succeeds
  • Review internal/approvals/sqlite.go for correctness (atomic consumption, rollback)
  • Review internal/proxy/handler.go handleApproval flow
  • Review fingerprinting logic in internal/approvals/fingerprint.go (canonical JSON key ordering)
  • Check POLICY.md and USAGE.md for accuracy

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
intercept Error Error Mar 30, 2026 11:48am

Request Review

L1AD added 9 commits March 27, 2026 11:16
…l action

- Add ApprovalsConfig struct and Approvals field to Config
- Add ApprovalTimeout field to Rule
- Accept require_approval as third action type in validation
- Reject rate_limit + require_approval and state + require_approval
- Validate approval_timeout and top-level approvals durations
- Prevent rate_limit desugaring when action is require_approval
- Add test fixtures and comprehensive tests
…roval

- Add DecisionType enum (allow, deny, require_approval)
- Add Type and RuleHash fields to Decision
- Handle require_approval in Evaluate with condition support
- Preserve deny precedence over require_approval
- Add ruleHash function (SHA-256 fingerprint)
- Set Type on all existing return paths for backward compat
- Comprehensive engine tests for new decision type
…rprinting

- Store interface with full lifecycle: create, find, approve, deny, consume, rollback, expire
- SQLite backend with WAL mode, atomic consumption via UPDATE WHERE
- Deterministic fingerprinting: canonical JSON with sorted keys + SHA-256
- 21 tests covering lifecycle, dedup, expiry, one-time consumption, rollback
…nd events

- Handler creates pending approvals, deduplicates retries, consumes approved records
- Rolls back both approval consumption and state counters on upstream failure
- JSON-RPC error response (-32003) with approval_id, tool, expiry, retryable, human_instructions
- Events: approval_requested, approval_reused, approval_consumed
- tool_call events emit result="approval_required" for approval decisions
- Nil approval store falls back to deny (backward compatible)
- CLI wires SQLite approval store in cmd/root.go
- 10 new proxy handler tests for full approval lifecycle
- list, show, approve, deny, expire subcommands under 'intercept approvals'
- list shows pending approvals in PRD format
- approve/deny accept --actor/--reason flags
- expire --all-stale cleans up expired records
- 9 tests covering all subcommands
- GET /api/pending, GET /api/pending/{id}, POST /api/approve/{id}, POST /api/deny/{id}
- approve/deny are idempotent, expired records return 409
- --enable-admin-api flag starts server, defaults to 127.0.0.1:9111
- Admin API Flags group in help output
- 10 tests covering all endpoints, idempotency, expiry, loopback binding
- NotifyConfig/WebhookConfig structs in ApprovalsConfig
- Validation: reject incomplete webhook config, require http(s) URL
- Notifier with HMAC-SHA256 signing over timestamp.body
- 5s default timeout, non-2xx returns error
- 5 webhook tests, 5 config webhook tests
- WithWebhookNotifier option for Handler
- sendWebhook fires async goroutine on approval_requested
- safePreview extracts top-5 keys, truncates strings, redacts nested
- Webhook wired in cmd/root.go from config
- 5 handler tests: fires on approval, no-panic without webhook,
  failure doesn't affect response, preview safe subset, long strings
- Cache computePolicyRevision (no per-call JSON marshal)
- Unify scanRecord/scanRecordRows via scannable interface
- Deduplicate handleApprove/handleDeny into handleDecision
- Cache HashArgs (compute once, pass through)
- Bound webhook goroutines with semaphore (cap 10)
- Use interceptSubdir helper in CLI
- Include approval CLI command in error message
- Add approval rules to POLICY.md and USAGE.md
- MarkApproved/MarkDenied now include WHERE status='pending', preventing
  re-arming of consumed/denied/expired records from CLI or admin API
- FindByFingerprint accepts dedupeWindow param, adds created_at bound
  so dedupe_window config is actually respected
- Fix POLICY.md docs: webhook config uses notify.webhook.url not webhook_url
- FindByFingerprint: dedupe_window restricts pending record reuse only;
  approved records remain valid until their full expires_at lifetime
- USAGE.md: fix approval ID prefix (appr_ -> apr_)
@L1AD
Copy link
Copy Markdown
Contributor Author

L1AD commented Mar 30, 2026

Closing to address review findings. Will reopen with fixes.

@L1AD L1AD closed this Mar 30, 2026
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