Skip to content

feat(api): add outbound require_review to the protection resource - #1061

Open
tunglambk wants to merge 2 commits into
tokencanopy:mainfrom
tunglambk:feat/outbound-require-review
Open

tunglambk wants to merge 2 commits into
tokencanopy:mainfrom
tunglambk:feat/outbound-require-review

Conversation

@tunglambk

@tunglambk tunglambk commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #989.

"Hold every outbound send for review" had no direct spelling. The outbound gate is a policy plus an action on non-match, and open matches every recipient, so action: review holds nothing. The only way to get there was an allowlist policy with an empty list: nothing matches, so the non-match action fires for every send. That expresses a deliberate posture as an accident of matching, and it's hard to tell apart from an unfinished trust ramp. It also broke the clients in small ways — the CLI's --outbound-review on had to force the content scan on so that something would hold.

This adds an optional outbound.require_review boolean to the account-scoped protection resource. When it's true, the recipient gate holds every send for review whatever the gate policy, allowlist, or configured non-match action says, so gate.policy can stay open. The gate's protection_events row now records the action that actually applied (review) rather than echoing outbound_policy_action; for every existing config the two are identical.

The old composition still works and is still covered by hitl_send_holds_202. I left it alone because deployed agents are configured with it and migration 042 forwards the retired hitl_mode=all onto it. The flag lives on the outbound direction only (ProtectionOutboundView / ProtectionOutboundRequest), so the wire schema doesn't advertise a knob the inbound direction ignores.

Client surface checklist

  • Go handler + integration tests — PUT /v1/agents/{email}/protection; store, handler, and DB-backed send-path tests
  • Migration written + idempotent + safe on prod-sized tables — 125_outbound_require_review.sql, one additive BOOLEAN NOT NULL DEFAULT false column, no backfill
  • OpenAPI spec + generated types refreshed (make generate is clean)
  • TypeScript SDK — generated base regenerated; no ergonomic-layer change, the protection resource already existed
  • Python SDK — generated base regenerated; no ergonomic-layer change for the same reason
  • CLI command or flag — --outbound-review on|off writes require_review; the pre-existing scan bump on on is left as it was
  • MCP tool in mcp/src/tools/ — update_protection gains outbound_require_review; no new tool, so the registry assertion is unchanged
  • Tests at each surface above (positive + at least one negative-path / regression case) — Go unit/handler/store, MCP read-modify-write, CLI, dashboard editor, shared contract scenario

The web dashboard isn't in the template but is part of the posture: ProtectionEditor gets the "Always require human review" checkbox, and it now round-trips the field. Without that, editing any other protection setting would have silently cleared require_review, because the editor builds the outbound body from scratch on save.

Operational risk

Additive and off by default, so no existing agent's posture changes. No change to the send path for agents that don't set the flag. The flag overrides the gate, not the scan: a message that crosses the scan block threshold is still blocked, not held.

Test plan

  • Reproduced the gap first. With the field plumbed through but screenOutbound not honoring it, TestDeliverOutbound_RequireReviewHoldsEverySend fails with Held:false ... Status:accepted — the send went straight out, which is what the reporter saw. It passes after the gate change.
  • go test ./internal/identity/ ./internal/agent/ ./internal/httpapi/ -count=1 -p 1 — all pass
  • make spec-check, make openapi-compat-check (no breaking changes vs origin/main), make generate-sdk-check
  • go test -tags integration ./tests/contract/ -run TestScenarios — includes the new outbound_require_review_holds_202
  • TypeScript and Python contract suites against a freshly launched cmd/e2a-contract-server — both pass. The TS and Python runners skip scenarios whose setup needs a store-verified domain, so outbound_require_review_holds_202 is skipped there exactly like the pre-existing hitl_send_holds_202; the Go runner executes it, and the handler/store tests cover it directly.
  • npm test in @e2a/sdk, @e2a/cli, @e2a/mcp-server; cd web && npx tsc --noEmit && npm run lint && npm run test:coverage (1096 tests)
  • Python SDK pytest tests/ (605 passed) and mypy

One local note: make fmt-check reports drift in internal/agent/selfsend.go, internal/hitlworker/worker.go, and internal/senderidentity/worker.go. Those are untouched by this branch and are a gofmt difference between the local Go 1.27 and the CI's Go 1.26; the touched files are clean.

"Hold every outbound send for review" was only expressible by composing an
allowlist gate with an empty list and action=review: nothing matches, so the
non-match action fires for every send. That overloads "nothing matched" to
mean "we decided to hold", and an empty allowlist looks like an unfinished
trust ramp instead of a deliberate posture (issue tokencanopy#989).

Add an explicit outbound.require_review boolean to the protection resource.
When set, the recipient gate holds every send for review whatever the gate
policy, allowlist, or non-match action says. The empty-allowlist composition
keeps working unchanged.

The switch lands across the OpenAPI spec and both generated SDK bases, the
MCP update_protection tool, the CLI --outbound-review toggle, the dashboard
ProtectionEditor, and a shared contract scenario. The agent setup guidance
now points at the flag rather than the empty-allowlist trick.
@tunglambk
tunglambk requested a review from jiashuoz as a code owner September 29, 2026 01:35
The setup guidance in plugins/e2a changed to prefer outbound.require_review
over the empty-allowlist composition, so the plugin needs a release bump per
the version gate. Regenerated manifests from plugin.meta.json and moved the
release-version assertions in the packaging and email-evals contract tests.
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.

feat(api): add require_review flag to outbound protection

1 participant