@wraps.dev/cli 2.25.1 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-lambda.zip +0 -0
- package/dist/cli.js +7302 -6252
- package/dist/cli.js.map +1 -1
- package/dist/lambda/agent-enforcer/.bundled +1 -0
- package/dist/lambda/agent-enforcer/index.js +4 -0
- package/dist/lambda/agent-enforcer/index.test.ts +537 -0
- package/dist/lambda/agent-enforcer/index.ts +514 -0
- package/dist/lambda/event-processor/.bundled +1 -1
- package/dist/lambda/inbound-processor/.bundled +1 -1
- package/dist/lambda/sms-event-processor/.bundled +1 -1
- package/dist/selfhost-migrations/0064_wise_brood.sql +42 -0
- package/dist/selfhost-migrations/0065_public_the_fallen.sql +2 -0
- package/dist/selfhost-migrations/meta/0064_snapshot.json +7626 -0
- package/dist/selfhost-migrations/meta/0065_snapshot.json +7662 -0
- package/dist/selfhost-migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
|
3
|
+
import {
|
|
4
|
+
DynamoDBDocumentClient,
|
|
5
|
+
GetCommand,
|
|
6
|
+
PutCommand,
|
|
7
|
+
UpdateCommand,
|
|
8
|
+
} from "@aws-sdk/lib-dynamodb";
|
|
9
|
+
import { mockClient } from "aws-sdk-client-mock";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import type {
|
|
12
|
+
AgentPolicy,
|
|
13
|
+
EnforcerRequest,
|
|
14
|
+
} from "../../src/agent-enforcer-contract.js";
|
|
15
|
+
import { createHandler } from "./index.ts";
|
|
16
|
+
|
|
17
|
+
const ddbMock = mockClient(DynamoDBDocumentClient);
|
|
18
|
+
const sesMock = mockClient(SESv2Client);
|
|
19
|
+
|
|
20
|
+
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
|
21
|
+
const sesClient = new SESv2Client({});
|
|
22
|
+
|
|
23
|
+
const AGENT_ADDRESS = "agent@bot.example.com";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_POLICY: AgentPolicy = {
|
|
26
|
+
maxPerHour: 20,
|
|
27
|
+
maxPerDay: 100,
|
|
28
|
+
allowedRecipients: ["ok@example.com"],
|
|
29
|
+
allowedRecipientDomains: ["allowed.com"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function configItem(overrides?: {
|
|
33
|
+
killed?: boolean;
|
|
34
|
+
emailAddress?: string;
|
|
35
|
+
policy?: Partial<AgentPolicy>;
|
|
36
|
+
}) {
|
|
37
|
+
return {
|
|
38
|
+
pk: "CONFIG#a1",
|
|
39
|
+
sk: "CONFIG",
|
|
40
|
+
killed: overrides?.killed ?? false,
|
|
41
|
+
emailAddress: overrides?.emailAddress ?? AGENT_ADDRESS,
|
|
42
|
+
policy: { ...DEFAULT_POLICY, ...overrides?.policy },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The caller is bound to its per-agent Lambda alias (SEC-2), never the payload. */
|
|
47
|
+
function agentContext(agentId = "a1") {
|
|
48
|
+
return {
|
|
49
|
+
invokedFunctionArn: `arn:aws:lambda:us-east-1:123456789012:function:wraps-agent-enforcer:agent-${agentId}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The platform assume-role invokes the unqualified function (execute only). */
|
|
54
|
+
function platformContext(qualifier?: string) {
|
|
55
|
+
const base =
|
|
56
|
+
"arn:aws:lambda:us-east-1:123456789012:function:wraps-agent-enforcer";
|
|
57
|
+
return { invokedFunctionArn: qualifier ? `${base}:${qualifier}` : base };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sendEvent(to = "ok@example.com", agentId = "a1"): EnforcerRequest {
|
|
61
|
+
return {
|
|
62
|
+
action: "send",
|
|
63
|
+
agentId,
|
|
64
|
+
payload: {
|
|
65
|
+
from: AGENT_ADDRESS,
|
|
66
|
+
to,
|
|
67
|
+
subject: "hi",
|
|
68
|
+
html: "<p>hi</p>",
|
|
69
|
+
text: "hi",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function executeEvent(): EnforcerRequest {
|
|
75
|
+
return {
|
|
76
|
+
action: "execute",
|
|
77
|
+
agentId: "a1",
|
|
78
|
+
approvalId: "ap-9",
|
|
79
|
+
payload: {
|
|
80
|
+
from: AGENT_ADDRESS,
|
|
81
|
+
to: "ok@example.com",
|
|
82
|
+
subject: "hi",
|
|
83
|
+
html: "<p>hi</p>",
|
|
84
|
+
text: "hi",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeHandler(fetchImpl?: typeof fetch) {
|
|
90
|
+
return createHandler({ dynamo, sesClient, fetchImpl });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function approvalFetch(approvalId: string) {
|
|
94
|
+
return vi.fn(
|
|
95
|
+
async () =>
|
|
96
|
+
new Response(JSON.stringify({ approvalId }), {
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: { "content-type": "application/json" },
|
|
99
|
+
})
|
|
100
|
+
) as unknown as typeof fetch;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class ConditionalCheckFailedException extends Error {
|
|
104
|
+
override name = "ConditionalCheckFailedException";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
ddbMock.reset();
|
|
109
|
+
sesMock.reset();
|
|
110
|
+
process.env.POLICY_TABLE = "wraps-email-agent-policy";
|
|
111
|
+
process.env.CONFIG_SET = "wraps-email-tracking";
|
|
112
|
+
process.env.WRAPS_API_URL = "https://api.wraps.dev";
|
|
113
|
+
process.env.WRAPS_AGENT_WEBHOOK_SECRET = "secret-key";
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
vi.restoreAllMocks();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("agent-enforcer — send under cap + allowlisted", () => {
|
|
121
|
+
it("sends via SES and returns {status:'sent'}, matching the payload", async () => {
|
|
122
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
123
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
124
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-1" });
|
|
125
|
+
|
|
126
|
+
const handler = makeHandler();
|
|
127
|
+
const res = await handler(sendEvent(), agentContext("a1"));
|
|
128
|
+
|
|
129
|
+
expect(res).toEqual({ status: "sent", messageId: "msg-1" });
|
|
130
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(1);
|
|
131
|
+
const sent = sesMock.commandCalls(SendEmailCommand)[0].args[0].input;
|
|
132
|
+
// TQ-3: the email actually sent mirrors the payload, not hardcoded fields.
|
|
133
|
+
expect(sent.FromEmailAddress).toBe(AGENT_ADDRESS);
|
|
134
|
+
expect(sent.Destination?.ToAddresses).toEqual(["ok@example.com"]);
|
|
135
|
+
expect(sent.Content?.Simple?.Subject?.Data).toBe("hi");
|
|
136
|
+
expect(sent.Content?.Simple?.Body?.Html?.Data).toBe("<p>hi</p>");
|
|
137
|
+
expect(sent.Content?.Simple?.Body?.Text?.Data).toBe("hi");
|
|
138
|
+
expect(sent.ConfigurationSetName).toBe("wraps-email-tracking");
|
|
139
|
+
expect(sent.EmailTags).toEqual([{ Name: "agentId", Value: "a1" }]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("agent-enforcer — caller-bound identity (SEC-2)", () => {
|
|
144
|
+
it("uses the caller's alias identity, ignoring a forged payload agentId", async () => {
|
|
145
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
146
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
147
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-1" });
|
|
148
|
+
|
|
149
|
+
// Payload claims to be a different (victim) agent; caller alias is a1.
|
|
150
|
+
const forged = sendEvent("ok@example.com", "victim-agent");
|
|
151
|
+
const handler = makeHandler();
|
|
152
|
+
const res = await handler(forged, agentContext("a1"));
|
|
153
|
+
|
|
154
|
+
expect(res.status).toBe("sent");
|
|
155
|
+
// CONFIG is read under the CALLER identity, not the forged payload agentId.
|
|
156
|
+
const configReads = ddbMock
|
|
157
|
+
.commandCalls(GetCommand)
|
|
158
|
+
.map((c) => c.args[0].input.Key);
|
|
159
|
+
expect(configReads).toContainEqual({ pk: "CONFIG#a1", sk: "CONFIG" });
|
|
160
|
+
expect(configReads).not.toContainEqual({
|
|
161
|
+
pk: "CONFIG#victim-agent",
|
|
162
|
+
sk: "CONFIG",
|
|
163
|
+
});
|
|
164
|
+
// Counters + tags are the caller's too.
|
|
165
|
+
const update = ddbMock.commandCalls(UpdateCommand)[0].args[0].input;
|
|
166
|
+
expect(update.Key?.pk).toBe("HOUR#a1");
|
|
167
|
+
const sent = sesMock.commandCalls(SendEmailCommand)[0].args[0].input;
|
|
168
|
+
expect(sent.EmailTags).toEqual([{ Name: "agentId", Value: "a1" }]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("blocks 'send' from an unqualified (platform) caller", async () => {
|
|
172
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
173
|
+
|
|
174
|
+
const handler = makeHandler();
|
|
175
|
+
const res = await handler(sendEvent(), platformContext());
|
|
176
|
+
|
|
177
|
+
expect(res).toEqual({
|
|
178
|
+
status: "blocked",
|
|
179
|
+
reason: "unauthorized action for caller",
|
|
180
|
+
});
|
|
181
|
+
// Rejected before any DynamoDB read.
|
|
182
|
+
expect(ddbMock.commandCalls(GetCommand)).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("blocks 'execute' from an agent-qualified caller", async () => {
|
|
186
|
+
const handler = makeHandler();
|
|
187
|
+
const res = await handler(executeEvent(), agentContext("a1"));
|
|
188
|
+
|
|
189
|
+
expect(res).toEqual({
|
|
190
|
+
status: "blocked",
|
|
191
|
+
reason: "unauthorized action for caller",
|
|
192
|
+
});
|
|
193
|
+
expect(ddbMock.commandCalls(GetCommand)).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("agent-enforcer — sender pinning (SEC-3)", () => {
|
|
198
|
+
it("blocks a send whose from is not the agent's own address, no SES", async () => {
|
|
199
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
200
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
201
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "nope" });
|
|
202
|
+
|
|
203
|
+
const spoof = sendEvent();
|
|
204
|
+
spoof.payload!.from = "ceo@example.com";
|
|
205
|
+
const handler = makeHandler();
|
|
206
|
+
const res = await handler(spoof, agentContext("a1"));
|
|
207
|
+
|
|
208
|
+
expect(res).toEqual({
|
|
209
|
+
status: "blocked",
|
|
210
|
+
reason: "from must be the agent's own address",
|
|
211
|
+
});
|
|
212
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
213
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("matches the pinned address case-insensitively", async () => {
|
|
217
|
+
ddbMock.on(GetCommand).resolves({
|
|
218
|
+
Item: configItem({ emailAddress: "Agent@Bot.Example.com" }),
|
|
219
|
+
});
|
|
220
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
221
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-1" });
|
|
222
|
+
|
|
223
|
+
const handler = makeHandler();
|
|
224
|
+
const res = await handler(sendEvent(), agentContext("a1"));
|
|
225
|
+
|
|
226
|
+
expect(res.status).toBe("sent");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("agent-enforcer — recipient validation (COR-6)", () => {
|
|
231
|
+
it("blocks a multi-recipient / malformed 'to', no SES, no cap write", async () => {
|
|
232
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
233
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
234
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "nope" });
|
|
235
|
+
|
|
236
|
+
const handler = makeHandler();
|
|
237
|
+
const res = await handler(
|
|
238
|
+
sendEvent("a@example.com, b@example.com"),
|
|
239
|
+
agentContext("a1")
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(res).toEqual({ status: "blocked", reason: "invalid recipient" });
|
|
243
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
244
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("agent-enforcer — recipient not on allowlist", () => {
|
|
249
|
+
it("skips SES, POSTs the webhook, returns pending_approval with approvalId", async () => {
|
|
250
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
251
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
252
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "should-not-send" });
|
|
253
|
+
|
|
254
|
+
const fetchImpl = approvalFetch("ap-1");
|
|
255
|
+
const handler = makeHandler(fetchImpl);
|
|
256
|
+
const res = await handler(
|
|
257
|
+
sendEvent("stranger@notallowed.com"),
|
|
258
|
+
agentContext("a1")
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
expect(res.status).toBe("pending_approval");
|
|
262
|
+
expect(res.approvalId).toBe("ap-1");
|
|
263
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
264
|
+
// No cap consumed for a non-allowlisted recipient (allowlist precedes caps).
|
|
265
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
266
|
+
|
|
267
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
268
|
+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
|
|
269
|
+
.calls[0];
|
|
270
|
+
expect(url).toBe("https://api.wraps.dev/v1/agents/webhook");
|
|
271
|
+
expect(init.headers["x-wraps-agent-key"]).toBe("secret-key");
|
|
272
|
+
const body = JSON.parse(init.body);
|
|
273
|
+
expect(body).toMatchObject({
|
|
274
|
+
agentId: "a1",
|
|
275
|
+
event: "pending_approval",
|
|
276
|
+
reason: "recipient not on allowlist",
|
|
277
|
+
});
|
|
278
|
+
expect(body.payload.to).toBe("stranger@notallowed.com");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("agent-enforcer — hourly cap reached", () => {
|
|
283
|
+
it("inspects the conditional write and flags to pending_approval", async () => {
|
|
284
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
285
|
+
// Conditional write fails: cap already reached. The item is not mutated.
|
|
286
|
+
ddbMock
|
|
287
|
+
.on(UpdateCommand)
|
|
288
|
+
.rejects(new ConditionalCheckFailedException("cap"));
|
|
289
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "should-not-send" });
|
|
290
|
+
|
|
291
|
+
const fetchImpl = approvalFetch("ap-2");
|
|
292
|
+
const handler = makeHandler(fetchImpl);
|
|
293
|
+
const res = await handler(sendEvent("ok@example.com"), agentContext("a1"));
|
|
294
|
+
|
|
295
|
+
expect(res.status).toBe("pending_approval");
|
|
296
|
+
expect(res.approvalId).toBe("ap-2");
|
|
297
|
+
expect(res.reason).toBe("hourly cap reached");
|
|
298
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
299
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
300
|
+
|
|
301
|
+
// TQ-2: the cap is enforced by the conditional UpdateItem, not read-modify-write.
|
|
302
|
+
const calls = ddbMock.commandCalls(UpdateCommand);
|
|
303
|
+
expect(calls).toHaveLength(1);
|
|
304
|
+
const input = calls[0].args[0].input;
|
|
305
|
+
expect(input.Key?.pk).toBe("HOUR#a1");
|
|
306
|
+
expect(input.ConditionExpression).toBe(
|
|
307
|
+
"attribute_not_exists(sends) OR sends < :cap"
|
|
308
|
+
);
|
|
309
|
+
expect(input.ExpressionAttributeValues?.[":cap"]).toBe(20);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("propagates a non-conditional DynamoDB error (never a false 'cap reached')", async () => {
|
|
313
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
314
|
+
const throttle = new Error("throughput exceeded");
|
|
315
|
+
throttle.name = "ProvisionedThroughputExceededException";
|
|
316
|
+
ddbMock.on(UpdateCommand).rejects(throttle);
|
|
317
|
+
|
|
318
|
+
const fetchImpl = approvalFetch("nope");
|
|
319
|
+
const handler = makeHandler(fetchImpl);
|
|
320
|
+
|
|
321
|
+
// A transient AWS error must surface as a Lambda failure (retryable), NOT be
|
|
322
|
+
// silently swallowed into a "cap reached" policy decision (TQ-2 negative path).
|
|
323
|
+
await expect(
|
|
324
|
+
handler(sendEvent("ok@example.com"), agentContext("a1"))
|
|
325
|
+
).rejects.toThrow("throughput exceeded");
|
|
326
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("agent-enforcer — daily cap reached (COR-9)", () => {
|
|
331
|
+
it("flags for the daily cap and compensates the hourly slot it consumed", async () => {
|
|
332
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
333
|
+
ddbMock.on(UpdateCommand).callsFake((input) => {
|
|
334
|
+
// Hour increment succeeds; day increment is at the cap.
|
|
335
|
+
if (String(input.Key?.pk).startsWith("DAY#")) {
|
|
336
|
+
throw new ConditionalCheckFailedException("day");
|
|
337
|
+
}
|
|
338
|
+
return {};
|
|
339
|
+
});
|
|
340
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "should-not-send" });
|
|
341
|
+
|
|
342
|
+
const fetchImpl = approvalFetch("ap-3");
|
|
343
|
+
const handler = makeHandler(fetchImpl);
|
|
344
|
+
const res = await handler(sendEvent("ok@example.com"), agentContext("a1"));
|
|
345
|
+
|
|
346
|
+
expect(res.status).toBe("pending_approval");
|
|
347
|
+
expect(res.reason).toBe("daily cap reached");
|
|
348
|
+
expect(res.approvalId).toBe("ap-3");
|
|
349
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
350
|
+
|
|
351
|
+
const updates = ddbMock
|
|
352
|
+
.commandCalls(UpdateCommand)
|
|
353
|
+
.map((c) => c.args[0].input);
|
|
354
|
+
const dayUpdate = updates.find((u) => String(u.Key?.pk).startsWith("DAY#"));
|
|
355
|
+
expect(dayUpdate?.ExpressionAttributeValues?.[":cap"]).toBe(100);
|
|
356
|
+
// COR-9: the hourly slot is incremented THEN given back — net zero.
|
|
357
|
+
const hourUpdates = updates.filter((u) =>
|
|
358
|
+
String(u.Key?.pk).startsWith("HOUR#")
|
|
359
|
+
);
|
|
360
|
+
expect(hourUpdates).toHaveLength(2);
|
|
361
|
+
expect(hourUpdates[0].UpdateExpression).toContain("+ :one");
|
|
362
|
+
expect(hourUpdates[1].UpdateExpression).toBe("SET sends = sends - :one");
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("agent-enforcer — cap <= 0 blocks every send (COR-7)", () => {
|
|
367
|
+
it("flags a maxPerHour:0 policy before writing any counter", async () => {
|
|
368
|
+
ddbMock
|
|
369
|
+
.on(GetCommand)
|
|
370
|
+
.resolves({ Item: configItem({ policy: { maxPerHour: 0 } }) });
|
|
371
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
372
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "nope" });
|
|
373
|
+
|
|
374
|
+
const fetchImpl = approvalFetch("ap-0");
|
|
375
|
+
const handler = makeHandler(fetchImpl);
|
|
376
|
+
const res = await handler(sendEvent("ok@example.com"), agentContext("a1"));
|
|
377
|
+
|
|
378
|
+
expect(res.status).toBe("pending_approval");
|
|
379
|
+
expect(res.reason).toBe("hourly cap reached");
|
|
380
|
+
// Never one free send: zero counter writes, zero SES.
|
|
381
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
382
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
383
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("agent-enforcer — killed agent (kill-switch supremacy)", () => {
|
|
388
|
+
it("blocks the send: no SES, no cap write, no webhook", async () => {
|
|
389
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem({ killed: true }) });
|
|
390
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
391
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "should-not-send" });
|
|
392
|
+
|
|
393
|
+
const fetchImpl = vi.fn() as unknown as typeof fetch;
|
|
394
|
+
const handler = makeHandler(fetchImpl);
|
|
395
|
+
const res = await handler(sendEvent("ok@example.com"), agentContext("a1"));
|
|
396
|
+
|
|
397
|
+
expect(res.status).toBe("blocked");
|
|
398
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
399
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
400
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
/** Route GetCommand by key: OUTCOME → idempotency probe, CONFIG → policy. */
|
|
405
|
+
function routeExecuteReads(opts: {
|
|
406
|
+
outcome?: Record<string, unknown> | null;
|
|
407
|
+
config?: Record<string, unknown>;
|
|
408
|
+
}) {
|
|
409
|
+
ddbMock.on(GetCommand).callsFake((input) => {
|
|
410
|
+
if (String(input.Key?.pk).startsWith("OUTCOME#")) {
|
|
411
|
+
return opts.outcome ? { Item: opts.outcome } : {};
|
|
412
|
+
}
|
|
413
|
+
return { Item: opts.config ?? configItem() };
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
describe("agent-enforcer — execute on killed agent", () => {
|
|
418
|
+
it("refuses to send (kill-switch wins the race) and writes no outcome", async () => {
|
|
419
|
+
routeExecuteReads({ outcome: null, config: configItem({ killed: true }) });
|
|
420
|
+
ddbMock.on(PutCommand).resolves({});
|
|
421
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "should-not-send" });
|
|
422
|
+
|
|
423
|
+
const handler = makeHandler();
|
|
424
|
+
const res = await handler(executeEvent(), platformContext());
|
|
425
|
+
|
|
426
|
+
expect(res.status).toBe("blocked");
|
|
427
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
428
|
+
expect(ddbMock.commandCalls(PutCommand)).toHaveLength(0);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("agent-enforcer — execute on live agent", () => {
|
|
433
|
+
it("sends and writes an OUTCOME item with the messageId and agentId", async () => {
|
|
434
|
+
routeExecuteReads({ outcome: null });
|
|
435
|
+
ddbMock.on(PutCommand).resolves({});
|
|
436
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-exec" });
|
|
437
|
+
|
|
438
|
+
const handler = makeHandler();
|
|
439
|
+
const res = await handler(executeEvent(), platformContext());
|
|
440
|
+
|
|
441
|
+
expect(res).toEqual({ status: "sent", messageId: "msg-exec" });
|
|
442
|
+
const puts = ddbMock.commandCalls(PutCommand);
|
|
443
|
+
expect(puts).toHaveLength(1);
|
|
444
|
+
const item = puts[0].args[0].input.Item;
|
|
445
|
+
expect(item?.pk).toBe("OUTCOME#ap-9");
|
|
446
|
+
expect(item?.sk).toBe("OUTCOME");
|
|
447
|
+
expect(item?.status).toBe("sent");
|
|
448
|
+
expect(item?.messageId).toBe("msg-exec");
|
|
449
|
+
expect(item?.agentId).toBe("a1");
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe("agent-enforcer — execute idempotency (convention 4)", () => {
|
|
454
|
+
it("two executes for the same approvalId send once; the second replays the stored outcome", async () => {
|
|
455
|
+
let stored: Record<string, unknown> | undefined;
|
|
456
|
+
ddbMock.on(GetCommand).callsFake((input) => {
|
|
457
|
+
if (String(input.Key?.pk).startsWith("OUTCOME#")) {
|
|
458
|
+
return stored ? { Item: stored } : {};
|
|
459
|
+
}
|
|
460
|
+
return { Item: configItem() };
|
|
461
|
+
});
|
|
462
|
+
ddbMock.on(PutCommand).callsFake((input) => {
|
|
463
|
+
stored = input.Item;
|
|
464
|
+
return {};
|
|
465
|
+
});
|
|
466
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-exec" });
|
|
467
|
+
|
|
468
|
+
const handler = makeHandler();
|
|
469
|
+
const first = await handler(executeEvent(), platformContext());
|
|
470
|
+
const second = await handler(executeEvent(), platformContext());
|
|
471
|
+
|
|
472
|
+
expect(first).toEqual({ status: "sent", messageId: "msg-exec" });
|
|
473
|
+
expect(second.status).toBe("sent");
|
|
474
|
+
expect(second.messageId).toBe("msg-exec");
|
|
475
|
+
// Exactly one SES send and one OUTCOME write across both invocations.
|
|
476
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(1);
|
|
477
|
+
expect(ddbMock.commandCalls(PutCommand)).toHaveLength(1);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe("agent-enforcer — status poll", () => {
|
|
482
|
+
it("returns the stored OUTCOME verdict for the owning agent", async () => {
|
|
483
|
+
ddbMock.on(GetCommand).resolves({
|
|
484
|
+
Item: {
|
|
485
|
+
pk: "OUTCOME#ap-9",
|
|
486
|
+
sk: "OUTCOME",
|
|
487
|
+
agentId: "a1",
|
|
488
|
+
status: "sent",
|
|
489
|
+
messageId: "msg-done",
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const handler = makeHandler();
|
|
494
|
+
const res = await handler(
|
|
495
|
+
{ action: "status", agentId: "a1", approvalId: "ap-9" },
|
|
496
|
+
agentContext("a1")
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
expect(res).toEqual({
|
|
500
|
+
status: "sent",
|
|
501
|
+
messageId: "msg-done",
|
|
502
|
+
reason: undefined,
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("returns {status:'unknown'} for an approvalId owned by another agent (SEC-9)", async () => {
|
|
507
|
+
ddbMock.on(GetCommand).resolves({
|
|
508
|
+
Item: {
|
|
509
|
+
pk: "OUTCOME#ap-9",
|
|
510
|
+
sk: "OUTCOME",
|
|
511
|
+
agentId: "other-agent",
|
|
512
|
+
status: "sent",
|
|
513
|
+
messageId: "leak",
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const handler = makeHandler();
|
|
518
|
+
const res = await handler(
|
|
519
|
+
{ action: "status", agentId: "a1", approvalId: "ap-9" },
|
|
520
|
+
agentContext("a1")
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
expect(res).toEqual({ status: "unknown" });
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("returns {status:'unknown'} for an unknown approvalId", async () => {
|
|
527
|
+
ddbMock.on(GetCommand).resolves({});
|
|
528
|
+
|
|
529
|
+
const handler = makeHandler();
|
|
530
|
+
const res = await handler(
|
|
531
|
+
{ action: "status", agentId: "a1", approvalId: "nope" },
|
|
532
|
+
agentContext("a1")
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
expect(res).toEqual({ status: "unknown" });
|
|
536
|
+
});
|
|
537
|
+
});
|