Skip to content

Commit 81f26a4

Browse files
committed
feat(cedar-hitl): Cedar-wasm layer + wire approval tables into agent stack
Activates the agent-side approval path and ships the Lambda layer Chunk 5's REST handlers need. Cedar-wasm Lambda layer (§15.2 task 10) ---------------------------------------- ``CedarWasmLayer`` bundles ``@cedar-policy/cedar-wasm@4.10.0`` into ``/opt/nodejs/node_modules/`` so Lambdas can ``require('@cedar-policy/cedar-wasm/nodejs')`` without shipping the 4 MB wasm binary in every function package. A dedicated ``cdk/layers/cedar-wasm/`` directory carries a minimal ``package.json`` pinning the exact version — bundling runs ``npm install --omit=dev`` against that manifest, so the layer build is hermetic from any ``cdk/node_modules/`` drift. The bundler has two fallbacks: - Docker (``public.ecr.aws/sam/build-nodejs22.x``) for CI / prod deploys. - Local-npm fallback for environments without Docker (unit-test synths + `cdk synth` on runners that lack Docker). The local path is safe here because the layer ships pure JS + a prebuilt wasm binary — no native build step. Three constants exposed from the module: - ``CEDAR_WASM_VERSION`` — single source of truth for the pinned version; tests assert this matches both ``cdk/package.json`` and the layer manifest, so the three places the version lives stay in sync. - ``CEDAR_WASM_MIN_LAMBDA_MEMORY_MB`` — 512 MB floor for attaching Lambdas per §15.2 task 10. - ``CedarWasmLayer.layer`` — the underlying ``LayerVersion`` for Chunk 5 handlers to attach via ``fn.addLayers(...)``. Agent stack wiring (§15.2 task 19) ------------------------------------ ``agent.ts`` now instantiates: - ``TaskApprovalsTable`` (prior commit) — grants RW to the runtime so ``pre_tool_use_hook`` can TransactWriteItems + ConsistentRead the PENDING row. - ``SlackUserMappingTable`` (prior commit) — not granted to the runtime; only the link-user Lambda (Chunk 5) writes here. - ``CedarWasmLayer`` — the layer's asset lands in the synthed template so Chunk 5 handlers can reference ``.layer`` without causing a new asset on their deploy. New runtime env vars: - ``TASK_APPROVALS_TABLE_NAME`` — consumed by ``task_state._require_tables``; its absence previously raised ``ApprovalTablesUnavailable`` → hook DENY. Now set, so the approval path is live on deploy. - ``AGENTCORE_MAX_LIFETIME_S = '28800'`` — 8 hours, matching ``lifecycleConfiguration.maxLifetime``. Consumed by the hook's ``_remaining_maxlifetime_s`` for the maxLifetime ceiling clip (§6.5). Kept in sync with the lifecycle via a direct test assertion so drift surfaces at build time. New CfnOutputs: ``TaskApprovalsTableName``, ``SlackUserMappingTableName``, ``CedarWasmLayerArn``. Each is useful for post-deploy smoke tests (`aws dynamodb describe-table` / `aws lambda get-layer-version`). Tests: +8 layer tests + 9 agent-stack assertions. Layer: - LayerVersion resource count. - Compatible runtimes (nodejs20/22). - Description carries the pinned version. - CEDAR_WASM_VERSION matches ``cdk/package.json``. - CEDAR_WASM_VERSION matches ``layers/cedar-wasm/package.json``. - CEDAR_WASM_MIN_LAMBDA_MEMORY_MB ≥ 512. - Custom description override works. - ``.layer`` exposes a real ``LayerVersion``. Agent stack: - Table count updated from 6 → 8. - TaskApprovalsTable schema match (task_id PK / request_id SK, user_id-status-index GSI presence). - SlackUserMappingTable single-key schema. - LayerVersion count + compatibleRuntimes. - Three new CfnOutputs present. - TASK_APPROVALS_TABLE_NAME env var on the runtime. - AGENTCORE_MAX_LIFETIME_S == '28800' (drift guard). Carry-forward ------------- - ``TASK_STARTED_AT`` is the other input the hook's ``_remaining_maxlifetime_s`` consumes — it's a PER-TASK value the orchestrator must stamp at invocation time, not a stack-level env var. Chunk 5's orchestrator changes need to add it to the runtime invocation payload / session env. For now the hook's fallback ("unknown, don't clip") keeps approvals functional. - Chunk 5 will attach the CedarWasmLayer onto ApproveTaskFn, DenyTaskFn, GetPoliciesFn, CreateTaskFn and assert ``memorySize >= CEDAR_WASM_MIN_LAMBDA_MEMORY_MB`` for each.
1 parent c149993 commit 81f26a4

