@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.
- package/dist/api-lambda.zip +0 -0
- package/dist/cli.js +7380 -6262
- 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,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-
|
|
1
|
+
Built at: 2026-07-13T15:42:03.222Z
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Built at: 2026-07-
|
|
1
|
+
Built at: 2026-07-13T15:42:03.375Z
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Built at: 2026-07-
|
|
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");
|