@wraps.dev/cli 3.4.0 → 3.5.1
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/cli.js +864 -598
- package/dist/cli.js.map +1 -1
- package/dist/console/assets/index-CGthEkGy.js +127 -0
- package/dist/console/index.html +1 -1
- package/dist/lambda/agent-enforcer/.bundled +1 -1
- package/dist/lambda/agent-enforcer/index.js +13 -13
- package/dist/lambda/agent-enforcer/index.test.ts +99 -0
- package/dist/lambda/agent-enforcer/index.ts +54 -0
- package/dist/lambda/event-processor/.bundled +1 -1
- package/dist/lambda/inbound-processor/.bundled +1 -1
- package/dist/lambda/inbound-processor/index.js +56 -56
- package/dist/lambda/sms-event-processor/.bundled +1 -1
- package/package.json +3 -3
- package/dist/console/assets/index-YRZcw9N8.js +0 -127
|
@@ -535,3 +535,102 @@ describe("agent-enforcer — status poll", () => {
|
|
|
535
535
|
expect(res).toEqual({ status: "unknown" });
|
|
536
536
|
});
|
|
537
537
|
});
|
|
538
|
+
|
|
539
|
+
describe("agent-enforcer — reply-to and threading headers", () => {
|
|
540
|
+
it("sends with replyTo and sets ReplyToAddresses", async () => {
|
|
541
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
542
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
543
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-1" });
|
|
544
|
+
|
|
545
|
+
const event = sendEvent();
|
|
546
|
+
event.payload!.replyTo = "human@example.com";
|
|
547
|
+
const handler = makeHandler();
|
|
548
|
+
const res = await handler(event, agentContext("a1"));
|
|
549
|
+
|
|
550
|
+
expect(res.status).toBe("sent");
|
|
551
|
+
const sent = sesMock.commandCalls(SendEmailCommand)[0].args[0].input;
|
|
552
|
+
expect(sent.ReplyToAddresses).toEqual(["human@example.com"]);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("sends with inReplyTo + references and sets Content.Simple.Headers", async () => {
|
|
556
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
557
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
558
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-1" });
|
|
559
|
+
|
|
560
|
+
const event = sendEvent();
|
|
561
|
+
event.payload!.inReplyTo = "<abc@mail.example.com>";
|
|
562
|
+
event.payload!.references = "<abc@mail.example.com> <def@mail.example.com>";
|
|
563
|
+
const handler = makeHandler();
|
|
564
|
+
const res = await handler(event, agentContext("a1"));
|
|
565
|
+
|
|
566
|
+
expect(res.status).toBe("sent");
|
|
567
|
+
const sent = sesMock.commandCalls(SendEmailCommand)[0].args[0].input;
|
|
568
|
+
expect(sent.Content?.Simple?.Headers).toEqual([
|
|
569
|
+
{ Name: "In-Reply-To", Value: "<abc@mail.example.com>" },
|
|
570
|
+
{
|
|
571
|
+
Name: "References",
|
|
572
|
+
Value: "<abc@mail.example.com> <def@mail.example.com>",
|
|
573
|
+
},
|
|
574
|
+
]);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("omits ReplyToAddresses and Headers keys entirely when none of the three fields are set", async () => {
|
|
578
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
579
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
580
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "msg-1" });
|
|
581
|
+
|
|
582
|
+
const handler = makeHandler();
|
|
583
|
+
const res = await handler(sendEvent(), agentContext("a1"));
|
|
584
|
+
|
|
585
|
+
expect(res.status).toBe("sent");
|
|
586
|
+
const sent = sesMock.commandCalls(SendEmailCommand)[0].args[0].input;
|
|
587
|
+
expect(sent).not.toHaveProperty("ReplyToAddresses");
|
|
588
|
+
expect(sent.Content?.Simple).not.toHaveProperty("Headers");
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("blocks a multi-address replyTo before consuming any cap", async () => {
|
|
592
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
593
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
594
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "nope" });
|
|
595
|
+
|
|
596
|
+
const event = sendEvent();
|
|
597
|
+
event.payload!.replyTo = "a@b.com, c@d.com";
|
|
598
|
+
const handler = makeHandler();
|
|
599
|
+
const res = await handler(event, agentContext("a1"));
|
|
600
|
+
|
|
601
|
+
expect(res).toEqual({ status: "blocked", reason: "invalid reply-to" });
|
|
602
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
603
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("blocks a CRLF header-injection attempt in inReplyTo, no SES, no counter write", async () => {
|
|
607
|
+
ddbMock.on(GetCommand).resolves({ Item: configItem() });
|
|
608
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
609
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "nope" });
|
|
610
|
+
|
|
611
|
+
const event = sendEvent();
|
|
612
|
+
event.payload!.inReplyTo =
|
|
613
|
+
"<abc@mail.example.com>\r\nBcc: attacker@evil.com";
|
|
614
|
+
const handler = makeHandler();
|
|
615
|
+
const res = await handler(event, agentContext("a1"));
|
|
616
|
+
|
|
617
|
+
expect(res).toEqual({ status: "blocked", reason: "invalid in-reply-to" });
|
|
618
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
619
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("blocks the same malformed replyTo on an execute event too", async () => {
|
|
623
|
+
routeExecuteReads({ outcome: null });
|
|
624
|
+
ddbMock.on(PutCommand).resolves({});
|
|
625
|
+
sesMock.on(SendEmailCommand).resolves({ MessageId: "should-not-send" });
|
|
626
|
+
|
|
627
|
+
const event = executeEvent();
|
|
628
|
+
event.payload!.replyTo = "a@b.com, c@d.com";
|
|
629
|
+
const handler = makeHandler();
|
|
630
|
+
const res = await handler(event, platformContext());
|
|
631
|
+
|
|
632
|
+
expect(res).toEqual({ status: "blocked", reason: "invalid reply-to" });
|
|
633
|
+
expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0);
|
|
634
|
+
expect(ddbMock.commandCalls(PutCommand)).toHaveLength(0);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
@@ -82,6 +82,21 @@ function isSingleEmail(to: unknown): to is string {
|
|
|
82
82
|
return /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(to);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A value safe to place on a MIME header line. CR/LF is the header-injection
|
|
87
|
+
* vector; SES additionally requires printable ASCII. Mirrors
|
|
88
|
+
* assertNoHeaderInjection() in wraps-js packages/email/src/utils/headers.ts,
|
|
89
|
+
* reimplemented as a predicate because the enforcer returns verdicts, never throws.
|
|
90
|
+
*/
|
|
91
|
+
function isSafeHeaderValue(value: unknown): value is string {
|
|
92
|
+
return (
|
|
93
|
+
typeof value === "string" &&
|
|
94
|
+
value.length > 0 &&
|
|
95
|
+
value.length <= 998 &&
|
|
96
|
+
/^[\x20-\x7e]+$/.test(value)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
85
100
|
/** Sender pinning (SEC-3): `payload.from` must be the agent's own address. */
|
|
86
101
|
function isOwnSender(from: unknown, emailAddress: string): boolean {
|
|
87
102
|
return (
|
|
@@ -167,6 +182,26 @@ async function tryDecrement(
|
|
|
167
182
|
}
|
|
168
183
|
}
|
|
169
184
|
|
|
185
|
+
/** Verdict reason if the optional header fields are malformed, else null. */
|
|
186
|
+
function headerFieldProblem(payload: AgentEmailPayload): string | null {
|
|
187
|
+
if (payload.replyTo !== undefined && !isSingleEmail(payload.replyTo)) {
|
|
188
|
+
return "invalid reply-to";
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
payload.inReplyTo !== undefined &&
|
|
192
|
+
!isSafeHeaderValue(payload.inReplyTo)
|
|
193
|
+
) {
|
|
194
|
+
return "invalid in-reply-to";
|
|
195
|
+
}
|
|
196
|
+
if (
|
|
197
|
+
payload.references !== undefined &&
|
|
198
|
+
!isSafeHeaderValue(payload.references)
|
|
199
|
+
) {
|
|
200
|
+
return "invalid references";
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
170
205
|
/** Caller identity bound to the invoke principal, never the payload (SEC-2). */
|
|
171
206
|
type Caller = { kind: "agent"; agentId: string } | { kind: "platform" };
|
|
172
207
|
|
|
@@ -211,10 +246,20 @@ async function sendViaSes(
|
|
|
211
246
|
agentId: string,
|
|
212
247
|
configSet: string
|
|
213
248
|
): Promise<string> {
|
|
249
|
+
const headers = [
|
|
250
|
+
...(payload.inReplyTo
|
|
251
|
+
? [{ Name: "In-Reply-To", Value: payload.inReplyTo }]
|
|
252
|
+
: []),
|
|
253
|
+
...(payload.references
|
|
254
|
+
? [{ Name: "References", Value: payload.references }]
|
|
255
|
+
: []),
|
|
256
|
+
];
|
|
257
|
+
|
|
214
258
|
const response = await sesClient.send(
|
|
215
259
|
new SendEmailCommand({
|
|
216
260
|
FromEmailAddress: payload.from,
|
|
217
261
|
Destination: { ToAddresses: [payload.to] },
|
|
262
|
+
...(payload.replyTo ? { ReplyToAddresses: [payload.replyTo] } : {}),
|
|
218
263
|
Content: {
|
|
219
264
|
Simple: {
|
|
220
265
|
Subject: { Data: payload.subject },
|
|
@@ -222,6 +267,7 @@ async function sendViaSes(
|
|
|
222
267
|
Html: { Data: payload.html },
|
|
223
268
|
Text: { Data: payload.text },
|
|
224
269
|
},
|
|
270
|
+
...(headers.length > 0 ? { Headers: headers } : {}),
|
|
225
271
|
},
|
|
226
272
|
},
|
|
227
273
|
ConfigurationSetName: configSet,
|
|
@@ -287,6 +333,10 @@ export function createHandler(deps: EnforcerDeps) {
|
|
|
287
333
|
if (!isSingleEmail(payload.to)) {
|
|
288
334
|
return { status: "blocked", reason: "invalid recipient" };
|
|
289
335
|
}
|
|
336
|
+
const headerProblem = headerFieldProblem(payload);
|
|
337
|
+
if (headerProblem) {
|
|
338
|
+
return { status: "blocked", reason: headerProblem };
|
|
339
|
+
}
|
|
290
340
|
|
|
291
341
|
const config = await loadConfig(dynamo, table, agentId);
|
|
292
342
|
// 1. Kill-switch + policy existence FIRST.
|
|
@@ -420,6 +470,10 @@ export function createHandler(deps: EnforcerDeps) {
|
|
|
420
470
|
if (!isSingleEmail(payload.to)) {
|
|
421
471
|
return { status: "blocked", reason: "invalid recipient" };
|
|
422
472
|
}
|
|
473
|
+
const headerProblem = headerFieldProblem(payload);
|
|
474
|
+
if (headerProblem) {
|
|
475
|
+
return { status: "blocked", reason: headerProblem };
|
|
476
|
+
}
|
|
423
477
|
|
|
424
478
|
const configSet = process.env.CONFIG_SET || DEFAULT_CONFIG_SET;
|
|
425
479
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Built at: 2026-
|
|
1
|
+
Built at: 2026-09-03T04:11:30.841Z
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Built at: 2026-
|
|
1
|
+
Built at: 2026-09-03T04:11:30.999Z
|