6 files changed

Lines changed: 438 additions & 2 deletions

File tree

cdk/layers/cedar-wasm/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Cedar-wasm Lambda layer source
2+
3+
This directory is consumed by
4+
[`cdk/src/constructs/cedar-wasm-layer.ts`](../../src/constructs/cedar-wasm-layer.ts)
5+
to build a Lambda layer that carries `@cedar-policy/cedar-wasm`.
6+
7+
## Why a separate directory
8+
9+
The layer build is hermetic: it runs `npm install --omit=dev` from
10+
`package.json` in this directory, independent of
11+
`cdk/node_modules/`. Keeping a dedicated `package.json` here means a
12+
stale top-level install cannot leak into the layer bundle.
13+
14+
## Keeping the version in sync
15+
16+
The pinned version here **must match** the `@cedar-policy/cedar-wasm`
17+
entry in `cdk/package.json`. Drift would let the Lambda-side wasm
18+
engine and the rest of the CDK package disagree — the parity contract
19+
(§15.6, decision #23) catches this in CI only for the wasm-vs-cedarpy
20+
direction, not for two wasm copies in the same stack.
21+
22+
Update process when bumping cedar-wasm:
23+
24+
1. Bump `cdk/package.json` in the repo root install.
25+
2. Bump `cdk/layers/cedar-wasm/package.json` to the same version.
26+
3. Bump `CEDAR_WASM_VERSION` in `cdk/src/constructs/cedar-wasm-layer.ts`.
27+
4. Run `contracts/cedar-parity/` tests on both sides; they fail if the
28+
wasm vs cedarpy engine outputs diverge.
29+
30+
## Layout produced
31+
32+
```
33+
/opt/nodejs/node_modules/@cedar-policy/cedar-wasm/
34+
nodejs/cedar_wasm.js
35+
nodejs/cedar_wasm_bg.wasm
36+
...
37+
```
38+
39+
Lambdas using the layer `require('@cedar-policy/cedar-wasm/nodejs')`.

cdk/layers/cedar-wasm/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "cedar-wasm-lambda-layer",
3+
"version": "1.0.0",
4+
"private": true,
5+
"description": "Lambda layer bundling @cedar-policy/cedar-wasm for Cedar HITL policy handlers. Pinned version must match cdk/package.json.",
6+
"dependencies": {
7+
"@cedar-policy/cedar-wasm": "4.10.0"
8+
}
9+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { execSync } from 'node:child_process';
21+
import * as fs from 'node:fs';
22+
import * as path from 'node:path';
23+
import { DockerImage } from 'aws-cdk-lib';
24+
import * as lambda from 'aws-cdk-lib/aws-lambda';
25+
import { Construct } from 'constructs';
26+
27+
/**
28+
* Pinned Cedar-wasm version — kept in sync with the top-level
29+
* ``cdk/package.json`` entry. Mismatch would let the agent-side
30+
* ``cedarpy`` engine and the Lambda-side ``cedar-wasm`` engine drift,
31+
* defeating the parity contract (design decision #23, §15.6).
32+
*
33+
* Reading this from a constant (rather than the caller passing it)
34+
* lets the tests assert we ship the right version without duplicating
35+
* the number across files.
36+
*/
37+
export const CEDAR_WASM_VERSION = '4.10.0';
38+
39+
/**
40+
* Minimum memory the Lambda attaching this layer should be configured
41+
* with. Cedar-wasm needs ≥512 MB headroom (§15.2 task 10 note); callers
42+
* wiring this layer onto a Lambda should cross-check their function's
43+
* memorySize against this constant.
44+
*/
45+
export const CEDAR_WASM_MIN_LAMBDA_MEMORY_MB = 512;
46+
47+
/**
48+
* Properties for CedarWasmLayer construct.
49+
*/
50+
export interface CedarWasmLayerProps {
51+
/**
52+
* Layer description. Defaults to a design-linked description so
53+
* ``aws lambda get-layer-version`` output is self-documenting.
54+
*/
55+
readonly description?: string;
56+
}
57+
58+
/**
59+
* Lambda layer bundling `@cedar-policy/cedar-wasm` for the REST-side
60+
* policy handlers (Chunk 5: ApproveTaskFn / DenyTaskFn / GetPoliciesFn /
61+
* CreateTaskFn).
62+
*
63+
* Layout produced:
64+
*
65+
* ```
66+
* /opt/nodejs/node_modules/@cedar-policy/cedar-wasm/
67+
* nodejs/cedar_wasm.js
68+
* nodejs/cedar_wasm_bg.wasm
69+
* ... (package.json + d.ts + README)
70+
* ```
71+
*
72+
* Lambdas attaching this layer ``require('@cedar-policy/cedar-wasm/nodejs')``
73+
* and load the wasm module transparently. The wasm binary is ~4 MB; a
74+
* shared layer keeps each individual function package small so cold
75+
* starts stay fast.
76+
*
77+
* Bundling uses ``npm install`` against a minimal ``package.json`` that
78+
* pins the exact ``CEDAR_WASM_VERSION``. The source-side ``cdk/package.json``
79+
* is the canonical pin; this construct re-declares the version string in
80+
* a layer-local manifest to keep bundling hermetic (no reliance on yarn
81+
* workspace resolution).
82+
*
83+
* Runtime compatibility is the union of Node.js 20 and 22 — older
84+
* runtimes lack the subtle WebAssembly support cedar-wasm relies on.
85+
*
86+
* See §15.2 task 10 and §15.6.
87+
*/
88+
export class CedarWasmLayer extends Construct {
89+
/**
90+
* The underlying Lambda layer. Attach to functions via
91+
* ``fn.addLayers(cedarWasmLayer.layer)``.
92+
*/
93+
public readonly layer: lambda.LayerVersion;
94+
95+
constructor(scope: Construct, id: string, props: CedarWasmLayerProps = {}) {
96+
super(scope, id);
97+
98+
// Bundling source: a small directory with a package.json that pins
99+
// cedar-wasm at CEDAR_WASM_VERSION. The bundling command does
100+
// `npm install --omit=dev` and lands the result under /opt/nodejs.
101+
//
102+
// Using an inline source directory (rather than depending on
103+
// cdk/package.json being resolved at bundle time) keeps this layer
104+
// fully hermetic: a stale `cdk/node_modules/` cannot leak into the
105+
// layer build.
106+
const layerSourceDir = path.join(__dirname, '..', '..', 'layers', 'cedar-wasm');
107+
108+
this.layer = new lambda.LayerVersion(this, 'Layer', {
109+
code: lambda.Code.fromAsset(layerSourceDir, {
110+
bundling: {
111+
image: DockerImage.fromRegistry('public.ecr.aws/sam/build-nodejs22.x:latest'),
112+
command: [
113+
'bash',
114+
'-c',
115+
[
116+
'cp -r . /asset-output',
117+
'mkdir -p /asset-output/nodejs',
118+
'cp /asset-output/package.json /asset-output/nodejs/package.json',
119+
'cd /asset-output/nodejs && npm install --omit=dev',
120+
'rm /asset-output/package.json',
121+
].join(' && '),
122+
],
123+
// Fall back to a local-npm bundle when Docker is unavailable
124+
// (e.g. `cdk synth` in CI runners that lack Docker). The
125+
// local hook mirrors the Docker commands using the host's
126+
// npm, which is acceptable here because the layer only ships
127+
// pure JS + a prebuilt wasm binary — no native build step.
128+
local: {
129+
tryBundle(outputDir: string): boolean {
130+
try {
131+
fs.cpSync(layerSourceDir, outputDir, { recursive: true });
132+
fs.mkdirSync(path.join(outputDir, 'nodejs'), { recursive: true });
133+
fs.copyFileSync(
134+
path.join(outputDir, 'package.json'),
135+
path.join(outputDir, 'nodejs', 'package.json'),
136+
);
137+
execSync('npm install --omit=dev', {
138+
cwd: path.join(outputDir, 'nodejs'),
139+
stdio: 'ignore',
140+
});
141+
fs.rmSync(path.join(outputDir, 'package.json'));
142+
return true;
143+
} catch {
144+
return false;
145+
}
146+
},
147+
},
148+
},
149+
}),
150+
compatibleRuntimes: [lambda.Runtime.NODEJS_20_X, lambda.Runtime.NODEJS_22_X],
151+
description:
152+
props.description
153+
?? `@cedar-policy/cedar-wasm@${CEDAR_WASM_VERSION} for Cedar HITL policy Lambdas (§15.2 task 10)`,
154+
});
155+
}
156+
}

cdk/src/stacks/agent.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@ import { Construct } from 'constructs';
3434
import { AgentMemory } from '../constructs/agent-memory';
3535
import { AgentVpc } from '../constructs/agent-vpc';
3636
import { Blueprint } from '../constructs/blueprint';
37+
import { CedarWasmLayer } from '../constructs/cedar-wasm-layer';
3738
import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler';
3839
import { DnsFirewall } from '../constructs/dns-firewall';
3940
import { FanOutConsumer } from '../constructs/fanout-consumer';
4041
import { RepoTable } from '../constructs/repo-table';
42+
import { SlackUserMappingTable } from '../constructs/slack-user-mapping-table';
4143
import { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler';
4244
// import { EcsAgentCluster } from '../constructs/ecs-agent-cluster';
4345
import { TaskApi } from '../constructs/task-api';
46+
import { TaskApprovalsTable } from '../constructs/task-approvals-table';
4447
import { TaskDashboard } from '../constructs/task-dashboard';
4548
import { TaskEventsTable } from '../constructs/task-events-table';
4649
import { TaskNudgesTable } from '../constructs/task-nudges-table';
@@ -62,10 +65,23 @@ export class AgentStack extends Stack {
6265
const taskTable = new TaskTable(this, 'TaskTable');
6366
const taskEventsTable = new TaskEventsTable(this, 'TaskEventsTable');
6467
const taskNudgesTable = new TaskNudgesTable(this, 'TaskNudgesTable');
68+
// Cedar HITL approval-gate state (design §10.1). Agent writes PENDING
69+
// rows + GSI query powers `bgagent pending`; Chunk 5 wires the
70+
// Approve/Deny Lambdas + fan-out consumer.
71+
const taskApprovalsTable = new TaskApprovalsTable(this, 'TaskApprovalsTable');
72+
// Cedar HITL Slack → Cognito mapping (§11.2). Written only via the
73+
// user-initiated OAuth link handler (Chunk 5).
74+
const slackUserMappingTable = new SlackUserMappingTable(this, 'SlackUserMappingTable');
6575
const userConcurrencyTable = new UserConcurrencyTable(this, 'UserConcurrencyTable');
6676
const webhookTable = new WebhookTable(this, 'WebhookTable');
6777
const repoTable = new RepoTable(this, 'RepoTable');
6878

79+
// Cedar-wasm Lambda layer (§15.2 task 10). Instantiated here so the
80+
// asset is in the synthed template; Chunk 5 handlers (Approve,
81+
// Deny, GetPolicies, CreateTask) attach the layer via
82+
// ``fn.addLayers(cedarWasmLayer.layer)``.
83+
const cedarWasmLayer = new CedarWasmLayer(this, 'CedarWasmLayer');
84+
6985
// --trace trajectory storage (design §10.1). Opt-in per task; only
7086
// written when the submit payload sets ``trace: true``.
7187
const traceArtifactsBucket = new TraceArtifactsBucket(this, 'TraceArtifactsBucket');
@@ -239,6 +255,15 @@ export class AgentStack extends Stack {
239255
TASK_TABLE_NAME: taskTable.table.tableName,
240256
TASK_EVENTS_TABLE_NAME: taskEventsTable.table.tableName,
241257
NUDGES_TABLE_NAME: taskNudgesTable.table.tableName,
258+
// Cedar HITL approval gates (§6.5). Agent's task_state primitives
259+
// use this to write PENDING rows + transition tasks to
260+
// AWAITING_APPROVAL; absent → hook fails closed with
261+
// ``approval_write_failed`` (the `ApprovalTablesUnavailable` path).
262+
TASK_APPROVALS_TABLE_NAME: taskApprovalsTable.table.tableName,
263+
// Hint for the hook's remaining-maxLifetime calculation (§6.5
264+
// pseudocode line 793). Kept in sync with the AgentCore
265+
// lifecycle configuration below so drift is visible. 8 hours.
266+
AGENTCORE_MAX_LIFETIME_S: '28800',
242267
USER_CONCURRENCY_TABLE_NAME: userConcurrencyTable.table.tableName,
243268
// --trace artifact store (§10.1). The agent writes the JSONL
244269
// trajectory to ``traces/<user_id>/<task_id>.jsonl.gz`` on
@@ -307,6 +332,12 @@ export class AgentStack extends Stack {
307332
taskTable.table.grantReadWriteData(runtime);
308333
taskEventsTable.table.grantReadWriteData(runtime);
309334
taskNudgesTable.table.grantReadWriteData(runtime);
335+
// Cedar HITL: the agent writes PENDING rows via TransactWriteItems
336+
// (cross-table with TaskTable), reads them with ConsistentRead during
337+
// the poll loop, and flips status to TIMED_OUT on deadline. The
338+
// grant must be RW because approve/deny Lambdas (Chunk 5) also
339+
// need RW; granting twice is idempotent.
340+
taskApprovalsTable.table.grantReadWriteData(runtime);
310341
userConcurrencyTable.table.grantReadWriteData(runtime);
311342
githubTokenSecret.grantRead(runtime);
312343
applicationLogGroup.grantWrite(runtime);
@@ -397,6 +428,21 @@ export class AgentStack extends Stack {
397428
description: 'Name of the DynamoDB task nudges table (Phase 2)',
398429
});
399430

431+
new CfnOutput(this, 'TaskApprovalsTableName', {
432+
value: taskApprovalsTable.table.tableName,
433+
description: 'Name of the DynamoDB task approvals table (Cedar HITL)',
434+
});
435+
436+
new CfnOutput(this, 'SlackUserMappingTableName', {
437+
value: slackUserMappingTable.table.tableName,
438+
description: 'Name of the DynamoDB slack_user_id → cognito_sub mapping table',
439+
});
440+
441+
new CfnOutput(this, 'CedarWasmLayerArn', {
442+
value: cedarWasmLayer.layer.layerVersionArn,
443+
description: 'ARN of the Cedar-wasm Lambda layer (consumed by Chunk 5 REST handlers)',
444+
});
445+
400446
new CfnOutput(this, 'UserConcurrencyTableName', {
401447
value: userConcurrencyTable.table.tableName,
402448
description: 'Name of the DynamoDB user concurrency table',

0 commit comments

Comments
 (0)