@wraps.dev/cli 3.4.0 → 3.5.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.
@@ -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-08-22T22:34:10.008Z
1
+ Built at: 2026-09-01T19:28:31.153Z
@@ -1 +1 @@
1
- Built at: 2026-08-22T22:34:10.163Z
1
+ Built at: 2026-09-01T19:28:31.306Z