@wraps.dev/cli 2.25.1 → 2.26.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.
@@ -0,0 +1,514 @@
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 { NodeHttpHandler } from "@smithy/node-http-handler";
10
+ import {
11
+ type AgentConfigItem,
12
+ type AgentEmailPayload,
13
+ type AgentPolicy,
14
+ configItemKey,
15
+ dayCounterKey,
16
+ type EnforcerRequest,
17
+ type EnforcerResponse,
18
+ hourCounterKey,
19
+ isoDateWindow,
20
+ isoHourWindow,
21
+ outcomeItemKey,
22
+ } from "../../src/agent-enforcer-contract.js";
23
+
24
+ /** Counter/outcome items live for 48h then TTL-reap. Cleanup only, never window logic. */
25
+ const TTL_SECONDS = 48 * 60 * 60;
26
+
27
+ /** Default configuration set, mirroring @wraps/email-send's WRAPS_CONFIGURATION_SET_NAME. */
28
+ const DEFAULT_CONFIG_SET = "wraps-email-tracking";
29
+
30
+ export type EnforcerDeps = {
31
+ dynamo: DynamoDBDocumentClient;
32
+ sesClient: SESv2Client;
33
+ fetchImpl?: typeof fetch;
34
+ };
35
+
36
+ function requireEnv(name: string): string {
37
+ const value = process.env[name];
38
+ if (!value) {
39
+ throw new Error(`${name} environment variable not set`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function isConditionalCheckFailed(error: unknown): boolean {
45
+ return (
46
+ typeof error === "object" &&
47
+ error !== null &&
48
+ (error as { name?: string }).name === "ConditionalCheckFailedException"
49
+ );
50
+ }
51
+
52
+ async function loadConfig(
53
+ dynamo: DynamoDBDocumentClient,
54
+ table: string,
55
+ agentId: string
56
+ ): Promise<AgentConfigItem | null> {
57
+ const res = await dynamo.send(
58
+ new GetCommand({ TableName: table, Key: configItemKey(agentId) })
59
+ );
60
+ if (!res.Item) {
61
+ return null;
62
+ }
63
+ return {
64
+ killed: res.Item.killed === true,
65
+ emailAddress: String(res.Item.emailAddress ?? ""),
66
+ policy: res.Item.policy as AgentPolicy,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * A single plausible email string: exactly one `@`, a dotted domain, no commas
72
+ * or whitespace (which would smuggle a second recipient), within SES's 320-char
73
+ * address limit. Enforced-mode sends carry exactly one recipient (COR-6).
74
+ */
75
+ function isSingleEmail(to: unknown): to is string {
76
+ if (typeof to !== "string") {
77
+ return false;
78
+ }
79
+ if (to.length === 0 || to.length > 320) {
80
+ return false;
81
+ }
82
+ return /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(to);
83
+ }
84
+
85
+ /** Sender pinning (SEC-3): `payload.from` must be the agent's own address. */
86
+ function isOwnSender(from: unknown, emailAddress: string): boolean {
87
+ return (
88
+ typeof from === "string" &&
89
+ emailAddress.length > 0 &&
90
+ from.toLowerCase() === emailAddress.toLowerCase()
91
+ );
92
+ }
93
+
94
+ function isAllowed(to: string, policy: AgentPolicy): boolean {
95
+ const addr = to.toLowerCase();
96
+ const domain = addr.split("@")[1] ?? "";
97
+ if (policy.allowedRecipients.some((r) => r.toLowerCase() === addr)) {
98
+ return true;
99
+ }
100
+ if (policy.allowedRecipientDomains.some((d) => d.toLowerCase() === domain)) {
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Conditional cap increment. Returns false when the cap is already reached
108
+ * (ConditionalCheckFailedException) — the item is NOT mutated in that case, so
109
+ * the counter never exceeds the cap. Never read-modify-write.
110
+ */
111
+ async function tryIncrement(
112
+ dynamo: DynamoDBDocumentClient,
113
+ table: string,
114
+ key: { pk: string; sk: string },
115
+ cap: number,
116
+ ttl: number
117
+ ): Promise<boolean> {
118
+ try {
119
+ await dynamo.send(
120
+ new UpdateCommand({
121
+ TableName: table,
122
+ Key: key,
123
+ UpdateExpression:
124
+ "SET sends = if_not_exists(sends, :z) + :one, expiresAt = if_not_exists(expiresAt, :ttl)",
125
+ ConditionExpression: "attribute_not_exists(sends) OR sends < :cap",
126
+ ExpressionAttributeValues: {
127
+ ":z": 0,
128
+ ":one": 1,
129
+ ":cap": cap,
130
+ ":ttl": ttl,
131
+ },
132
+ })
133
+ );
134
+ return true;
135
+ } catch (error) {
136
+ if (isConditionalCheckFailed(error)) {
137
+ return false;
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Best-effort compensating decrement of a cap counter. Used when a later cap
145
+ * (daily) blocks a send after an earlier cap (hourly) already consumed a slot,
146
+ * so a daily-capped send never burns an hourly slot (COR-9). Guarded so the
147
+ * counter can never go negative; swallows errors — over-counting by one on a
148
+ * transient failure is acceptable, the TTL reaps the window anyway.
149
+ */
150
+ async function tryDecrement(
151
+ dynamo: DynamoDBDocumentClient,
152
+ table: string,
153
+ key: { pk: string; sk: string }
154
+ ): Promise<void> {
155
+ try {
156
+ await dynamo.send(
157
+ new UpdateCommand({
158
+ TableName: table,
159
+ Key: key,
160
+ UpdateExpression: "SET sends = sends - :one",
161
+ ConditionExpression: "attribute_exists(sends) AND sends > :z",
162
+ ExpressionAttributeValues: { ":one": 1, ":z": 0 },
163
+ })
164
+ );
165
+ } catch {
166
+ // Best-effort compensation only.
167
+ }
168
+ }
169
+
170
+ /** Caller identity bound to the invoke principal, never the payload (SEC-2). */
171
+ type Caller = { kind: "agent"; agentId: string } | { kind: "platform" };
172
+
173
+ const AGENT_QUALIFIER_PREFIX = "agent-";
174
+
175
+ /**
176
+ * Resolve the caller from the Lambda alias qualifier in `invokedFunctionArn`
177
+ * (`arn:aws:lambda:region:acct:function:name[:qualifier]`), NOT from the invoke
178
+ * payload — a payload `agentId` is forgeable and grants nothing (SEC-2).
179
+ * - qualifier `agent-<id>` → that agent (may `send`/`status`).
180
+ * - no qualifier or `$LATEST` → the platform assume-role (may `execute`).
181
+ */
182
+ function resolveCaller(context?: { invokedFunctionArn?: string }): Caller {
183
+ const arn = context?.invokedFunctionArn ?? "";
184
+ const parts = arn.split(":");
185
+ const qualifier = parts.length >= 8 ? parts[7] : undefined;
186
+ if (qualifier?.startsWith(AGENT_QUALIFIER_PREFIX)) {
187
+ return {
188
+ kind: "agent",
189
+ agentId: qualifier.slice(AGENT_QUALIFIER_PREFIX.length),
190
+ };
191
+ }
192
+ return { kind: "platform" };
193
+ }
194
+
195
+ const UNAUTHORIZED: EnforcerResponse = {
196
+ status: "blocked",
197
+ reason: "unauthorized action for caller",
198
+ };
199
+
200
+ /**
201
+ * Send a single email through SES v2.
202
+ *
203
+ * TODO: unify with @wraps/email-send — this mirrors sendEmail() in
204
+ * packages/email-send/src/send.ts:55-89 (Simple content, configuration set,
205
+ * EmailTags). It is inlined because @wraps/core cannot import the workspace
206
+ * package from a bundled Lambda. Keep the fields identical.
207
+ */
208
+ async function sendViaSes(
209
+ sesClient: SESv2Client,
210
+ payload: AgentEmailPayload,
211
+ agentId: string,
212
+ configSet: string
213
+ ): Promise<string> {
214
+ const response = await sesClient.send(
215
+ new SendEmailCommand({
216
+ FromEmailAddress: payload.from,
217
+ Destination: { ToAddresses: [payload.to] },
218
+ Content: {
219
+ Simple: {
220
+ Subject: { Data: payload.subject },
221
+ Body: {
222
+ Html: { Data: payload.html },
223
+ Text: { Data: payload.text },
224
+ },
225
+ },
226
+ },
227
+ ConfigurationSetName: configSet,
228
+ EmailTags: [{ Name: "agentId", Value: agentId }],
229
+ })
230
+ );
231
+ if (!response.MessageId) {
232
+ throw new Error("SES SendEmail returned no MessageId");
233
+ }
234
+ return response.MessageId;
235
+ }
236
+
237
+ /**
238
+ * Enforcer handler factory. Tests inject mocked clients; the default export
239
+ * wires real AWS clients. Check ordering is sacred: kill flag FIRST, allowlist
240
+ * SECOND, caps THIRD.
241
+ */
242
+ export function createHandler(deps: EnforcerDeps) {
243
+ const { dynamo, sesClient } = deps;
244
+ const fetchImpl = deps.fetchImpl ?? fetch;
245
+
246
+ async function flag(
247
+ agentId: string,
248
+ payload: AgentEmailPayload,
249
+ reason: string
250
+ ): Promise<EnforcerResponse> {
251
+ const apiUrl = requireEnv("WRAPS_API_URL");
252
+ const secret = requireEnv("WRAPS_AGENT_WEBHOOK_SECRET");
253
+ const response = await fetchImpl(`${apiUrl}/v1/agents/webhook`, {
254
+ method: "POST",
255
+ headers: {
256
+ "content-type": "application/json",
257
+ "x-wraps-agent-key": secret,
258
+ },
259
+ body: JSON.stringify({
260
+ agentId,
261
+ event: "pending_approval",
262
+ payload,
263
+ reason,
264
+ }),
265
+ });
266
+ if (!response.ok) {
267
+ throw new Error(`agent webhook failed: ${response.status}`);
268
+ }
269
+ const data = (await response.json()) as { approvalId?: string };
270
+ return { status: "pending_approval", approvalId: data.approvalId, reason };
271
+ }
272
+
273
+ /**
274
+ * @param agentId - the caller-bound identity (from the Lambda alias, SEC-2),
275
+ * NOT `event.agentId`; all CONFIG/counter/webhook keys use it.
276
+ */
277
+ async function handleSend(
278
+ table: string,
279
+ agentId: string,
280
+ event: EnforcerRequest
281
+ ): Promise<EnforcerResponse> {
282
+ const payload = event.payload;
283
+ if (!payload) {
284
+ return { status: "failed", reason: "missing payload" };
285
+ }
286
+ // Enforced mode is single-recipient; reject anything not a lone address (COR-6).
287
+ if (!isSingleEmail(payload.to)) {
288
+ return { status: "blocked", reason: "invalid recipient" };
289
+ }
290
+
291
+ const config = await loadConfig(dynamo, table, agentId);
292
+ // 1. Kill-switch + policy existence FIRST.
293
+ if (!config) {
294
+ return { status: "blocked", reason: "unknown agent" };
295
+ }
296
+ if (config.killed) {
297
+ return { status: "blocked", reason: "killed" };
298
+ }
299
+
300
+ // 2. Sender pinning: the agent may only send as its own address (SEC-3).
301
+ if (!isOwnSender(payload.from, config.emailAddress)) {
302
+ return {
303
+ status: "blocked",
304
+ reason: "from must be the agent's own address",
305
+ };
306
+ }
307
+
308
+ // 3. Recipient allowlist (before consuming any cap).
309
+ if (!isAllowed(payload.to, config.policy)) {
310
+ return flag(agentId, payload, "recipient not on allowlist");
311
+ }
312
+
313
+ // 4. Caps. cap<=0 means "block every send" — flag BEFORE any counter
314
+ // write, never one free send (COR-7 / convention 6).
315
+ if (config.policy.maxPerHour <= 0) {
316
+ return flag(agentId, payload, "hourly cap reached");
317
+ }
318
+ if (config.policy.maxPerDay <= 0) {
319
+ return flag(agentId, payload, "daily cap reached");
320
+ }
321
+
322
+ const now = new Date();
323
+ const ttl = Math.floor(now.getTime() / 1000) + TTL_SECONDS;
324
+ const hourKey = hourCounterKey(agentId, isoHourWindow(now));
325
+ const hourOk = await tryIncrement(
326
+ dynamo,
327
+ table,
328
+ hourKey,
329
+ config.policy.maxPerHour,
330
+ ttl
331
+ );
332
+ if (!hourOk) {
333
+ return flag(agentId, payload, "hourly cap reached");
334
+ }
335
+ const dayOk = await tryIncrement(
336
+ dynamo,
337
+ table,
338
+ dayCounterKey(agentId, isoDateWindow(now)),
339
+ config.policy.maxPerDay,
340
+ ttl
341
+ );
342
+ if (!dayOk) {
343
+ // The hourly slot was consumed above but the send never happens — give it
344
+ // back so a daily-capped send doesn't burn an hourly slot (COR-9).
345
+ await tryDecrement(dynamo, table, hourKey);
346
+ return flag(agentId, payload, "daily cap reached");
347
+ }
348
+
349
+ const configSet = process.env.CONFIG_SET || DEFAULT_CONFIG_SET;
350
+ try {
351
+ const messageId = await sendViaSes(
352
+ sesClient,
353
+ payload,
354
+ agentId,
355
+ configSet
356
+ );
357
+ return { status: "sent", messageId };
358
+ } catch (error) {
359
+ return { status: "failed", reason: String(error) };
360
+ }
361
+ }
362
+
363
+ async function writeOutcome(
364
+ table: string,
365
+ approvalId: string,
366
+ agentId: string,
367
+ outcome: EnforcerResponse
368
+ ): Promise<void> {
369
+ await dynamo.send(
370
+ new PutCommand({
371
+ TableName: table,
372
+ Item: {
373
+ ...outcomeItemKey(approvalId),
374
+ agentId,
375
+ status: outcome.status,
376
+ messageId: outcome.messageId,
377
+ reason: outcome.reason,
378
+ expiresAt: Math.floor(Date.now() / 1000) + TTL_SECONDS,
379
+ },
380
+ })
381
+ );
382
+ }
383
+
384
+ async function handleExecute(
385
+ table: string,
386
+ event: EnforcerRequest
387
+ ): Promise<EnforcerResponse> {
388
+ const payload = event.payload;
389
+ const approvalId = event.approvalId;
390
+ if (!(payload && approvalId)) {
391
+ return { status: "failed", reason: "missing payload or approvalId" };
392
+ }
393
+
394
+ // Idempotency: a previously recorded outcome wins — never resend (SEC-4/
395
+ // COR-4 defense, convention 4). Approve-retry replays land here.
396
+ const existing = await dynamo.send(
397
+ new GetCommand({ TableName: table, Key: outcomeItemKey(approvalId) })
398
+ );
399
+ if (existing.Item) {
400
+ return {
401
+ status: existing.Item.status,
402
+ messageId: existing.Item.messageId,
403
+ reason: existing.Item.reason,
404
+ };
405
+ }
406
+
407
+ // Re-check the kill-switch: an operator approval must not outrun a kill.
408
+ const config = await loadConfig(dynamo, table, event.agentId);
409
+ if (!config || config.killed) {
410
+ return { status: "blocked", reason: "killed" };
411
+ }
412
+
413
+ // Sender pinning applies to approved sends too (SEC-3).
414
+ if (!isOwnSender(payload.from, config.emailAddress)) {
415
+ return {
416
+ status: "blocked",
417
+ reason: "from must be the agent's own address",
418
+ };
419
+ }
420
+ if (!isSingleEmail(payload.to)) {
421
+ return { status: "blocked", reason: "invalid recipient" };
422
+ }
423
+
424
+ const configSet = process.env.CONFIG_SET || DEFAULT_CONFIG_SET;
425
+ try {
426
+ const messageId = await sendViaSes(
427
+ sesClient,
428
+ payload,
429
+ event.agentId,
430
+ configSet
431
+ );
432
+ const outcome: EnforcerResponse = { status: "sent", messageId };
433
+ await writeOutcome(table, approvalId, event.agentId, outcome);
434
+ return outcome;
435
+ } catch (error) {
436
+ const outcome: EnforcerResponse = {
437
+ status: "failed",
438
+ reason: String(error),
439
+ };
440
+ await writeOutcome(table, approvalId, event.agentId, outcome);
441
+ return outcome;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * @param agentId - the caller-bound identity (SEC-2). A stored outcome is only
447
+ * revealed when it belongs to this agent; any mismatch reads as `unknown`
448
+ * so one agent can't probe another's approvalId (SEC-9).
449
+ */
450
+ async function handleStatus(
451
+ table: string,
452
+ agentId: string,
453
+ event: EnforcerRequest
454
+ ): Promise<EnforcerResponse> {
455
+ const approvalId = event.approvalId;
456
+ if (!approvalId) {
457
+ return { status: "failed", reason: "missing approvalId" };
458
+ }
459
+ const res = await dynamo.send(
460
+ new GetCommand({ TableName: table, Key: outcomeItemKey(approvalId) })
461
+ );
462
+ if (!res.Item || res.Item.agentId !== agentId) {
463
+ return { status: "unknown" };
464
+ }
465
+ return {
466
+ status: res.Item.status,
467
+ messageId: res.Item.messageId,
468
+ reason: res.Item.reason,
469
+ };
470
+ }
471
+
472
+ return async function handler(
473
+ event: EnforcerRequest,
474
+ context?: { invokedFunctionArn?: string }
475
+ ): Promise<EnforcerResponse> {
476
+ const table = requireEnv("POLICY_TABLE");
477
+ const caller = resolveCaller(context);
478
+ switch (event.action) {
479
+ case "send":
480
+ return caller.kind === "agent"
481
+ ? handleSend(table, caller.agentId, event)
482
+ : UNAUTHORIZED;
483
+ case "status":
484
+ return caller.kind === "agent"
485
+ ? handleStatus(table, caller.agentId, event)
486
+ : UNAUTHORIZED;
487
+ case "execute":
488
+ return caller.kind === "platform"
489
+ ? handleExecute(table, event)
490
+ : UNAUTHORIZED;
491
+ default:
492
+ return { status: "failed", reason: `unknown action: ${event.action}` };
493
+ }
494
+ };
495
+ }
496
+
497
+ const awsDefaults = {
498
+ requestHandler: new NodeHttpHandler({
499
+ requestTimeout: 10_000,
500
+ connectionTimeout: 5000,
501
+ }),
502
+ maxAttempts: 5,
503
+ };
504
+
505
+ const defaultDynamo = DynamoDBDocumentClient.from(
506
+ new DynamoDBClient(awsDefaults)
507
+ );
508
+ const defaultSes = new SESv2Client(awsDefaults);
509
+
510
+ /** Lambda entry point (index.handler) wired to real AWS clients. */
511
+ export const handler = createHandler({
512
+ dynamo: defaultDynamo,
513
+ sesClient: defaultSes,
514
+ });
@@ -1 +1 @@
1
- Built at: 2026-07-09T20:58:38.886Z
1
+ Built at: 2026-07-13T15:42:03.222Z
@@ -1 +1 @@
1
- Built at: 2026-07-09T20:58:39.035Z
1
+ Built at: 2026-07-13T15:42:03.375Z
@@ -1 +1 @@
1
- Built at: 2026-07-09T20:58:38.941Z
1
+ Built at: 2026-07-13T15:42:03.283Z
@@ -0,0 +1,42 @@
1
+ CREATE TYPE "public"."agent_approval_status" AS ENUM('PENDING', 'APPROVED', 'REJECTED', 'SENT', 'FAILED');--> statement-breakpoint
2
+ CREATE TYPE "public"."agent_status" AS ENUM('ACTIVE', 'KILLED');--> statement-breakpoint
3
+ CREATE TABLE "agent" (
4
+ "id" text PRIMARY KEY NOT NULL,
5
+ "organization_id" text NOT NULL,
6
+ "name" text NOT NULL,
7
+ "email_address" text NOT NULL,
8
+ "domain" text NOT NULL,
9
+ "status" "agent_status" DEFAULT 'ACTIVE' NOT NULL,
10
+ "policy" jsonb NOT NULL,
11
+ "credential_user_arn" text,
12
+ "enforcer_function_arn" text,
13
+ "aws_account_id" text,
14
+ "created_by" text,
15
+ "created_at" timestamp DEFAULT now() NOT NULL,
16
+ "updated_at" timestamp DEFAULT now() NOT NULL
17
+ );
18
+ --> statement-breakpoint
19
+ CREATE TABLE "agent_approval_queue" (
20
+ "id" text PRIMARY KEY NOT NULL,
21
+ "organization_id" text NOT NULL,
22
+ "agent_id" text NOT NULL,
23
+ "payload" jsonb NOT NULL,
24
+ "reason" text,
25
+ "status" "agent_approval_status" DEFAULT 'PENDING' NOT NULL,
26
+ "decided_by" text,
27
+ "decided_at" timestamp,
28
+ "message_id" text,
29
+ "error_message" text,
30
+ "created_at" timestamp DEFAULT now() NOT NULL,
31
+ "updated_at" timestamp DEFAULT now() NOT NULL
32
+ );
33
+ --> statement-breakpoint
34
+ ALTER TABLE "agent" ADD CONSTRAINT "agent_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
35
+ ALTER TABLE "agent" ADD CONSTRAINT "agent_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
36
+ ALTER TABLE "agent_approval_queue" ADD CONSTRAINT "agent_approval_queue_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
37
+ ALTER TABLE "agent_approval_queue" ADD CONSTRAINT "agent_approval_queue_agent_id_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
38
+ ALTER TABLE "agent_approval_queue" ADD CONSTRAINT "agent_approval_queue_decided_by_user_id_fk" FOREIGN KEY ("decided_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
39
+ CREATE INDEX "agent_org_idx" ON "agent" USING btree ("organization_id");--> statement-breakpoint
40
+ CREATE UNIQUE INDEX "agent_unique_org_name_idx" ON "agent" USING btree ("organization_id","name");--> statement-breakpoint
41
+ CREATE INDEX "agent_approval_queue_org_idx" ON "agent_approval_queue" USING btree ("organization_id");--> statement-breakpoint
42
+ CREATE INDEX "agent_approval_queue_org_status_idx" ON "agent_approval_queue" USING btree ("organization_id","status");
@@ -0,0 +1,2 @@
1
+ CREATE UNIQUE INDEX "agent_unique_org_email_idx" ON "agent" USING btree ("organization_id","email_address");--> statement-breakpoint
2
+ CREATE INDEX "agent_approval_queue_agent_idx" ON "agent_approval_queue" USING btree ("agent_id");