From e91ac326240c33fdf89f64be7dce0f950368bee0 Mon Sep 17 00:00:00 2001 From: Tung Lam <53996158+tunglambk@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:34:02 +0000 Subject: [PATCH 1/2] feat(api): add outbound require_review protection switch "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 #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. --- api/openapi.yaml | 39 +++++- cli/src/__tests__/protection.test.ts | 5 +- cli/src/commands/protection.ts | 17 ++- docs/api.md | 4 +- internal/agent/require_review_test.go | 75 ++++++++++++ internal/agent/screening.go | 36 +++++- internal/agent/screening_test.go | 68 +++++++++++ internal/httpapi/protection.go | 40 ++++++- internal/httpapi/protection_test.go | 47 ++++++++ internal/identity/protection.go | 23 ++-- internal/identity/protection_test.go | 47 ++++++++ internal/identity/store.go | 29 +++-- mcp/src/tools/agents.ts | 9 +- mcp/tests/tools.test.ts | 16 +++ migrations/132_outbound_require_review.sql | 18 +++ plugins/e2a/docs/setup.md | 15 ++- plugins/e2a/skills/e2a/SKILL.md | 15 ++- scripts/plugin-agent-guidance.test.mjs | 4 +- sdks/python/src/e2a/v1/generated/__init__.py | 4 + .../src/e2a/v1/generated/models/__init__.py | 2 + .../e2a/v1/generated/models/agent_identity.py | 4 +- .../models/protection_config_request.py | 5 +- .../models/protection_config_view.py | 5 +- .../models/protection_outbound_request.py | 112 ++++++++++++++++++ .../models/protection_outbound_view.py | 112 ++++++++++++++++++ .../src/v1/generated/.openapi-generator/FILES | 2 + .../src/v1/generated/models/AgentIdentity.ts | 7 ++ .../v1/generated/models/ObjectSerializer.ts | 6 + .../models/ProtectionConfigRequest.ts | 5 +- .../generated/models/ProtectionConfigView.ts | 5 +- .../models/ProtectionOutboundRequest.ts | 55 +++++++++ .../models/ProtectionOutboundView.ts | 55 +++++++++ .../typescript/src/v1/generated/models/all.ts | 2 + .../src/v1/generated/types/ObjectParamAPI.ts | 2 + .../src/v1/generated/types/ObservableAPI.ts | 2 + .../src/v1/generated/types/PromiseAPI.ts | 2 + tests/contract/scenarios.yaml | 47 ++++++++ web/public/llms-full.txt | 15 ++- web/public/setup.md | 15 ++- .../_components/ProtectionEditor.test.tsx | 35 ++++++ .../inboxes/_components/ProtectionEditor.tsx | 42 +++++-- web/src/app/components/onboarding/types.ts | 11 +- 42 files changed, 968 insertions(+), 91 deletions(-) create mode 100644 internal/agent/require_review_test.go create mode 100644 migrations/132_outbound_require_review.sql create mode 100644 sdks/python/src/e2a/v1/generated/models/protection_outbound_request.py create mode 100644 sdks/python/src/e2a/v1/generated/models/protection_outbound_view.py create mode 100644 sdks/typescript/src/v1/generated/models/ProtectionOutboundRequest.ts create mode 100644 sdks/typescript/src/v1/generated/models/ProtectionOutboundView.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index d09ce65ce..73d21806f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -304,6 +304,8 @@ components: type: string outbound_policy_action: type: string + outbound_require_review: + type: boolean outbound_scan: type: string outbound_scan_block_threshold: @@ -350,6 +352,7 @@ components: - inbound_policy_action - outbound_policy - outbound_policy_action + - outbound_require_review - inbound_scan - inbound_scan_review_threshold - inbound_scan_block_threshold @@ -3828,7 +3831,7 @@ components: inbound: $ref: "#/components/schemas/ProtectionDirectionRequest" outbound: - $ref: "#/components/schemas/ProtectionDirectionRequest" + $ref: "#/components/schemas/ProtectionOutboundRequest" required: - inbound - outbound @@ -3843,7 +3846,7 @@ components: inbound: $ref: "#/components/schemas/ProtectionDirectionView" outbound: - $ref: "#/components/schemas/ProtectionDirectionView" + $ref: "#/components/schemas/ProtectionOutboundView" required: - inbound - outbound @@ -4029,6 +4032,38 @@ components: type: integer type: object x-stability-level: beta + ProtectionOutboundRequest: + additionalProperties: false + properties: + gate: + $ref: "#/components/schemas/ProtectionGateRequest" + require_review: + default: false + description: When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action. + type: boolean + scan: + $ref: "#/components/schemas/ProtectionScanRequest" + required: + - gate + - scan + type: object + x-stability-level: beta + ProtectionOutboundView: + additionalProperties: true + properties: + gate: + $ref: "#/components/schemas/ProtectionGateView" + require_review: + default: false + description: When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action. A content scan can still block a message that crosses the scan block threshold. + type: boolean + scan: + $ref: "#/components/schemas/ProtectionScanView" + required: + - gate + - scan + type: object + x-stability-level: beta ProtectionScanRequest: additionalProperties: false properties: diff --git a/cli/src/__tests__/protection.test.ts b/cli/src/__tests__/protection.test.ts index 02004144b..a288fea67 100644 --- a/cli/src/__tests__/protection.test.ts +++ b/cli/src/__tests__/protection.test.ts @@ -52,6 +52,7 @@ describe("protection commands", () => { const put = mockReplaceProtection.mock.calls[0][1]; expect(put.outbound.gate.action).toBe("flag"); expect(put.outbound.scan.sensitivity).toBe("off"); + expect(put.outbound.requireReview).toBe(false); // Untouched knobs survive: gate policy/allowlist, inbound, holds. expect(put.outbound.gate.policy).toBe("allowlist"); expect(put.outbound.gate.allowlist).toEqual(["trusted@x.com"]); @@ -100,6 +101,8 @@ describe("protection commands", () => { const put = mockReplaceProtection.mock.calls[0][1]; expect(put.outbound.gate.action).toBe("review"); expect(put.outbound.scan.sensitivity).toBe("medium"); + // #989: the switch that actually holds every send, independent of the gate. + expect(put.outbound.requireReview).toBe(true); }); it("NEVER writes when the read fails — a transient GET error must not reset the doc", async () => { @@ -129,7 +132,7 @@ describe("protection commands", () => { await protectionGet("bot@agents.e2a.dev", {}); const output = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join(""); - expect(output).toContain("outbound: gate=allowlist/review scan=medium"); + expect(output).toContain("outbound: gate=allowlist/review scan=medium require_review=off"); expect(output).toContain("inbound: gate=open/review scan=high"); expect(output).toContain("holds: ttl=3600s on_expiry=approve"); expect(output).toContain("notifications=enabled"); diff --git a/cli/src/commands/protection.ts b/cli/src/commands/protection.ts index 8e22c42d8..6c7a494c7 100644 --- a/cli/src/commands/protection.ts +++ b/cli/src/commands/protection.ts @@ -2,6 +2,7 @@ import type { ProtectionConfigView, ProtectionConfigRequest, ProtectionDirectionView, + ProtectionOutboundView, } from "@e2a/sdk/v1"; import { createClient } from "../sdk.js"; import { EXIT, fail } from "../exit.js"; @@ -25,7 +26,7 @@ function summarize(config: ProtectionConfigView): string { const dir = (d: ProtectionDirectionView) => `gate=${d.gate.policy ?? "open"}/${d.gate.action ?? "flag"} scan=${d.scan.sensitivity ?? "off"}`; return ( - `outbound: ${dir(config.outbound)}\n` + + `outbound: ${dir(config.outbound)} require_review=${config.outbound.requireReview ? "on" : "off"}\n` + `inbound: ${dir(config.inbound)}\n` + `holds: ttl=${config.holds.ttlSeconds ?? 604800}s on_expiry=${config.holds.onExpiry ?? "reject"} notifications=${config.holds.suppressNotifications ? "suppressed" : "enabled"}\n` ); @@ -63,6 +64,18 @@ function applyReview(direction: ProtectionDirectionView, mode: "on" | "off"): vo } } +/** + * Outbound review also flips require_review (#989). Without it, "hold for + * review" only fires on recipients that fail the gate, so under the default + * "open" policy the action never runs and the switch would look on while + * holding nothing; the scan below was the old workaround for that. With + * require_review the gate holds every send outright. + */ +function applyOutboundReview(direction: ProtectionOutboundView, mode: "on" | "off"): void { + applyReview(direction, mode); + direction.requireReview = mode === "on"; +} + export async function protectionSet( email: string | undefined, opts: ProtectionSetOptions, @@ -86,7 +99,7 @@ export async function protectionSet( // flow). A thrown GET propagates and the PUT below is never reached. const config = await client.agents.getProtection(email); - if (opts.outboundReview) applyReview(config.outbound, opts.outboundReview as "on" | "off"); + if (opts.outboundReview) applyOutboundReview(config.outbound, opts.outboundReview as "on" | "off"); if (opts.inboundReview) applyReview(config.inbound, opts.inboundReview as "on" | "off"); if (opts.suppressNotifications !== undefined) { config.holds.suppressNotifications = opts.suppressNotifications === "on"; diff --git a/docs/api.md b/docs/api.md index a86ff470d..b79953480 100644 --- a/docs/api.md +++ b/docs/api.md @@ -746,7 +746,9 @@ or on the deployment's shared domain (see `GET /v1/info`). - `GET/PUT /v1/agents/{email}/protection` — **(beta)** read / wholesale-replace the agent's protection posture: inbound/outbound trust gate, content-scan sensitivity, and the hold-queue mechanism (TTL + expiration action). Setting the - outbound gate to `review` (or enabling the scan) is what turns on HITL holds. + outbound gate to `review`, enabling the scan, or setting `outbound.require_review` + is what turns on HITL holds; `require_review` holds every outbound send for + review regardless of the gate policy, allowlist, or non-match action. Account scope only. Beta — shape may change before it is declared stable. - `POST /v1/agents/{email}/test` — send a platform test email to the agent's own address to confirm inbound delivery. diff --git a/internal/agent/require_review_test.go b/internal/agent/require_review_test.go new file mode 100644 index 000000000..c6d2ddf64 --- /dev/null +++ b/internal/agent/require_review_test.go @@ -0,0 +1,75 @@ +package agent_test + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/outbound" +) + +// TestDeliverOutbound_RequireReviewHoldsEverySend is the #989 regression: with +// require_review set on a permissive open gate — every recipient matches, so no +// non-match ever fires — the send is still held for review. Before the fix the +// only config that held everything was an allowlist with an empty list, which +// reached the same state by making "nothing matched" mean "we decided to hold". +func TestDeliverOutbound_RequireReviewHoldsEverySend(t *testing.T) { + api, store, _, _ := setupAsyncAPI(t) + ctx := context.Background() + user, ag := selfAgent(t, store, "requirereview") + + if _, err := store.UpdateAgentProtection(ctx, ag.ID, user.ID, identity.ProtectionConfig{ + InboundGatePolicy: "open", + InboundGateAction: "flag", + InboundScanSensitivity: identity.SensitivityOff, + OutboundGatePolicy: "open", // permissive: every recipient matches + OutboundGateAction: "flag", // a real non-match would only annotate + OutboundRequireReview: true, + OutboundScanSensitivity: identity.SensitivityOff, + HITLTTLSeconds: 3600, + HITLExpirationAction: "approve", + }); err != nil { + t.Fatalf("UpdateAgentProtection: %v", err) + } + ag, err := store.GetAgentByID(ctx, ag.ID) + if err != nil { + t.Fatalf("GetAgentByID: %v", err) + } + + res, oerr := api.DeliverOutbound(ctx, user, ag, outbound.SendRequest{ + To: []string{"alice@external.test"}, Subject: "hold every send", Body: "b", + }, "send", "", nil, nil) + if oerr != nil { + t.Fatalf("DeliverOutbound: %+v", oerr) + } + if res == nil || !res.Held { + t.Fatalf("result = %+v, want a held result", res) + } + + var status, reason string + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, `SELECT status, COALESCE(review_reason, '') FROM messages WHERE id=$1`, res.PendingMessageID).Scan(&status, &reason) + }); err != nil { + t.Fatalf("read held row: %v", err) + } + if status != identity.MessageStatusPendingReview { + t.Errorf("held row status = %q, want %q", status, identity.MessageStatusPendingReview) + } + if reason != identity.ReviewReasonRecipientGate { + t.Errorf("held row review_reason = %q, want %q", reason, identity.ReviewReasonRecipientGate) + } + + // The gate audit row records the action that actually applied (review), not + // the configured non-match action (flag). + var action string + if err := store.WithTx(ctx, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, `SELECT action FROM protection_events WHERE message_id=$1 AND source='gate'`, res.PendingMessageID).Scan(&action) + }); err != nil { + t.Fatalf("read gate audit row: %v", err) + } + if action != "review" { + t.Errorf("gate audit action = %q, want review", action) + } +} diff --git a/internal/agent/screening.go b/internal/agent/screening.go index 481c570b2..debccbe4f 100644 --- a/internal/agent/screening.go +++ b/internal/agent/screening.go @@ -21,6 +21,7 @@ import ( // email.blocked. Mirrors relay.inboundScreenResult on the egress side. type outboundVerdict struct { Applied piguard.Action // most-severe of gate + scan + gateAction piguard.Action // the gate's own action (for its audit row) scanAction piguard.Action // the scan's own action (for its audit row) ReviewReason string // recipient_gate | outbound_scan (drives denorm + event) ScanScore *float64 @@ -77,6 +78,15 @@ func allRecipients(req outbound.SendRequest) []string { return out } +// firstSendRecipient anchors the audit row for a require_review hold, where no +// recipient tripped the gate but the send still needs a subject address. +func firstSendRecipient(req outbound.SendRequest) string { + if recips := allRecipients(req); len(recips) > 0 { + return recips[0] + } + return "" +} + func domainOf(addr string) string { if i := strings.LastIndex(addr, "@"); i >= 0 { return strings.TrimSpace(addr[i+1:]) @@ -172,11 +182,23 @@ func (a *API) screenOutbound(ctx context.Context, agent *identity.AgentIdentity, var v outboundVerdict gateAction := piguard.ActionAllow - if flagged, addr := recipientGate(agent, req); flagged { - gateAction = piguard.Action(agent.OutboundPolicyAction) + switch { + case agent.OutboundRequireReview: + // require_review short-circuits the recipient match (#989): every send + // is held for review whatever the policy, allowlist, or non-match action + // says, so "hold everything" no longer depends on matching nothing. The + // first recipient anchors the audit row since none actually tripped. + gateAction = piguard.ActionReview v.gateFlagged = true - v.GateAddr = addr + v.GateAddr = firstSendRecipient(req) + default: + if flagged, addr := recipientGate(agent, req); flagged { + gateAction = piguard.Action(agent.OutboundPolicyAction) + v.gateFlagged = true + v.GateAddr = addr + } } + v.gateAction = gateAction scanAction := piguard.ActionAllow if identity.ContentScanEnabled() && agent.OutboundScan == identity.ScanOn && a.screen != nil { @@ -221,7 +243,11 @@ func (a *API) screenOutbound(ctx context.Context, agent *identity.AgentIdentity, v.ReviewReason = identity.ReviewReasonOutboundScan } if v.Reason == "" && v.gateFlagged { - v.Reason = "recipient not permitted by outbound policy" + if agent.OutboundRequireReview { + v.Reason = "require_review holds every outbound send" + } else { + v.Reason = "recipient not permitted by outbound policy" + } } return v } @@ -239,7 +265,7 @@ func (v outboundVerdict) screeningEvents(messageID string, agent *identity.Agent Direction: "outbound", Source: identity.ScreeningSourceGate, Reason: identity.ReviewReasonRecipientGate, - Action: agent.OutboundPolicyAction, + Action: string(v.gateAction), SubjectAddr: v.GateAddr, }) } diff --git a/internal/agent/screening_test.go b/internal/agent/screening_test.go index 136f4499e..43362626c 100644 --- a/internal/agent/screening_test.go +++ b/internal/agent/screening_test.go @@ -109,6 +109,74 @@ func TestScreenOutbound_OpenAllowsBenign(t *testing.T) { } } +// TestScreenOutbound_RequireReview: require_review holds every send for review +// regardless of the gate — an open policy with action=flag, an allowlist that +// explicitly matches the recipient, and a non-match action of block all resolve +// to review. Before #989, "hold everything" was only expressible by making the +// allowlist empty so nothing matched. +func TestScreenOutbound_RequireReview(t *testing.T) { + a := testScreenAPI() + cases := []struct { + name string + policy string + allowlist []string + action string + }{ + {"open gate never flags but is held anyway", identity.OutboundPolicyOpen, nil, "flag"}, + {"matching allowlist recipient is still held", identity.OutboundPolicyAllowlist, []string{"ok@friend.com"}, "flag"}, + {"block non-match action is overridden to review", identity.OutboundPolicyAllowlist, nil, "block"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ag := &identity.AgentIdentity{ + Domain: "bot.example.com", ID: "bot@bot.example.com", + OutboundPolicy: tc.policy, OutboundAllowlist: tc.allowlist, + OutboundPolicyAction: tc.action, OutboundRequireReview: true, + OutboundScan: identity.ScanOff, + } + v := a.screenOutbound(context.Background(), ag, outbound.SendRequest{ + To: []string{"ok@friend.com"}, Subject: "hi", Body: "benign hello", + }) + if v.Applied != piguard.ActionReview { + t.Errorf("applied = %q, want review", v.Applied) + } + if !v.gateFlagged || v.ReviewReason != identity.ReviewReasonRecipientGate { + t.Errorf("gateFlagged=%v reason=%q, want a recipient_gate hold", v.gateFlagged, v.ReviewReason) + } + if v.GateAddr != "ok@friend.com" { + t.Errorf("gate addr = %q, want the first recipient", v.GateAddr) + } + }) + } +} + +// TestScreenOutbound_RequireReviewOffKeepsGateSemantics pins the boundary the +// switch implicates: with require_review unset, an open gate with action=review +// still holds nothing (the #989 gap, now expressible the other way), and the +// empty-allowlist composition still holds every send. +func TestScreenOutbound_RequireReviewOffKeepsGateSemantics(t *testing.T) { + a := testScreenAPI() + req := outbound.SendRequest{To: []string{"anyone@anywhere.com"}, Subject: "hi", Body: "benign hello"} + + open := &identity.AgentIdentity{ + Domain: "bot.example.com", ID: "bot@bot.example.com", + OutboundPolicy: identity.OutboundPolicyOpen, OutboundPolicyAction: "review", + OutboundScan: identity.ScanOff, + } + if v := a.screenOutbound(context.Background(), open, req); v.Applied != piguard.ActionAllow { + t.Errorf("open policy + review + require_review off: applied = %q, want allow", v.Applied) + } + + emptyAllowlist := &identity.AgentIdentity{ + Domain: "bot.example.com", ID: "bot@bot.example.com", + OutboundPolicy: identity.OutboundPolicyAllowlist, OutboundAllowlist: []string{}, + OutboundPolicyAction: "review", OutboundScan: identity.ScanOff, + } + if v := a.screenOutbound(context.Background(), emptyAllowlist, req); v.Applied != piguard.ActionReview { + t.Errorf("empty allowlist + review must keep holding, applied = %q", v.Applied) + } +} + // TestScreenOutbound_Scan: outbound_scan=on flags an injection payload (Unicode // Tags smuggling) and combines via MoreSevere with the gate. func TestScreenOutbound_Scan(t *testing.T) { diff --git a/internal/httpapi/protection.go b/internal/httpapi/protection.go index 4277df40c..69d8c6dd4 100644 --- a/internal/httpapi/protection.go +++ b/internal/httpapi/protection.go @@ -64,6 +64,20 @@ type ProtectionDirectionView struct { Scan ProtectionScanView `json:"scan"` } +// ProtectionOutboundView is the outbound direction: the same gate/scan pair as +// inbound plus the outbound-only require_review switch (#989). The flag lives on +// its own type rather than on ProtectionDirectionView so the wire schema doesn't +// advertise a knob only one direction honors. +type ProtectionOutboundView struct { + ProtectionDirectionView + // RequireReview holds every outbound send for review. It short-circuits the + // recipient match, so policy, allowlist, and the non-match action are all + // ignored. Before #989 the only way to say this was an allowlist policy with + // an empty list and action=review: nothing matched, so the non-match action + // fired for every send. That composition still works; this says it outright. + RequireReview bool `json:"require_review,omitempty" default:"false" doc:"When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action. A content scan can still block a message that crosses the scan block threshold."` +} + // ProtectionHoldsView is the shared review-queue mechanism for held items. type ProtectionHoldsView struct { TTLSeconds int `json:"ttl_seconds,omitempty" minimum:"0" default:"604800" doc:"How long a held item waits before its on_expiry action fires."` @@ -80,7 +94,7 @@ type ProtectionHoldsView struct { // distinct types despite the identical shape. type ProtectionConfigView struct { Inbound ProtectionDirectionView `json:"inbound"` - Outbound ProtectionDirectionView `json:"outbound"` + Outbound ProtectionOutboundView `json:"outbound"` Holds ProtectionHoldsView `json:"holds"` } @@ -90,16 +104,20 @@ func protectionViewFromIdentity(ag *identity.AgentIdentity) ProtectionConfigView Gate: ProtectionGateView{Policy: ag.InboundPolicy, Allowlist: orEmpty(ag.InboundAllowlist), Action: ag.InboundPolicyAction}, Scan: ProtectionScanView{Sensitivity: ag.InboundScanSensitivity}, }, - Outbound: ProtectionDirectionView{ - Gate: ProtectionGateView{Policy: ag.OutboundPolicy, Allowlist: orEmpty(ag.OutboundAllowlist), Action: ag.OutboundPolicyAction}, - Scan: ProtectionScanView{Sensitivity: ag.OutboundScanSensitivity}, + Outbound: ProtectionOutboundView{ + ProtectionDirectionView: ProtectionDirectionView{ + Gate: ProtectionGateView{Policy: ag.OutboundPolicy, Allowlist: orEmpty(ag.OutboundAllowlist), Action: ag.OutboundPolicyAction}, + Scan: ProtectionScanView{Sensitivity: ag.OutboundScanSensitivity}, + }, + RequireReview: ag.OutboundRequireReview, }, Holds: ProtectionHoldsView{TTLSeconds: ag.HITLTTLSeconds, OnExpiry: ag.HITLExpirationAction, SuppressNotifications: ag.SuppressNotifications}, } } // ProtectionGateRequest / ProtectionScanRequest / ProtectionDirectionRequest / -// ProtectionHoldsRequest / ProtectionConfigRequest mirror the *View shapes +// ProtectionOutboundRequest / ProtectionHoldsRequest / ProtectionConfigRequest +// mirror the *View shapes // field-for-field as the PUT body. They are dedicated INPUT types (not the // Views) because the spec's forward-compat stance is asymmetric: request // schemas stay `additionalProperties: false` (strict validation — an unknown @@ -129,6 +147,15 @@ type ProtectionDirectionRequest struct { Scan ProtectionScanRequest `json:"scan"` } +// ProtectionOutboundRequest mirrors ProtectionOutboundView for the PUT body: +// the shared direction shape plus require_review. +type ProtectionOutboundRequest struct { + ProtectionDirectionRequest + // RequireReview carries no enum to validate; the request schema's + // additionalProperties:false strictness still rejects a misspelled key. + RequireReview bool `json:"require_review,omitempty" default:"false" doc:"When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action."` +} + // ProtectionHoldsRequest mirrors ProtectionHoldsView for the PUT body. type ProtectionHoldsRequest struct { TTLSeconds int `json:"ttl_seconds,omitempty" minimum:"0" default:"604800" doc:"How long a held item waits before its on_expiry action fires."` @@ -141,7 +168,7 @@ type ProtectionHoldsRequest struct { // from defaults. type ProtectionConfigRequest struct { Inbound ProtectionDirectionRequest `json:"inbound"` - Outbound ProtectionDirectionRequest `json:"outbound"` + Outbound ProtectionOutboundRequest `json:"outbound"` Holds ProtectionHoldsRequest `json:"holds"` } @@ -154,6 +181,7 @@ func protectionConfigFromRequest(v ProtectionConfigRequest) identity.ProtectionC OutboundGatePolicy: v.Outbound.Gate.Policy, OutboundAllowlist: v.Outbound.Gate.Allowlist, OutboundGateAction: v.Outbound.Gate.Action, + OutboundRequireReview: v.Outbound.RequireReview, OutboundScanSensitivity: v.Outbound.Scan.Sensitivity, HITLTTLSeconds: v.Holds.TTLSeconds, HITLExpirationAction: v.Holds.OnExpiry, diff --git a/internal/httpapi/protection_test.go b/internal/httpapi/protection_test.go index 2b95d49d4..aba4bd62f 100644 --- a/internal/httpapi/protection_test.go +++ b/internal/httpapi/protection_test.go @@ -45,6 +45,7 @@ func protectionServer(t *testing.T) (*httptest.Server, *identity.AgentIdentity) ag.InboundScanSensitivity = cfg.InboundScanSensitivity ag.OutboundPolicy = cfg.OutboundGatePolicy ag.OutboundPolicyAction = cfg.OutboundGateAction + ag.OutboundRequireReview = cfg.OutboundRequireReview ag.OutboundScanSensitivity = cfg.OutboundScanSensitivity ag.HITLTTLSeconds = cfg.HITLTTLSeconds ag.HITLExpirationAction = cfg.HITLExpirationAction @@ -102,6 +103,52 @@ func TestProtectionPutGetRoundTrip(t *testing.T) { } } +// TestProtectionPutRequireReview: outbound.require_review round-trips through +// the PUT/GET pair while the gate stays at its permissive default, and it does +// not appear on the inbound direction (#989). +func TestProtectionPutRequireReview(t *testing.T) { + srv, ag := protectionServer(t) + put := map[string]any{ + "inbound": map[string]any{ + "gate": map[string]any{"policy": "open", "action": "flag"}, + "scan": map[string]any{"sensitivity": "off"}, + }, + "outbound": map[string]any{ + "gate": map[string]any{"policy": "open", "action": "flag"}, + "scan": map[string]any{"sensitivity": "off"}, + "require_review": true, + }, + "holds": map[string]any{"ttl_seconds": 3600, "on_expiry": "reject"}, + } + code, body := sendJSON(t, "PUT", srv.URL+"/v1/agents/support%40acme.com/protection", "good", put) + if code != 200 { + t.Fatalf("PUT status %d body %v", code, body) + } + if !ag.OutboundRequireReview { + t.Error("PUT did not carry outbound require_review into the store config") + } + outbound, _ := body["outbound"].(map[string]any) + if outbound["require_review"] != true { + t.Errorf("PUT echo outbound.require_review = %v, want true", outbound["require_review"]) + } + if _, leaked := body["inbound"].(map[string]any)["require_review"]; leaked { + t.Error("inbound direction advertises require_review, which is outbound-only") + } + + code, got := sendJSON(t, "GET", srv.URL+"/v1/agents/support%40acme.com/protection", "good", nil) + if code != 200 { + t.Fatalf("GET status %d body %v", code, got) + } + outbound, _ = got["outbound"].(map[string]any) + if outbound["require_review"] != true { + t.Errorf("GET outbound.require_review = %v, want true", outbound["require_review"]) + } + gate, _ := outbound["gate"].(map[string]any) + if gate["policy"] != "open" || gate["action"] != "flag" { + t.Errorf("require_review must leave the gate alone, got %v", gate) + } +} + // With content scan gated off (the GA default), the protection handler clamps // scan_sensitivity to "off" so a caller never persists a knob that silently // never runs — get_protection reads back the honest, effective posture. diff --git a/internal/identity/protection.go b/internal/identity/protection.go index 5184adccb..bfdcb4fa0 100644 --- a/internal/identity/protection.go +++ b/internal/identity/protection.go @@ -62,13 +62,18 @@ func validSensitivity(s string) bool { // mechanism. Concrete values (the PUT body is a full replace), so // UpdateAgentProtection validates and writes the effective posture atomically. type ProtectionConfig struct { - InboundGatePolicy string - InboundAllowlist []string - InboundGateAction string - InboundScanSensitivity string - OutboundGatePolicy string - OutboundAllowlist []string - OutboundGateAction string + InboundGatePolicy string + InboundAllowlist []string + InboundGateAction string + InboundScanSensitivity string + OutboundGatePolicy string + OutboundAllowlist []string + OutboundGateAction string + // OutboundRequireReview holds every outbound send for review regardless of + // the gate policy, allowlist, or non-match action (issue #989). It is the + // explicit form of what an empty allowlist with action=review expressed + // through the accident of matching nothing. + OutboundRequireReview bool OutboundScanSensitivity string HITLTTLSeconds int HITLExpirationAction string @@ -166,7 +171,8 @@ func (s *Store) UpdateAgentProtection(ctx context.Context, agentID, userID strin outbound_scan = $13, outbound_scan_review_threshold = $14, outbound_scan_block_threshold = $15, outbound_scan_sensitivity = $16, hitl_ttl_seconds = $17, hitl_expiration_action = $18, - suppress_notifications = $19 + suppress_notifications = $19, + outbound_require_review = $20 WHERE id = $1 AND user_id = $2`, agentID, userID, c.InboundGatePolicy, inAllow, c.InboundGateAction, @@ -177,6 +183,7 @@ func (s *Store) UpdateAgentProtection(ctx context.Context, agentID, userID strin c.OutboundScanSensitivity, c.HITLTTLSeconds, c.HITLExpirationAction, c.SuppressNotifications, + c.OutboundRequireReview, ) if err != nil { return nil, err diff --git a/internal/identity/protection_test.go b/internal/identity/protection_test.go index 872c17f48..8d75b58c1 100644 --- a/internal/identity/protection_test.go +++ b/internal/identity/protection_test.go @@ -96,6 +96,53 @@ func TestUpdateAgentProtectionRoundTrip(t *testing.T) { } } +// TestUpdateAgentProtectionRequireReviewRoundTrip: the outbound require_review +// switch (#989) defaults off for a fresh agent, persists when set, and clears +// again on an explicit false — the full replace keeps no stale hold-everything +// state behind. +func TestUpdateAgentProtectionRequireReviewRoundTrip(t *testing.T) { + store, ctx, agentID, userID := newProtectionAgent(t, "prot-rr") + + fresh, err := store.GetAgentByID(ctx, agentID) + if err != nil { + t.Fatalf("GetAgentByID: %v", err) + } + if fresh.OutboundRequireReview { + t.Error("fresh agent defaults to outbound require_review=true, want false") + } + + base := identity.ProtectionConfig{ + InboundGatePolicy: "open", InboundGateAction: "flag", InboundScanSensitivity: "off", + OutboundGatePolicy: "open", OutboundGateAction: "flag", OutboundScanSensitivity: "off", + HITLTTLSeconds: 604800, HITLExpirationAction: "reject", + } + on := base + on.OutboundRequireReview = true + if _, err := store.UpdateAgentProtection(ctx, agentID, userID, on); err != nil { + t.Fatalf("UpdateAgentProtection(on): %v", err) + } + got, err := store.GetAgentByID(ctx, agentID) + if err != nil { + t.Fatalf("GetAgentByID after on: %v", err) + } + if !got.OutboundRequireReview { + t.Error("outbound require_review did not persist as true") + } + + off := base + off.OutboundRequireReview = false + if _, err := store.UpdateAgentProtection(ctx, agentID, userID, off); err != nil { + t.Fatalf("UpdateAgentProtection(off): %v", err) + } + got, err = store.GetAgentByID(ctx, agentID) + if err != nil { + t.Fatalf("GetAgentByID after off: %v", err) + } + if got.OutboundRequireReview { + t.Error("outbound require_review did not clear on an explicit false") + } +} + // TestUpdateAgentProtectionSensitivityMapping pins each level to its derived band. func TestUpdateAgentProtectionSensitivityMapping(t *testing.T) { store, ctx, agentID, userID := newProtectionAgent(t, "prot-map") diff --git a/internal/identity/store.go b/internal/identity/store.go index 1d16a6383..0ec02016a 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -151,16 +151,21 @@ type AgentIdentity struct { // outbound_allowlist are the egress recipient gate (open|allowlist|domain); // inbound_scan/outbound_scan toggle the content scan with a review/block // threshold ladder. See docs/design/2026-06-20-agent-screening-hitl.md §4.1. - InboundPolicyAction string `json:"inbound_policy_action"` - OutboundPolicy string `json:"outbound_policy"` - OutboundAllowlist []string `json:"outbound_allowlist,omitempty"` - OutboundPolicyAction string `json:"outbound_policy_action"` - InboundScan string `json:"inbound_scan"` - InboundScanReviewThreshold float64 `json:"inbound_scan_review_threshold"` - InboundScanBlockThreshold float64 `json:"inbound_scan_block_threshold"` - OutboundScan string `json:"outbound_scan"` - OutboundScanReviewThreshold float64 `json:"outbound_scan_review_threshold"` - OutboundScanBlockThreshold float64 `json:"outbound_scan_block_threshold"` + InboundPolicyAction string `json:"inbound_policy_action"` + OutboundPolicy string `json:"outbound_policy"` + OutboundAllowlist []string `json:"outbound_allowlist,omitempty"` + OutboundPolicyAction string `json:"outbound_policy_action"` + // OutboundRequireReview (migration 125, issue #989) holds every outbound + // send for review regardless of the gate policy, allowlist, or configured + // non-match action. It replaces the empty-allowlist composition ("nothing + // matched") as the way to say "we decided to hold everything". + OutboundRequireReview bool `json:"outbound_require_review"` + InboundScan string `json:"inbound_scan"` + InboundScanReviewThreshold float64 `json:"inbound_scan_review_threshold"` + InboundScanBlockThreshold float64 `json:"inbound_scan_block_threshold"` + OutboundScan string `json:"outbound_scan"` + OutboundScanReviewThreshold float64 `json:"outbound_scan_review_threshold"` + OutboundScanBlockThreshold float64 `json:"outbound_scan_block_threshold"` // Scan sensitivity (migration 045) is the protection API's content-scan knob // (off|low|medium|high). It is the read-back source of truth; the float // thresholds above are derived from it on write and are what the piguard @@ -2223,6 +2228,7 @@ func loadAgentByID(ctx context.Context, exec agentRowQuerier, id string, include COALESCE(a.inbound_policy, 'open'), a.inbound_allowlist, a.inbound_policy_action, a.outbound_policy, a.outbound_allowlist, a.outbound_policy_action, + a.outbound_require_review, a.inbound_scan, a.inbound_scan_review_threshold, a.inbound_scan_block_threshold, a.outbound_scan, a.outbound_scan_review_threshold, a.outbound_scan_block_threshold, a.inbound_scan_sensitivity, a.outbound_scan_sensitivity, @@ -2241,6 +2247,7 @@ func loadAgentByID(ctx context.Context, exec agentRowQuerier, id string, include &a.InboundPolicy, &a.InboundAllowlist, &a.InboundPolicyAction, &a.OutboundPolicy, &a.OutboundAllowlist, &a.OutboundPolicyAction, + &a.OutboundRequireReview, &a.InboundScan, &a.InboundScanReviewThreshold, &a.InboundScanBlockThreshold, &a.OutboundScan, &a.OutboundScanReviewThreshold, &a.OutboundScanBlockThreshold, &a.InboundScanSensitivity, &a.OutboundScanSensitivity, @@ -2583,6 +2590,7 @@ func (s *Store) listAgentsByUser(ctx context.Context, userID string, limit int, COALESCE(a.inbound_policy, 'open'), a.inbound_allowlist, a.inbound_policy_action, a.outbound_policy, a.outbound_allowlist, a.outbound_policy_action, + a.outbound_require_review, a.inbound_scan, a.inbound_scan_review_threshold, a.inbound_scan_block_threshold, a.outbound_scan, a.outbound_scan_review_threshold, a.outbound_scan_block_threshold, a.inbound_scan_sensitivity, a.outbound_scan_sensitivity, @@ -2650,6 +2658,7 @@ func (s *Store) listAgentsByUser(ctx context.Context, userID string, limit int, &a.InboundPolicy, &a.InboundAllowlist, &a.InboundPolicyAction, &a.OutboundPolicy, &a.OutboundAllowlist, &a.OutboundPolicyAction, + &a.OutboundRequireReview, &a.InboundScan, &a.InboundScanReviewThreshold, &a.InboundScanBlockThreshold, &a.OutboundScan, &a.OutboundScanReviewThreshold, &a.OutboundScanBlockThreshold, &a.InboundScanSensitivity, &a.OutboundScanSensitivity, diff --git a/mcp/src/tools/agents.ts b/mcp/src/tools/agents.ts index 759d5e88e..d374daf19 100644 --- a/mcp/src/tools/agents.ts +++ b/mcp/src/tools/agents.ts @@ -133,7 +133,7 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { title: "Update an agent's protection config (beta)", annotations: { idempotentHint: true, destructiveHint: false }, description: - "Set an agent's protection posture. Read-modify-write: only the fields you pass change; the rest keep their current value. Inbound allowlist/domain gates first require DMARC pass, then match the aligned RFC 5322 From address. Outbound policy semantics: open matches every recipient; allowlist matches exact addresses in outbound_gate_allowlist; domain matches recipients on the agent's own domain. The outbound gate action applies when any recipient does not match. To require human review for every outbound message, set outbound_gate_policy=allowlist, outbound_gate_allowlist=[], outbound_gate_action=review, and holds_on_expiry=reject — this guarantees the recipient GATE routes every message to review; when scanning is enabled, messages crossing the scan block threshold are refused outright (blocked, not held). Using open with review, the gate will hold nothing (every recipient matches); content scanning, when enabled, can still hold or block a message. The scan sensitivity (off|low|medium|high) tunes content screening; holds govern the review queue. BETA. Account scope only.", + "Set an agent's protection posture. Read-modify-write: only the fields you pass change; the rest keep their current value. Inbound allowlist/domain gates first require DMARC pass, then match the aligned RFC 5322 From address. Outbound policy semantics: open matches every recipient; allowlist matches exact addresses in outbound_gate_allowlist; domain matches recipients on the agent's own domain. The outbound gate action applies when any recipient does not match. To require human review for every outbound message, set outbound_require_review=true and holds_on_expiry=reject — the flag holds every send whatever the gate says, so holds_on_expiry=reject keeps an unanswered hold from sending later; when scanning is enabled, messages crossing the scan block threshold are refused outright (blocked, not held). The older composition (outbound_gate_policy=allowlist, outbound_gate_allowlist=[], outbound_gate_action=review) still holds every message, but only because nothing matches an empty list. Using open with review, the gate will hold nothing (every recipient matches); content scanning, when enabled, can still hold or block a message. The scan sensitivity (off|low|medium|high) tunes content screening; holds govern the review queue. BETA. Account scope only.", inputSchema: strictInputSchema({ email: z .string() @@ -174,6 +174,12 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { .describe( "What an outbound gate non-match does: flag (send + annotate), review (hold as pending_review for human approval), or block. With policy=open there are no non-matches, so this action never fires.", ), + outbound_require_review: z + .boolean() + .optional() + .describe( + "When true, hold every outbound send for review regardless of outbound_gate_policy, outbound_gate_allowlist, or outbound_gate_action. This is the direct way to require human approval for every outbound message; a content scan can still block a message that crosses the scan block threshold.", + ), outbound_scan_sensitivity: z .enum(["off", "low", "medium", "high"]) .optional() @@ -216,6 +222,7 @@ export function registerAgentTools(server: McpServer, client: McpClient): void { if (args.outbound_gate_allowlist !== undefined) cfg.outbound.gate.allowlist = args.outbound_gate_allowlist; if (args.outbound_gate_action !== undefined) cfg.outbound.gate.action = args.outbound_gate_action as typeof cfg.outbound.gate.action; + if (args.outbound_require_review !== undefined) cfg.outbound.requireReview = args.outbound_require_review; if (args.outbound_scan_sensitivity !== undefined) cfg.outbound.scan.sensitivity = args.outbound_scan_sensitivity as typeof cfg.outbound.scan.sensitivity; if (args.holds_ttl_seconds !== undefined) cfg.holds.ttlSeconds = args.holds_ttl_seconds; diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index 46f180cda..2aa90543c 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -441,6 +441,7 @@ describe("e2a MCP server", () => { properties?: Record; })?.properties ?? {}; + expect(description).toContain("outbound_require_review=true"); expect(description).toContain("outbound_gate_policy=allowlist"); expect(description).toContain("outbound_gate_allowlist=[]"); expect(description).toContain("outbound_gate_action=review"); @@ -453,6 +454,7 @@ describe("e2a MCP server", () => { // refuses outright (blocked, not held). expect(description).toMatch(/blocked, not held/i); expect(properties.outbound_gate_policy?.description).toMatch(/open.*every recipient/i); + expect(properties.outbound_require_review?.description).toMatch(/every outbound send/i); expect(properties.holds_on_expiry?.description).toMatch(/reject.*explicit human approval/i); }); @@ -2071,6 +2073,20 @@ describe("e2a MCP server", () => { expect(addr).toBeUndefined(); }); + it("update_protection sets outbound require_review without touching the gate", async () => { + await client.callTool({ + name: "update_protection", + arguments: { outbound_require_review: true }, + }); + const [cfg] = stub.updateProtection.mock.calls.at(-1)!; + expect(cfg.outbound.requireReview).toBe(true); + // The switch holds every send on its own: the gate stays permissive rather + // than being arranged so nothing matches (#989). + expect(cfg.outbound.gate.policy).toBe("open"); + expect(cfg.outbound.gate.action).toBe("flag"); + expect(cfg.inbound.gate.policy).toBe("open"); + }); + it("delete_agent requires confirm:true — server-side schema rejects when omitted", async () => { // The Zod schema marks `confirm` as required-literal(true); the MCP // server's validator surfaces that as an isError content before any diff --git a/migrations/132_outbound_require_review.sql b/migrations/132_outbound_require_review.sql new file mode 100644 index 000000000..84723aa74 --- /dev/null +++ b/migrations/132_outbound_require_review.sql @@ -0,0 +1,18 @@ +-- 132_outbound_require_review.sql +-- +-- Outbound protection: a `require_review` switch (issue #989). +-- +-- "Hold every outbound send for review" was only expressible by composing +-- outbound_policy='allowlist' with an EMPTY allowlist and action='review': no +-- recipient matches, so the non-match action fires for every send. That works, +-- but it overloads "nothing matched" to mean "we decided to hold", and an empty +-- allowlist is indistinguishable from an unfinished trust ramp. +-- +-- This adds an explicit per-agent boolean so the posture reads as what it is. +-- When true, the outbound gate holds every send for review regardless of the +-- gate policy, the allowlist, and the configured non-match action. +-- +-- ADDITIVE and idempotent. Default false preserves today's behavior (including +-- the empty-allowlist composition, which is left working). + +ALTER TABLE agent_identities ADD COLUMN IF NOT EXISTS outbound_require_review BOOLEAN NOT NULL DEFAULT false; diff --git a/plugins/e2a/docs/setup.md b/plugins/e2a/docs/setup.md index cda799d24..214b0d388 100644 --- a/plugins/e2a/docs/setup.md +++ b/plugins/e2a/docs/setup.md @@ -141,18 +141,17 @@ selected inbox with: ```json { - "outbound_gate_policy": "allowlist", - "outbound_gate_allowlist": [], - "outbound_gate_action": "review", + "outbound_require_review": true, "holds_on_expiry": "reject" } ``` -An empty allowlist makes every recipient a gate non-match, `review` holds every -non-match for a human, and `reject` prevents expiry from sending an unreviewed -message. Do not use `open` with `review`: `open` matches every recipient, so the -recipient gate holds nothing. Inbox creation alone is not permission to enable -this policy. +`require_review` holds every outbound send for a human whatever the gate says, +and `reject` prevents expiry from sending an unreviewed message. Do not try to +reach this by setting `outbound_gate_policy` to `open` with `outbound_gate_action` +`review`: `open` matches every recipient, so the recipient gate holds nothing. +(An allowlist with an empty list still holds everything too, but only because +nothing matches.) Inbox creation alone is not permission to enable this policy. ## Use the inbox safely diff --git a/plugins/e2a/skills/e2a/SKILL.md b/plugins/e2a/skills/e2a/SKILL.md index 7b4d85f22..b3801ed0b 100644 --- a/plugins/e2a/skills/e2a/SKILL.md +++ b/plugins/e2a/skills/e2a/SKILL.md @@ -56,18 +56,17 @@ that inbox with: ```json { - "outbound_gate_policy": "allowlist", - "outbound_gate_allowlist": [], - "outbound_gate_action": "review", + "outbound_require_review": true, "holds_on_expiry": "reject" } ``` -The empty allowlist makes every recipient a gate non-match, `review` holds each -non-match for a human, and `reject` prevents an unreviewed message from being -sent when its hold expires. Do not use `open` with `review` for this outcome: -`open` matches every recipient, so the recipient gate holds nothing. This is -opt-in; never enable it merely because an inbox was created. +`require_review` holds every outbound send for a human whatever the gate says, +and `reject` prevents an unreviewed message from being sent when its hold +expires. Do not use `open` with `review` for this outcome: `open` matches every +recipient, so the recipient gate holds nothing. (An allowlist with an empty list +still holds everything too, but only because nothing matches.) This is opt-in; +never enable it merely because an inbox was created. ### Triage the inbox diff --git a/scripts/plugin-agent-guidance.test.mjs b/scripts/plugin-agent-guidance.test.mjs index 5813b7c52..28b0dfb8b 100644 --- a/scripts/plugin-agent-guidance.test.mjs +++ b/scripts/plugin-agent-guidance.test.mjs @@ -137,9 +137,7 @@ test("the setup guide reaches a verified first inbox", async () => { const assertAlwaysReviewGuidance = (source, file) => { assert.match(source, /update_protection/, file); - assert.match(source, /outbound_gate_policy["`:\s]+allowlist/, file); - assert.match(source, /outbound_gate_allowlist["`:\s]+\[\]/, file); - assert.match(source, /outbound_gate_action["`:\s]+review/, file); + assert.match(source, /outbound_require_review["`:\s]+true/, file); assert.match(source, /holds_on_expiry["`:\s]+reject/, file); assert.match(source, /open.*review.*hold(?:s|ing)? nothing/is, file); assert.match(source, /only when the user (?:asks|requests)/i, file); diff --git a/sdks/python/src/e2a/v1/generated/__init__.py b/sdks/python/src/e2a/v1/generated/__init__.py index be3a4fe48..1ec29662a 100644 --- a/sdks/python/src/e2a/v1/generated/__init__.py +++ b/sdks/python/src/e2a/v1/generated/__init__.py @@ -154,6 +154,8 @@ "ProtectionGateView", "ProtectionHoldsRequest", "ProtectionHoldsView", + "ProtectionOutboundRequest", + "ProtectionOutboundView", "ProtectionScanRequest", "ProtectionScanView", "RateLimitedDetails", @@ -353,6 +355,8 @@ from e2a.v1.generated.models.protection_gate_view import ProtectionGateView as ProtectionGateView from e2a.v1.generated.models.protection_holds_request import ProtectionHoldsRequest as ProtectionHoldsRequest from e2a.v1.generated.models.protection_holds_view import ProtectionHoldsView as ProtectionHoldsView +from e2a.v1.generated.models.protection_outbound_request import ProtectionOutboundRequest as ProtectionOutboundRequest +from e2a.v1.generated.models.protection_outbound_view import ProtectionOutboundView as ProtectionOutboundView from e2a.v1.generated.models.protection_scan_request import ProtectionScanRequest as ProtectionScanRequest from e2a.v1.generated.models.protection_scan_view import ProtectionScanView as ProtectionScanView from e2a.v1.generated.models.rate_limited_details import RateLimitedDetails as RateLimitedDetails diff --git a/sdks/python/src/e2a/v1/generated/models/__init__.py b/sdks/python/src/e2a/v1/generated/models/__init__.py index 991181499..c384e0f0e 100644 --- a/sdks/python/src/e2a/v1/generated/models/__init__.py +++ b/sdks/python/src/e2a/v1/generated/models/__init__.py @@ -129,6 +129,8 @@ from e2a.v1.generated.models.protection_gate_view import ProtectionGateView from e2a.v1.generated.models.protection_holds_request import ProtectionHoldsRequest from e2a.v1.generated.models.protection_holds_view import ProtectionHoldsView +from e2a.v1.generated.models.protection_outbound_request import ProtectionOutboundRequest +from e2a.v1.generated.models.protection_outbound_view import ProtectionOutboundView from e2a.v1.generated.models.protection_scan_request import ProtectionScanRequest from e2a.v1.generated.models.protection_scan_view import ProtectionScanView from e2a.v1.generated.models.rate_limited_details import RateLimitedDetails diff --git a/sdks/python/src/e2a/v1/generated/models/agent_identity.py b/sdks/python/src/e2a/v1/generated/models/agent_identity.py index 1d410347b..e4b9326b2 100644 --- a/sdks/python/src/e2a/v1/generated/models/agent_identity.py +++ b/sdks/python/src/e2a/v1/generated/models/agent_identity.py @@ -47,6 +47,7 @@ class AgentIdentity(BaseModel): outbound_allowlist: Optional[List[StrictStr]] = None outbound_policy: StrictStr outbound_policy_action: StrictStr + outbound_require_review: StrictBool outbound_scan: StrictStr outbound_scan_block_threshold: Union[StrictFloat, StrictInt] outbound_scan_review_threshold: Union[StrictFloat, StrictInt] @@ -59,7 +60,7 @@ class AgentIdentity(BaseModel): user_id: StrictStr webhook_status: Optional[StrictStr] = Field(default=None, description="Webhook posture for this agent, derived from the account's webhook subscribers that match it (a webhook with no agent filter matches every agent). Open set; tolerate unknown values. Known values: none (no webhook matches this agent), healthy (an enabled webhook matches and none serving this agent has a terminally-failed delivery in the last 24h), failing (an enabled webhook matches but at least one delivery on a matching enabled webhook terminally failed in the last 24h), disabled (webhooks match but every one is disabled, turned off manually), auto_disabled (webhooks match, every one is disabled, and at least one was auto-disabled by the chronic-failure sweep). Present on enriched surfaces (account export, dashboard agent list); absent where not computed.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["created_at", "deleted_at", "domain", "domain_verified", "email", "inbound_7d", "inbound_allowlist", "inbound_policy", "inbound_policy_action", "inbound_scan", "inbound_scan_block_threshold", "inbound_scan_review_threshold", "inbound_scan_sensitivity", "last_delivery_at", "name", "on_expiry", "outbound_7d", "outbound_allowlist", "outbound_policy", "outbound_policy_action", "outbound_scan", "outbound_scan_block_threshold", "outbound_scan_review_threshold", "outbound_scan_sensitivity", "pending_count", "public", "registered_domain", "suppress_notifications", "ttl_seconds", "user_id", "webhook_status"] + __properties: ClassVar[List[str]] = ["created_at", "deleted_at", "domain", "domain_verified", "email", "inbound_7d", "inbound_allowlist", "inbound_policy", "inbound_policy_action", "inbound_scan", "inbound_scan_block_threshold", "inbound_scan_review_threshold", "inbound_scan_sensitivity", "last_delivery_at", "name", "on_expiry", "outbound_7d", "outbound_allowlist", "outbound_policy", "outbound_policy_action", "outbound_require_review", "outbound_scan", "outbound_scan_block_threshold", "outbound_scan_review_threshold", "outbound_scan_sensitivity", "pending_count", "public", "registered_domain", "suppress_notifications", "ttl_seconds", "user_id", "webhook_status"] model_config = ConfigDict( populate_by_name=True, @@ -149,6 +150,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "outbound_allowlist": obj.get("outbound_allowlist"), "outbound_policy": obj.get("outbound_policy"), "outbound_policy_action": obj.get("outbound_policy_action"), + "outbound_require_review": obj.get("outbound_require_review"), "outbound_scan": obj.get("outbound_scan"), "outbound_scan_block_threshold": obj.get("outbound_scan_block_threshold"), "outbound_scan_review_threshold": obj.get("outbound_scan_review_threshold"), diff --git a/sdks/python/src/e2a/v1/generated/models/protection_config_request.py b/sdks/python/src/e2a/v1/generated/models/protection_config_request.py index 55ea0aeef..862e221f9 100644 --- a/sdks/python/src/e2a/v1/generated/models/protection_config_request.py +++ b/sdks/python/src/e2a/v1/generated/models/protection_config_request.py @@ -21,6 +21,7 @@ from typing import Any, ClassVar, Dict, List from e2a.v1.generated.models.protection_direction_request import ProtectionDirectionRequest from e2a.v1.generated.models.protection_holds_request import ProtectionHoldsRequest +from e2a.v1.generated.models.protection_outbound_request import ProtectionOutboundRequest from typing import Optional, Set from typing_extensions import Self @@ -30,7 +31,7 @@ class ProtectionConfigRequest(BaseModel): """ # noqa: E501 holds: ProtectionHoldsRequest inbound: ProtectionDirectionRequest - outbound: ProtectionDirectionRequest + outbound: ProtectionOutboundRequest additional_properties: Dict[str, Any] = {} __properties: ClassVar[List[str]] = ["holds", "inbound", "outbound"] @@ -103,7 +104,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "holds": ProtectionHoldsRequest.from_dict(obj["holds"]) if obj.get("holds") is not None else None, "inbound": ProtectionDirectionRequest.from_dict(obj["inbound"]) if obj.get("inbound") is not None else None, - "outbound": ProtectionDirectionRequest.from_dict(obj["outbound"]) if obj.get("outbound") is not None else None + "outbound": ProtectionOutboundRequest.from_dict(obj["outbound"]) if obj.get("outbound") is not None else None }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdks/python/src/e2a/v1/generated/models/protection_config_view.py b/sdks/python/src/e2a/v1/generated/models/protection_config_view.py index 09da31528..ea165ab4d 100644 --- a/sdks/python/src/e2a/v1/generated/models/protection_config_view.py +++ b/sdks/python/src/e2a/v1/generated/models/protection_config_view.py @@ -21,6 +21,7 @@ from typing import Any, ClassVar, Dict, List from e2a.v1.generated.models.protection_direction_view import ProtectionDirectionView from e2a.v1.generated.models.protection_holds_view import ProtectionHoldsView +from e2a.v1.generated.models.protection_outbound_view import ProtectionOutboundView from typing import Optional, Set from typing_extensions import Self @@ -30,7 +31,7 @@ class ProtectionConfigView(BaseModel): """ # noqa: E501 holds: ProtectionHoldsView inbound: ProtectionDirectionView - outbound: ProtectionDirectionView + outbound: ProtectionOutboundView additional_properties: Dict[str, Any] = {} __properties: ClassVar[List[str]] = ["holds", "inbound", "outbound"] @@ -103,7 +104,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "holds": ProtectionHoldsView.from_dict(obj["holds"]) if obj.get("holds") is not None else None, "inbound": ProtectionDirectionView.from_dict(obj["inbound"]) if obj.get("inbound") is not None else None, - "outbound": ProtectionDirectionView.from_dict(obj["outbound"]) if obj.get("outbound") is not None else None + "outbound": ProtectionOutboundView.from_dict(obj["outbound"]) if obj.get("outbound") is not None else None }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdks/python/src/e2a/v1/generated/models/protection_outbound_request.py b/sdks/python/src/e2a/v1/generated/models/protection_outbound_request.py new file mode 100644 index 000000000..d624e6c49 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/protection_outbound_request.py @@ -0,0 +1,112 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictBool +from typing import Any, ClassVar, Dict, List, Optional +from e2a.v1.generated.models.protection_gate_request import ProtectionGateRequest +from e2a.v1.generated.models.protection_scan_request import ProtectionScanRequest +from typing import Optional, Set +from typing_extensions import Self + +class ProtectionOutboundRequest(BaseModel): + """ + ProtectionOutboundRequest + """ # noqa: E501 + gate: ProtectionGateRequest + require_review: Optional[StrictBool] = Field(default=False, description="When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action.") + scan: ProtectionScanRequest + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["gate", "require_review", "scan"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ProtectionOutboundRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of gate + if self.gate: + _dict['gate'] = self.gate.to_dict() + # override the default output from pydantic by calling `to_dict()` of scan + if self.scan: + _dict['scan'] = self.scan.to_dict() + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ProtectionOutboundRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "gate": ProtectionGateRequest.from_dict(obj["gate"]) if obj.get("gate") is not None else None, + "require_review": obj.get("require_review") if obj.get("require_review") is not None else False, + "scan": ProtectionScanRequest.from_dict(obj["scan"]) if obj.get("scan") is not None else None + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/python/src/e2a/v1/generated/models/protection_outbound_view.py b/sdks/python/src/e2a/v1/generated/models/protection_outbound_view.py new file mode 100644 index 000000000..aec3bd988 --- /dev/null +++ b/sdks/python/src/e2a/v1/generated/models/protection_outbound_view.py @@ -0,0 +1,112 @@ +# coding: utf-8 + +""" + e2a API + + e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictBool +from typing import Any, ClassVar, Dict, List, Optional +from e2a.v1.generated.models.protection_gate_view import ProtectionGateView +from e2a.v1.generated.models.protection_scan_view import ProtectionScanView +from typing import Optional, Set +from typing_extensions import Self + +class ProtectionOutboundView(BaseModel): + """ + ProtectionOutboundView + """ # noqa: E501 + gate: ProtectionGateView + require_review: Optional[StrictBool] = Field(default=False, description="When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action. A content scan can still block a message that crosses the scan block threshold.") + scan: ProtectionScanView + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["gate", "require_review", "scan"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ProtectionOutboundView from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of gate + if self.gate: + _dict['gate'] = self.gate.to_dict() + # override the default output from pydantic by calling `to_dict()` of scan + if self.scan: + _dict['scan'] = self.scan.to_dict() + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ProtectionOutboundView from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "gate": ProtectionGateView.from_dict(obj["gate"]) if obj.get("gate") is not None else None, + "require_review": obj.get("require_review") if obj.get("require_review") is not None else False, + "scan": ProtectionScanView.from_dict(obj["scan"]) if obj.get("scan") is not None else None + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdks/typescript/src/v1/generated/.openapi-generator/FILES b/sdks/typescript/src/v1/generated/.openapi-generator/FILES index 6bfbe7aee..4a5e0cfb7 100644 --- a/sdks/typescript/src/v1/generated/.openapi-generator/FILES +++ b/sdks/typescript/src/v1/generated/.openapi-generator/FILES @@ -134,6 +134,8 @@ models/ProtectionGateRequest.ts models/ProtectionGateView.ts models/ProtectionHoldsRequest.ts models/ProtectionHoldsView.ts +models/ProtectionOutboundRequest.ts +models/ProtectionOutboundView.ts models/ProtectionScanRequest.ts models/ProtectionScanView.ts models/RateLimitedDetails.ts diff --git a/sdks/typescript/src/v1/generated/models/AgentIdentity.ts b/sdks/typescript/src/v1/generated/models/AgentIdentity.ts index 31c0b72b4..a87c08550 100644 --- a/sdks/typescript/src/v1/generated/models/AgentIdentity.ts +++ b/sdks/typescript/src/v1/generated/models/AgentIdentity.ts @@ -33,6 +33,7 @@ export class AgentIdentity { 'outboundAllowlist'?: Array | null; 'outboundPolicy': string; 'outboundPolicyAction': string; + 'outboundRequireReview': boolean; 'outboundScan': string; 'outboundScanBlockThreshold': number; 'outboundScanReviewThreshold': number; @@ -173,6 +174,12 @@ export class AgentIdentity { "type": "string", "format": "" }, + { + "name": "outboundRequireReview", + "baseName": "outbound_require_review", + "type": "boolean", + "format": "" + }, { "name": "outboundScan", "baseName": "outbound_scan", diff --git a/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts b/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts index 8e2cfcd88..726f4b30e 100644 --- a/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts +++ b/sdks/typescript/src/v1/generated/models/ObjectSerializer.ts @@ -114,6 +114,8 @@ export * from '../models/ProtectionGateRequest.js'; export * from '../models/ProtectionGateView.js'; export * from '../models/ProtectionHoldsRequest.js'; export * from '../models/ProtectionHoldsView.js'; +export * from '../models/ProtectionOutboundRequest.js'; +export * from '../models/ProtectionOutboundView.js'; export * from '../models/ProtectionScanRequest.js'; export * from '../models/ProtectionScanView.js'; export * from '../models/RateLimitedDetails.js'; @@ -286,6 +288,8 @@ import { ProtectionGateRequest, ProtectionGateRequestActionEnum , ProtectionGa import { ProtectionGateView } from '../models/ProtectionGateView.js'; import { ProtectionHoldsRequest, ProtectionHoldsRequestOnExpiryEnum } from '../models/ProtectionHoldsRequest.js'; import { ProtectionHoldsView } from '../models/ProtectionHoldsView.js'; +import { ProtectionOutboundRequest } from '../models/ProtectionOutboundRequest.js'; +import { ProtectionOutboundView } from '../models/ProtectionOutboundView.js'; import { ProtectionScanRequest, ProtectionScanRequestSensitivityEnum } from '../models/ProtectionScanRequest.js'; import { ProtectionScanView } from '../models/ProtectionScanView.js'; import { RateLimitedDetails } from '../models/RateLimitedDetails.js'; @@ -499,6 +503,8 @@ let typeMap: {[index: string]: any} = { "ProtectionGateView": ProtectionGateView, "ProtectionHoldsRequest": ProtectionHoldsRequest, "ProtectionHoldsView": ProtectionHoldsView, + "ProtectionOutboundRequest": ProtectionOutboundRequest, + "ProtectionOutboundView": ProtectionOutboundView, "ProtectionScanRequest": ProtectionScanRequest, "ProtectionScanView": ProtectionScanView, "RateLimitedDetails": RateLimitedDetails, diff --git a/sdks/typescript/src/v1/generated/models/ProtectionConfigRequest.ts b/sdks/typescript/src/v1/generated/models/ProtectionConfigRequest.ts index eef880def..1bd34ac47 100644 --- a/sdks/typescript/src/v1/generated/models/ProtectionConfigRequest.ts +++ b/sdks/typescript/src/v1/generated/models/ProtectionConfigRequest.ts @@ -12,12 +12,13 @@ import { ProtectionDirectionRequest } from '../models/ProtectionDirectionRequest.js'; import { ProtectionHoldsRequest } from '../models/ProtectionHoldsRequest.js'; +import { ProtectionOutboundRequest } from '../models/ProtectionOutboundRequest.js'; import { HttpFile } from '../http/http.js'; export class ProtectionConfigRequest { 'holds': ProtectionHoldsRequest; 'inbound': ProtectionDirectionRequest; - 'outbound': ProtectionDirectionRequest; + 'outbound': ProtectionOutboundRequest; static readonly discriminator: string | undefined = undefined; @@ -39,7 +40,7 @@ export class ProtectionConfigRequest { { "name": "outbound", "baseName": "outbound", - "type": "ProtectionDirectionRequest", + "type": "ProtectionOutboundRequest", "format": "" } ]; diff --git a/sdks/typescript/src/v1/generated/models/ProtectionConfigView.ts b/sdks/typescript/src/v1/generated/models/ProtectionConfigView.ts index b7ca2d0c4..39327e1fb 100644 --- a/sdks/typescript/src/v1/generated/models/ProtectionConfigView.ts +++ b/sdks/typescript/src/v1/generated/models/ProtectionConfigView.ts @@ -12,12 +12,13 @@ import { ProtectionDirectionView } from '../models/ProtectionDirectionView.js'; import { ProtectionHoldsView } from '../models/ProtectionHoldsView.js'; +import { ProtectionOutboundView } from '../models/ProtectionOutboundView.js'; import { HttpFile } from '../http/http.js'; export class ProtectionConfigView { 'holds': ProtectionHoldsView; 'inbound': ProtectionDirectionView; - 'outbound': ProtectionDirectionView; + 'outbound': ProtectionOutboundView; static readonly discriminator: string | undefined = undefined; @@ -39,7 +40,7 @@ export class ProtectionConfigView { { "name": "outbound", "baseName": "outbound", - "type": "ProtectionDirectionView", + "type": "ProtectionOutboundView", "format": "" } ]; diff --git a/sdks/typescript/src/v1/generated/models/ProtectionOutboundRequest.ts b/sdks/typescript/src/v1/generated/models/ProtectionOutboundRequest.ts new file mode 100644 index 000000000..4f906574f --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/ProtectionOutboundRequest.ts @@ -0,0 +1,55 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ProtectionGateRequest } from '../models/ProtectionGateRequest.js'; +import { ProtectionScanRequest } from '../models/ProtectionScanRequest.js'; +import { HttpFile } from '../http/http.js'; + +export class ProtectionOutboundRequest { + 'gate': ProtectionGateRequest; + /** + * When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action. + */ + 'requireReview'?: boolean; + 'scan': ProtectionScanRequest; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "gate", + "baseName": "gate", + "type": "ProtectionGateRequest", + "format": "" + }, + { + "name": "requireReview", + "baseName": "require_review", + "type": "boolean", + "format": "" + }, + { + "name": "scan", + "baseName": "scan", + "type": "ProtectionScanRequest", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ProtectionOutboundRequest.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/ProtectionOutboundView.ts b/sdks/typescript/src/v1/generated/models/ProtectionOutboundView.ts new file mode 100644 index 000000000..b98a58c1a --- /dev/null +++ b/sdks/typescript/src/v1/generated/models/ProtectionOutboundView.ts @@ -0,0 +1,55 @@ +/** + * e2a API + * e2a — authenticated email gateway for AI agents. v1 contract. ## Stability policy The v1 surface is stable and evolves **additively only**: new endpoints, new optional request fields, new response fields, and new values in open string sets (event types, statuses) may appear at any time without a version bump. Clients MUST tolerate unknown response fields and unknown values in open string sets. This is machine-readable in the schemas: response schemas declare `additionalProperties: true`; request schemas stay strict (`additionalProperties: false` — an unknown request field is rejected with 422). Operations and schemas marked `x-stability-level: beta` are exempt from this freeze and may change or be removed without a major version. A field marked `x-experimental-values` is itself stable, but the listed values (and their event payloads) are experimental. Everything not marked beta, or enumerated as experimental, is stable. Removing or changing stable surface only happens on a new major version path (/v2); deprecations are announced ahead of time via `deprecated: true` in this document and keep working within v1. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ProtectionGateView } from '../models/ProtectionGateView.js'; +import { ProtectionScanView } from '../models/ProtectionScanView.js'; +import { HttpFile } from '../http/http.js'; + +export class ProtectionOutboundView { + 'gate': ProtectionGateView; + /** + * When true, hold every outbound send for review regardless of the gate policy, allowlist, or non-match action. A content scan can still block a message that crosses the scan block threshold. + */ + 'requireReview'?: boolean; + 'scan': ProtectionScanView; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "gate", + "baseName": "gate", + "type": "ProtectionGateView", + "format": "" + }, + { + "name": "requireReview", + "baseName": "require_review", + "type": "boolean", + "format": "" + }, + { + "name": "scan", + "baseName": "scan", + "type": "ProtectionScanView", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ProtectionOutboundView.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/sdks/typescript/src/v1/generated/models/all.ts b/sdks/typescript/src/v1/generated/models/all.ts index 71678e622..1553bbc3c 100644 --- a/sdks/typescript/src/v1/generated/models/all.ts +++ b/sdks/typescript/src/v1/generated/models/all.ts @@ -114,6 +114,8 @@ export * from '../models/ProtectionGateRequest.js' export * from '../models/ProtectionGateView.js' export * from '../models/ProtectionHoldsRequest.js' export * from '../models/ProtectionHoldsView.js' +export * from '../models/ProtectionOutboundRequest.js' +export * from '../models/ProtectionOutboundView.js' export * from '../models/ProtectionScanRequest.js' export * from '../models/ProtectionScanView.js' export * from '../models/RateLimitedDetails.js' diff --git a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts index c7f29873c..3b411d3df 100644 --- a/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObjectParamAPI.ts @@ -115,6 +115,8 @@ import { ProtectionGateRequest } from '../models/ProtectionGateRequest.js'; import { ProtectionGateView } from '../models/ProtectionGateView.js'; import { ProtectionHoldsRequest } from '../models/ProtectionHoldsRequest.js'; import { ProtectionHoldsView } from '../models/ProtectionHoldsView.js'; +import { ProtectionOutboundRequest } from '../models/ProtectionOutboundRequest.js'; +import { ProtectionOutboundView } from '../models/ProtectionOutboundView.js'; import { ProtectionScanRequest } from '../models/ProtectionScanRequest.js'; import { ProtectionScanView } from '../models/ProtectionScanView.js'; import { RateLimitedDetails } from '../models/RateLimitedDetails.js'; diff --git a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts index f0705998b..91d6da9d8 100644 --- a/sdks/typescript/src/v1/generated/types/ObservableAPI.ts +++ b/sdks/typescript/src/v1/generated/types/ObservableAPI.ts @@ -116,6 +116,8 @@ import { ProtectionGateRequest } from '../models/ProtectionGateRequest.js'; import { ProtectionGateView } from '../models/ProtectionGateView.js'; import { ProtectionHoldsRequest } from '../models/ProtectionHoldsRequest.js'; import { ProtectionHoldsView } from '../models/ProtectionHoldsView.js'; +import { ProtectionOutboundRequest } from '../models/ProtectionOutboundRequest.js'; +import { ProtectionOutboundView } from '../models/ProtectionOutboundView.js'; import { ProtectionScanRequest } from '../models/ProtectionScanRequest.js'; import { ProtectionScanView } from '../models/ProtectionScanView.js'; import { RateLimitedDetails } from '../models/RateLimitedDetails.js'; diff --git a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts index b5effcfdb..bfc791188 100644 --- a/sdks/typescript/src/v1/generated/types/PromiseAPI.ts +++ b/sdks/typescript/src/v1/generated/types/PromiseAPI.ts @@ -114,6 +114,8 @@ import { ProtectionGateRequest } from '../models/ProtectionGateRequest.js'; import { ProtectionGateView } from '../models/ProtectionGateView.js'; import { ProtectionHoldsRequest } from '../models/ProtectionHoldsRequest.js'; import { ProtectionHoldsView } from '../models/ProtectionHoldsView.js'; +import { ProtectionOutboundRequest } from '../models/ProtectionOutboundRequest.js'; +import { ProtectionOutboundView } from '../models/ProtectionOutboundView.js'; import { ProtectionScanRequest } from '../models/ProtectionScanRequest.js'; import { ProtectionScanView } from '../models/ProtectionScanView.js'; import { RateLimitedDetails } from '../models/RateLimitedDetails.js'; diff --git a/tests/contract/scenarios.yaml b/tests/contract/scenarios.yaml index 7c4c80661..32df2d8a4 100644 --- a/tests/contract/scenarios.yaml +++ b/tests/contract/scenarios.yaml @@ -686,6 +686,53 @@ scenarios: body_match: status: pending_review + - name: outbound_require_review_holds_202 + description: > + require_review holds every outbound send on a permissive open gate, where + no recipient is a non-match. Before #989 this state was only reachable by + arranging an empty allowlist so nothing matched; POST /send returns + 202 + status=pending_review. + setup: + - register_domain: require-review.test.dev + - verify_domain: require-review.test.dev + - register_agent: + email: bot@require-review.test.dev + steps: + - id: enable_require_review + action: request + method: PUT + path: /v1/agents/{agent_email}/protection + body: + inbound: + gate: {} + scan: {} + outbound: + gate: + policy: open + action: flag + scan: {} + require_review: true + holds: {} + expect: + status: 200 + body_match: + outbound.require_review: true + outbound.gate.policy: open + + - id: send_held + action: request + method: POST + path: /v1/agents/{agent_email}/messages + body: + to: + - alice@example.com + subject: Hold me too + text: require_review should hold this + expect: + status: 202 + body_match: + status: pending_review + - name: hitl_reject_records_reason description: > Reject endpoint accepts a reason in the body and surfaces it. Catches the diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 485fa4f98..760ca8550 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -158,18 +158,17 @@ selected inbox with: ```json { - "outbound_gate_policy": "allowlist", - "outbound_gate_allowlist": [], - "outbound_gate_action": "review", + "outbound_require_review": true, "holds_on_expiry": "reject" } ``` -An empty allowlist makes every recipient a gate non-match, `review` holds every -non-match for a human, and `reject` prevents expiry from sending an unreviewed -message. Do not use `open` with `review`: `open` matches every recipient, so the -recipient gate holds nothing. Inbox creation alone is not permission to enable -this policy. +`require_review` holds every outbound send for a human whatever the gate says, +and `reject` prevents expiry from sending an unreviewed message. Do not try to +reach this by setting `outbound_gate_policy` to `open` with `outbound_gate_action` +`review`: `open` matches every recipient, so the recipient gate holds nothing. +(An allowlist with an empty list still holds everything too, but only because +nothing matches.) Inbox creation alone is not permission to enable this policy. ## Use the inbox safely diff --git a/web/public/setup.md b/web/public/setup.md index cda799d24..214b0d388 100644 --- a/web/public/setup.md +++ b/web/public/setup.md @@ -141,18 +141,17 @@ selected inbox with: ```json { - "outbound_gate_policy": "allowlist", - "outbound_gate_allowlist": [], - "outbound_gate_action": "review", + "outbound_require_review": true, "holds_on_expiry": "reject" } ``` -An empty allowlist makes every recipient a gate non-match, `review` holds every -non-match for a human, and `reject` prevents expiry from sending an unreviewed -message. Do not use `open` with `review`: `open` matches every recipient, so the -recipient gate holds nothing. Inbox creation alone is not permission to enable -this policy. +`require_review` holds every outbound send for a human whatever the gate says, +and `reject` prevents expiry from sending an unreviewed message. Do not try to +reach this by setting `outbound_gate_policy` to `open` with `outbound_gate_action` +`review`: `open` matches every recipient, so the recipient gate holds nothing. +(An allowlist with an empty list still holds everything too, but only because +nothing matches.) Inbox creation alone is not permission to enable this policy. ## Use the inbox safely diff --git a/web/src/app/(app)/inboxes/_components/ProtectionEditor.test.tsx b/web/src/app/(app)/inboxes/_components/ProtectionEditor.test.tsx index 59a74f13c..67c7f76aa 100644 --- a/web/src/app/(app)/inboxes/_components/ProtectionEditor.test.tsx +++ b/web/src/app/(app)/inboxes/_components/ProtectionEditor.test.tsx @@ -178,6 +178,7 @@ describe("ProtectionEditor — save", () => { // field is not editable in that state. gate: { policy: "open", allowlist: [], action: "flag" }, scan: { sensitivity: "off" }, + require_review: false, }, holds: { ttl_seconds: 86400, on_expiry: "approve" }, }); @@ -186,6 +187,40 @@ describe("ProtectionEditor — save", () => { expect(onSaved).toHaveBeenCalledTimes(1); }); + it("offers the outbound-only require_review toggle and PUTs it", async () => { + mockSetProtection.mockResolvedValue(undefined); + renderEditor(); + + // The switch is outbound-only: an inbound hold-everything knob would be a + // different posture and the API does not expose one. + expect(screen.queryByLabelText("Inbound always require human review")).not.toBeInTheDocument(); + await userEvent.click(screen.getByLabelText("Outbound always require human review")); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockSetProtection).toHaveBeenCalledTimes(1)); + const [, payload] = mockSetProtection.mock.calls[0]; + expect(payload.outbound.require_review).toBe(true); + // The flag holds every send on its own; the gate is left as it was. + expect(payload.outbound.gate.policy).toBe("open"); + expect(payload.outbound.gate.action).toBe("flag"); + }); + + it("keeps an existing require_review set when an unrelated field is saved", async () => { + mockSetProtection.mockResolvedValue(undefined); + renderEditor({ + ...baseConfig, + outbound: { ...baseConfig.outbound, require_review: true }, + }); + expect(screen.getByLabelText("Outbound always require human review")).toBeChecked(); + + await userEvent.click(screen.getByRole("button", { name: "1 day" })); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockSetProtection).toHaveBeenCalledTimes(1)); + const [, payload] = mockSetProtection.mock.calls[0]; + expect(payload.outbound.require_review).toBe(true); + }); + it("trims allowlist entries and drops blank lines on save", async () => { mockSetProtection.mockResolvedValue(undefined); renderEditor(); diff --git a/web/src/app/(app)/inboxes/_components/ProtectionEditor.tsx b/web/src/app/(app)/inboxes/_components/ProtectionEditor.tsx index 3f31740ee..081dd02f2 100644 --- a/web/src/app/(app)/inboxes/_components/ProtectionEditor.tsx +++ b/web/src/app/(app)/inboxes/_components/ProtectionEditor.tsx @@ -5,8 +5,8 @@ import { Chip, Eyebrow } from "@e2a/ui"; import { setProtection } from "../../../components/onboarding/api"; import type { ProtectionConfig, - ProtectionGate, - ProtectionScan, + ProtectionDirection, + ProtectionOutboundDirection, } from "../../../components/onboarding/types"; // Beta protection editor for the inbox-settings page. Exposes the whole @@ -48,22 +48,22 @@ type Sensitivity = "off" | "low" | "medium" | "high"; // One direction's draft state (gate policy/action/allowlist + scan). // allowlist is kept as raw textarea text; split into lines on save. +// requireReview is outbound-only; inbound drafts leave it false. type DirectionDraft = { policy: Policy; action: Action; allowlist: string; scan: Sensitivity; + requireReview: boolean; }; -function directionFromConfig(d: { - gate: ProtectionGate; - scan: ProtectionScan; -}): DirectionDraft { +function directionFromConfig(d: ProtectionDirection | ProtectionOutboundDirection): DirectionDraft { return { policy: (d.gate.policy ?? "open") as Policy, action: (d.gate.action ?? "flag") as Action, allowlist: (d.gate.allowlist ?? []).join("\n"), scan: (d.scan.sensitivity ?? "off") as Sensitivity, + requireReview: "require_review" in d ? Boolean(d.require_review) : false, }; } @@ -84,10 +84,16 @@ function directionToConfig(d: DirectionDraft) { }; } +// Outbound carries require_review alongside the shared gate/scan shape. +function outboundToConfig(d: DirectionDraft) { + return { ...directionToConfig(d), require_review: d.requireReview }; +} + function isDirectionDirty(current: DirectionDraft, baseline: DirectionDraft): boolean { if (current.policy !== baseline.policy) return true; if (current.action !== baseline.action) return true; if (current.scan !== baseline.scan) return true; + if (current.requireReview !== baseline.requireReview) return true; if (current.policy !== "open" && current.allowlist !== baseline.allowlist) { return true; } @@ -131,11 +137,13 @@ function DirectionFields({ gateLabel, draft, onChange, + requireReviewControl = false, }: { title: string; gateLabel: string; draft: DirectionDraft; onChange: (next: DirectionDraft) => void; + requireReviewControl?: boolean; }) { return (
@@ -188,6 +196,25 @@ function DirectionFields({ onChange={(scan) => onChange({ ...draft, scan })} />
+ + {requireReviewControl && ( + + )} ); } @@ -247,7 +274,7 @@ export function ProtectionEditor({ try { await setProtection(email, { inbound: directionToConfig(inbound), - outbound: directionToConfig(outbound), + outbound: outboundToConfig(outbound), holds: { ttl_seconds: ttl, on_expiry: onExpiry }, }); setBaseline({ @@ -319,6 +346,7 @@ export function ProtectionEditor({ gateLabel="Who this inbox may send to" draft={outbound} onChange={(d) => { setOutbound(d); setSaved(false); }} + requireReviewControl /> {/* Review queue (holds) — what happens to messages a gate or scan diff --git a/web/src/app/components/onboarding/types.ts b/web/src/app/components/onboarding/types.ts index 82374150e..ca9bcc83a 100644 --- a/web/src/app/components/onboarding/types.ts +++ b/web/src/app/components/onboarding/types.ts @@ -169,9 +169,7 @@ export type AgentCreateResponse = { }; // ── Protection config (GET/PUT /v1/agents/{email}/protection) ── -// Mirrors ProtectionConfigView. Beta. The dashboard only edits the -// `holds` section; inbound/outbound are read + passed back unchanged on -// the wholesale PUT. +// Mirrors ProtectionConfigView. Beta. export type ProtectionGate = { policy?: "open" | "allowlist" | "domain"; @@ -188,6 +186,11 @@ export type ProtectionDirection = { scan: ProtectionScan; }; +// Outbound adds require_review (#989): hold every send regardless of the gate. +export type ProtectionOutboundDirection = ProtectionDirection & { + require_review?: boolean; +}; + export type ProtectionHolds = { ttl_seconds?: number; on_expiry?: "approve" | "reject"; @@ -195,6 +198,6 @@ export type ProtectionHolds = { export type ProtectionConfig = { inbound: ProtectionDirection; - outbound: ProtectionDirection; + outbound: ProtectionOutboundDirection; holds: ProtectionHolds; }; From 1ee6815b29d0222dd83e01f1c71892e3879976c3 Mon Sep 17 00:00:00 2001 From: Tung Lam <53996158+tunglambk@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:38:15 +0000 Subject: [PATCH 2/2] chore(plugins): bump e2a to 0.9.7 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. --- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 2 +- plugins/e2a/.claude-plugin/plugin.json | 2 +- plugins/e2a/.codex-plugin/plugin.json | 2 +- plugins/e2a/.cursor-plugin/plugin.json | 2 +- plugins/e2a/plugin.json | 2 +- plugins/e2a/plugin.meta.json | 2 +- scripts/plugin-email-evals.test.mjs | 2 +- scripts/plugin-packaging.test.mjs | 6 +++--- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3e9644411..ec0ce54b7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ }, "metadata": { "description": "e2a plugins for Claude Code — open-source email API for applications and AI agents", - "version": "0.9.6" + "version": "0.9.7" }, "plugins": [ { diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 13a26916d..5a52bc8bd 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "e2a — open-source email API for applications and AI agents (MCP configuration and canonical docs).", - "version": "0.9.6" + "version": "0.9.7" }, "plugins": [ { diff --git a/plugins/e2a/.claude-plugin/plugin.json b/plugins/e2a/.claude-plugin/plugin.json index 8020049ed..8acf10480 100644 --- a/plugins/e2a/.claude-plugin/plugin.json +++ b/plugins/e2a/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "e2a", "displayName": "e2a", - "version": "0.9.6", + "version": "0.9.7", "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy", diff --git a/plugins/e2a/.codex-plugin/plugin.json b/plugins/e2a/.codex-plugin/plugin.json index 69927f049..850fc2faa 100644 --- a/plugins/e2a/.codex-plugin/plugin.json +++ b/plugins/e2a/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "e2a", "displayName": "e2a", - "version": "0.9.6", + "version": "0.9.7", "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy" diff --git a/plugins/e2a/.cursor-plugin/plugin.json b/plugins/e2a/.cursor-plugin/plugin.json index b4a5f2901..110b8fb13 100644 --- a/plugins/e2a/.cursor-plugin/plugin.json +++ b/plugins/e2a/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "e2a", "displayName": "e2a", - "version": "0.9.6", + "version": "0.9.7", "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy", diff --git a/plugins/e2a/plugin.json b/plugins/e2a/plugin.json index b3994917d..025755c00 100644 --- a/plugins/e2a/plugin.json +++ b/plugins/e2a/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "e2a", - "version": "0.9.6", + "version": "0.9.7", "description": "Open-source email API for applications and AI agents — transactional sending from any product, per-agent two-way inboxes, structured SPF/DKIM/DMARC evidence, and a queryable event log. 80 MCP tools over hosted streamable HTTP with OAuth.", "author": { "name": "TokenCanopy", diff --git a/plugins/e2a/plugin.meta.json b/plugins/e2a/plugin.meta.json index b86342927..df6378ba5 100644 --- a/plugins/e2a/plugin.meta.json +++ b/plugins/e2a/plugin.meta.json @@ -3,7 +3,7 @@ "name": "e2a", "displayName": "e2a", - "version": "0.9.6", + "version": "0.9.7", "license": "Apache-2.0", "homepage": "https://e2a.dev", "repository": "https://github.com/tokencanopy/e2a", diff --git a/scripts/plugin-email-evals.test.mjs b/scripts/plugin-email-evals.test.mjs index 58c11ec86..81e7e9564 100644 --- a/scripts/plugin-email-evals.test.mjs +++ b/scripts/plugin-email-evals.test.mjs @@ -603,7 +603,7 @@ test("templates and skill contain only synthetic email identities", async () => test("all plugin manifests release email-evals together without changing discovery conventions", async () => { for (const file of manifestFiles) { const manifest = JSON.parse(await readFile(file, "utf8")); - assert.equal(manifest.version ?? manifest.metadata?.version, "0.9.6", file); + assert.equal(manifest.version ?? manifest.metadata?.version, "0.9.7", file); } const claude = JSON.parse(await readFile(manifestFiles[0], "utf8")); diff --git a/scripts/plugin-packaging.test.mjs b/scripts/plugin-packaging.test.mjs index 295d08f27..daa4ceb86 100644 --- a/scripts/plugin-packaging.test.mjs +++ b/scripts/plugin-packaging.test.mjs @@ -55,12 +55,12 @@ test("marketplaces expose the supported plugin set and release versions", async assert.deepEqual(claudeMarket.plugins.map((plugin) => plugin.name).sort(), ["e2a", "e2a-labs"]); assert.deepEqual(codexMarket.plugins.map((plugin) => plugin.name).sort(), ["e2a", "e2a-labs"]); assert.deepEqual(cursorMarket.plugins.map((plugin) => plugin.name), ["e2a"]); - assert.equal(claudeMarket.metadata.version, "0.9.6"); - assert.equal(cursorMarket.metadata.version, "0.9.6"); + assert.equal(claudeMarket.metadata.version, "0.9.7"); + assert.equal(cursorMarket.metadata.version, "0.9.7"); for (const client of [".claude-plugin", ".codex-plugin", ".cursor-plugin"]) { const core = JSON.parse(await readFile(`plugins/e2a/${client}/plugin.json`, "utf8")); - assert.equal(core.version, "0.9.6"); + assert.equal(core.version, "0.9.7"); } for (const client of [".claude-plugin", ".codex-plugin"]) { const labs = JSON.parse(await readFile(`plugins/e2a-labs/${client}/plugin.json`, "utf8"));