Skip to content

Commit d7fb4fb

Browse files
author
bgagent
committed
feat(cedar-hitl): get-pending + get-policies + link-slack-user handlers
Lands the three read/discovery handlers Chunk 6 (CLI) needs to power ``bgagent pending``, ``bgagent policies list/show``, and ``bgagent notifications configure slack``. Completes §15.2 tasks 14, 15, and 25 (handler side). Handlers -------- ``get-pending.ts`` (§7.7 — GET /v1/pending) - Queries ``user_id-status-index`` GSI on TaskApprovalsTable with ``user_id = :caller AND status = :pending``. Without the GSI this would be a full-table Scan per call — under ``watch -n1 bgagent pending`` that exhausts burst capacity for the whole fleet (§10.1 finding aws-samples#8). - Response maps each row to ``PendingApprovalSummary`` with a derived ``expires_at = created_at + timeout_s`` so the CLI can render time-to-timeout without doing arithmetic on ISO strings. - Severity coerced to ``medium`` on unknown values so GSI writes that drift from the enum don't break the list response. - Rate-limited 10/min/user (synthetic row on the same table, namespaced ``RATE#<user>#PENDING`` so it does not collide with the approve/deny counter). ``get-policies.ts`` (§7.6 — GET /v1/repos/{repo_id}/policies) - Combines ``BUILTIN_HARD_DENY_POLICIES`` + ``BUILTIN_SOFT_DENY_POLICIES`` with the repo's ``cedar_policies`` blueprint override. Runs the combined text through ``parseRules`` and returns ``{hard[], soft[]}`` rule summaries. - 5-minute per-repo in-Lambda cache; cold starts throw it away. ``_resetCacheForTests`` exposed for unit-test isolation. - Repo ID is URL-decoded from the path (``owner%2Frepo`` common in CLI UX). - Rate-limited 30/min/user. - Blueprint load failure falls back to built-ins with a WARN log; invalid blueprint cedar text returns 503 ``SERVICE_UNAVAILABLE`` rather than a misleading empty list. ``link-slack-user.ts`` (§11.2 finding aws-samples#4 — POST /v1/notifications/slack/link) - Writes to SlackUserMappingTable with ``ConditionExpression: attribute_not_exists(slack_user_id)``. This guard is the entire admission control the §11.2 design hinges on: even a compromised Slack admin cannot overwrite an existing mapping. - Validates ``slack_user_id`` shape (letters, digits, underscores, 2–40 chars) so junk rows cannot land. - Conflict surface is 409 ``REQUEST_ALREADY_DECIDED`` — reused error code (the payload message directs the user to unlink via support). - Slack link_token end-to-end validation against Slack OAuth is deferred — v1 accepts the token on trust from the Cognito-authed caller; it is persisted in CloudWatch for audit. Supporting primitives --------------------- ``shared/builtin-policies.ts`` — mirrors ``agent/policies/hard_deny.cedar`` and ``agent/policies/soft_deny.cedar`` as TypeScript string constants. Embedded rather than read from disk because Lambda's esbuild bundler does not copy non-TS assets by default and a dedicated bundling hook is more code than the embed. A drift test (``builtin-policies.test.ts``) asserts byte-equality with the agent files so any change on one side without the other flips red at build time. ``shared/cedar-policy.ts`` — ``parseRules`` now skips the unannotated ``base_permit`` entry (both tiers need it as a Cedar catch-all; it is not a user-facing rule so it stays out of ParsedRule[]). This matches the agent-side ``_parse_policy_annotations`` behaviour. Tests: +37 total. - get-pending (8): 401 on missing auth, 429 on rate limit, empty result, GSI query shape, row → PendingApprovalSummary with derived expires_at, severity fallback, missing timeout → expires_at falls back to created_at, 500 on DDB error. - get-policies (11): 401/400 validation, built-in rules listed on empty repo, URL-decoded repo path, custom blueprint rule lands in soft, per-repo cache across calls, 429 rate limit, 503 on invalid blueprint cedar, fallback on load failure, hard rules omit severity / approval_timeout_s, soft rules carry them. - link-slack-user (8): 401/400 validation, shape check, 201 on success, 409 on overwrite attempt, 500 on unknown DDB error, whitespace trim on slack_user_id, ConditionExpression verified. - builtin-policies (4): drift byte-equality with both agent files, parseRules round-trip for hard/soft rule IDs. - cedar-policy (updated): ``base_permit`` is skipped from ParsedRule[] rather than rejected. Stack wiring (task-api.ts routes, agent.ts layer attachment, CreateTaskFn extension, orchestrator + reconciler + fanout) lands in the next commit.
1 parent ff74484 commit d7fb4fb

9 files changed

Lines changed: 1377 additions & 1 deletion

File tree

cdk/src/handlers/get-pending.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 { DynamoDBClient } from '@aws-sdk/client-dynamodb';
21+
import { DynamoDBDocumentClient, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
22+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
23+
import { ulid } from 'ulid';
24+
import { extractUserId } from './shared/gateway';
25+
import { logger } from './shared/logger';
26+
import { ErrorCode, errorResponse, successResponse } from './shared/response';
27+
import type { GetPendingResponse, PendingApprovalSummary } from './shared/types';
28+
29+
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
30+
const TASK_APPROVALS_TABLE_NAME = process.env.TASK_APPROVALS_TABLE_NAME;
31+
if (!TASK_APPROVALS_TABLE_NAME) {
32+
throw new Error('get-pending handler requires TASK_APPROVALS_TABLE_NAME env var');
33+
}
34+
const USER_STATUS_INDEX_NAME = process.env.USER_STATUS_INDEX_NAME ?? 'user_id-status-index';
35+
const PENDING_RATE_LIMIT_PER_MINUTE = Number(process.env.PENDING_RATE_LIMIT_PER_MINUTE ?? '10');
36+
const PENDING_LIST_LIMIT = 100;
37+
38+
/**
39+
* GET /v1/pending — List pending approvals owned by the caller (§7.7).
40+
*
41+
* Backed by the `user_id-status-index` GSI on `TaskApprovalsTable`.
42+
* Without the GSI a Scan would touch every approval row on every
43+
* `watch -n1 bgagent pending` call and exhaust DDB burst capacity for
44+
* the whole fleet (§10.1 finding #8).
45+
*
46+
* Rate-limited 10/min/user to belt-and-suspenders the GSI — runaway
47+
* polling from a single user is capped before it touches the GSI at
48+
* all.
49+
*
50+
* Response contains a `pending[]` of summaries, each with
51+
* `expires_at` derived from `created_at + timeout_s` so the CLI can
52+
* render time-to-timeout without the user doing the math.
53+
*/
54+
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
55+
const requestId = ulid();
56+
57+
try {
58+
const userId = extractUserId(event);
59+
if (!userId) {
60+
return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId);
61+
}
62+
63+
const nowEpoch = Math.floor(Date.now() / 1000);
64+
65+
// Per-user per-minute rate limit. Uses a synthetic row on the
66+
// approvals table with `RATE#<user_id>#PENDING` PK to avoid
67+
// colliding with the approve/deny counter (same table but
68+
// different PK namespace).
69+
const minuteBucket = formatMinuteBucket(new Date());
70+
try {
71+
await ddb.send(new UpdateCommand({
72+
TableName: TASK_APPROVALS_TABLE_NAME,
73+
Key: {
74+
task_id: `RATE#${userId}#PENDING`,
75+
request_id: `MINUTE#${minuteBucket}`,
76+
},
77+
UpdateExpression: 'ADD #count :one SET #ttl = :ttl',
78+
ConditionExpression: 'attribute_not_exists(#count) OR #count < :max',
79+
ExpressionAttributeNames: {
80+
'#count': 'count',
81+
'#ttl': 'ttl',
82+
},
83+
ExpressionAttributeValues: {
84+
':one': 1,
85+
':max': PENDING_RATE_LIMIT_PER_MINUTE,
86+
':ttl': nowEpoch + 120,
87+
},
88+
}));
89+
} catch (err: unknown) {
90+
const name = (err as { name?: string })?.name;
91+
if (name === 'ConditionalCheckFailedException') {
92+
return errorResponse(
93+
429,
94+
ErrorCode.RATE_LIMIT_EXCEEDED,
95+
`Rate limit exceeded: at most ${PENDING_RATE_LIMIT_PER_MINUTE} pending-list queries per minute.`,
96+
requestId,
97+
);
98+
}
99+
throw err;
100+
}
101+
102+
const result = await ddb.send(new QueryCommand({
103+
TableName: TASK_APPROVALS_TABLE_NAME,
104+
IndexName: USER_STATUS_INDEX_NAME,
105+
KeyConditionExpression: 'user_id = :user AND #status = :pending',
106+
ExpressionAttributeNames: { '#status': 'status' },
107+
ExpressionAttributeValues: {
108+
':user': userId,
109+
':pending': 'PENDING',
110+
},
111+
Limit: PENDING_LIST_LIMIT,
112+
}));
113+
114+
const items = (result.Items ?? []) as ReadonlyArray<Record<string, unknown>>;
115+
const pending: PendingApprovalSummary[] = items.map((row) => {
116+
const created_at = String(row.created_at ?? '');
117+
const timeout_s = Number(row.timeout_s ?? 0);
118+
const expires_at = computeExpiresAt(created_at, timeout_s);
119+
return {
120+
task_id: String(row.task_id ?? ''),
121+
request_id: String(row.request_id ?? ''),
122+
tool_name: String(row.tool_name ?? ''),
123+
tool_input_preview: String(row.tool_input_preview ?? ''),
124+
severity: coerceSeverity(row.severity),
125+
reason: String(row.reason ?? ''),
126+
created_at,
127+
timeout_s,
128+
expires_at,
129+
};
130+
});
131+
132+
logger.info('Pending approvals listed', {
133+
user_id: userId,
134+
count: pending.length,
135+
request_id: requestId,
136+
});
137+
138+
const response: GetPendingResponse = { pending };
139+
return successResponse(200, response, requestId);
140+
} catch (err) {
141+
logger.error('Failed to list pending approvals', {
142+
error: err instanceof Error ? err.message : String(err),
143+
request_id: requestId,
144+
});
145+
return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId);
146+
}
147+
}
148+
149+
function coerceSeverity(value: unknown): 'low' | 'medium' | 'high' {
150+
if (value === 'low' || value === 'medium' || value === 'high') {
151+
return value;
152+
}
153+
return 'medium';
154+
}
155+
156+
function computeExpiresAt(createdAt: string, timeoutS: number): string {
157+
if (!createdAt || !Number.isFinite(timeoutS) || timeoutS <= 0) {
158+
return createdAt;
159+
}
160+
const created = Date.parse(createdAt);
161+
if (Number.isNaN(created)) {
162+
return createdAt;
163+
}
164+
return new Date(created + timeoutS * 1000).toISOString();
165+
}
166+
167+
function formatMinuteBucket(date: Date): string {
168+
const y = date.getUTCFullYear().toString().padStart(4, '0');
169+
const m = (date.getUTCMonth() + 1).toString().padStart(2, '0');
170+
const d = date.getUTCDate().toString().padStart(2, '0');
171+
const h = date.getUTCHours().toString().padStart(2, '0');
172+
const mi = date.getUTCMinutes().toString().padStart(2, '0');
173+
return `${y}${m}${d}${h}${mi}`;
174+
}

cdk/src/handlers/get-policies.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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 { DynamoDBClient } from '@aws-sdk/client-dynamodb';
21+
import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';
22+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
23+
import { ulid } from 'ulid';
24+
import {
25+
BUILTIN_HARD_DENY_POLICIES,
26+
BUILTIN_SOFT_DENY_POLICIES,
27+
} from './shared/builtin-policies';
28+
import { CedarPolicyParseError, concatPolicies, parseRules } from './shared/cedar-policy';
29+
import { extractUserId } from './shared/gateway';
30+
import { logger } from './shared/logger';
31+
import { loadRepoConfig } from './shared/repo-config';
32+
import { ErrorCode, errorResponse, successResponse } from './shared/response';
33+
import type { GetPoliciesResponse, PolicyRuleSummary } from './shared/types';
34+
35+
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
36+
const TASK_APPROVALS_TABLE_NAME = process.env.TASK_APPROVALS_TABLE_NAME;
37+
const POLICIES_RATE_LIMIT_PER_MINUTE = Number(process.env.POLICIES_RATE_LIMIT_PER_MINUTE ?? '30');
38+
39+
// In-Lambda cache keyed by repo; 5 minutes. Keeps repeated `bgagent
40+
// policies list` calls snappy without hitting DDB + re-parsing the
41+
// policy set every time. Cold starts throw the cache away.
42+
const CACHE_TTL_MS = 5 * 60 * 1000;
43+
interface CacheEntry {
44+
readonly response: GetPoliciesResponse;
45+
readonly expiresAt: number;
46+
}
47+
const cache = new Map<string, CacheEntry>();
48+
49+
/**
50+
* GET /v1/repos/{repo_id}/policies — List Cedar rules for a repo (§7.6).
51+
*
52+
* Response combines the built-in hard/soft policy sets with any
53+
* `cedar_policies` the repo's blueprint has registered. Each rule is
54+
* rendered as a `{rule_id, category, severity?, approval_timeout_s?,
55+
* summary}` envelope.
56+
*
57+
* Rate-limited 30/min/user — generous for UX (`bgagent policies list`
58+
* is an interactive lookup) but bounded so a runaway script cannot
59+
* hammer the shared Cedar parser.
60+
*
61+
* `repo_id` is URL-decoded from the path parameter (`owner%2Frepo`
62+
* encoding is common in CLI UX).
63+
*/
64+
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
65+
const requestId = ulid();
66+
67+
try {
68+
const userId = extractUserId(event);
69+
if (!userId) {
70+
return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId);
71+
}
72+
73+
const rawRepoId = event.pathParameters?.repo_id;
74+
if (!rawRepoId) {
75+
return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Missing repo_id path parameter.', requestId);
76+
}
77+
const repoId = decodeURIComponent(rawRepoId);
78+
79+
// Rate-limit. Uses a synthetic row on the approvals table keyed on
80+
// `RATE#<user_id>#POLICIES` to avoid colliding with the approve /
81+
// pending counters.
82+
if (TASK_APPROVALS_TABLE_NAME) {
83+
const nowEpoch = Math.floor(Date.now() / 1000);
84+
const minuteBucket = formatMinuteBucket(new Date());
85+
try {
86+
await ddb.send(new UpdateCommand({
87+
TableName: TASK_APPROVALS_TABLE_NAME,
88+
Key: {
89+
task_id: `RATE#${userId}#POLICIES`,
90+
request_id: `MINUTE#${minuteBucket}`,
91+
},
92+
UpdateExpression: 'ADD #count :one SET #ttl = :ttl',
93+
ConditionExpression: 'attribute_not_exists(#count) OR #count < :max',
94+
ExpressionAttributeNames: { '#count': 'count', '#ttl': 'ttl' },
95+
ExpressionAttributeValues: {
96+
':one': 1,
97+
':max': POLICIES_RATE_LIMIT_PER_MINUTE,
98+
':ttl': nowEpoch + 120,
99+
},
100+
}));
101+
} catch (err: unknown) {
102+
const name = (err as { name?: string })?.name;
103+
if (name === 'ConditionalCheckFailedException') {
104+
return errorResponse(
105+
429,
106+
ErrorCode.RATE_LIMIT_EXCEEDED,
107+
`Rate limit exceeded: at most ${POLICIES_RATE_LIMIT_PER_MINUTE} policy-list queries per minute.`,
108+
requestId,
109+
);
110+
}
111+
throw err;
112+
}
113+
}
114+
115+
// Cache check. Per-repo TTL of 5 min (IMPL-note — does not include
116+
// user_id because the policy set is not user-specific).
117+
const cached = cache.get(repoId);
118+
if (cached && cached.expiresAt > Date.now()) {
119+
return successResponse(200, cached.response, requestId);
120+
}
121+
122+
// Load blueprint config (optional — repos without custom policies
123+
// still get the built-in set).
124+
let blueprintCedarPolicies: readonly string[] = [];
125+
try {
126+
const repoConfig = await loadRepoConfig(repoId);
127+
if (repoConfig) {
128+
blueprintCedarPolicies = repoConfig.cedar_policies ?? [];
129+
}
130+
} catch (configErr) {
131+
logger.warn('Could not load repo config for policies endpoint — continuing with built-ins', {
132+
repo_id: repoId,
133+
error: configErr instanceof Error ? configErr.message : String(configErr),
134+
});
135+
}
136+
137+
const blueprintText = blueprintCedarPolicies.join('\n');
138+
const hardText = BUILTIN_HARD_DENY_POLICIES;
139+
const softText = concatPolicies(BUILTIN_SOFT_DENY_POLICIES, blueprintText);
140+
141+
let hardRules;
142+
let softRules;
143+
try {
144+
hardRules = parseRules(hardText);
145+
softRules = parseRules(softText);
146+
} catch (err) {
147+
if (err instanceof CedarPolicyParseError) {
148+
logger.error('Cedar parse failure for repo policies', {
149+
repo_id: repoId,
150+
message: err.message,
151+
});
152+
return errorResponse(
153+
503,
154+
ErrorCode.SERVICE_UNAVAILABLE,
155+
'Policy set for this repo is currently invalid.',
156+
requestId,
157+
);
158+
}
159+
throw err;
160+
}
161+
162+
const response: GetPoliciesResponse = {
163+
repo_id: repoId,
164+
policies: {
165+
hard: hardRules
166+
.filter((r) => r.tier === 'hard')
167+
.map(toSummary),
168+
soft: softRules
169+
.filter((r) => r.tier === 'soft')
170+
.map(toSummary),
171+
},
172+
};
173+
174+
cache.set(repoId, {
175+
response,
176+
expiresAt: Date.now() + CACHE_TTL_MS,
177+
});
178+
179+
return successResponse(200, response, requestId);
180+
} catch (err) {
181+
logger.error('Failed to list policies', {
182+
error: err instanceof Error ? err.message : String(err),
183+
request_id: requestId,
184+
});
185+
return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId);
186+
}
187+
}
188+
189+
function toSummary(rule: ReturnType<typeof parseRules>[number]): PolicyRuleSummary {
190+
const out: {
191+
-readonly [K in keyof PolicyRuleSummary]?: PolicyRuleSummary[K];
192+
} = {
193+
rule_id: rule.rule_id,
194+
summary: rule.summary,
195+
};
196+
if (rule.category) out.category = rule.category;
197+
if (rule.severity) out.severity = rule.severity;
198+
if (rule.approval_timeout_s) out.approval_timeout_s = rule.approval_timeout_s;
199+
return out as PolicyRuleSummary;
200+
}
201+
202+
function formatMinuteBucket(date: Date): string {
203+
const y = date.getUTCFullYear().toString().padStart(4, '0');
204+
const m = (date.getUTCMonth() + 1).toString().padStart(2, '0');
205+
const d = date.getUTCDate().toString().padStart(2, '0');
206+
const h = date.getUTCHours().toString().padStart(2, '0');
207+
const mi = date.getUTCMinutes().toString().padStart(2, '0');
208+
return `${y}${m}${d}${h}${mi}`;
209+
}
210+
211+
/** Test-only cache reset — exposed for unit tests. */
212+
export function _resetCacheForTests(): void {
213+
cache.clear();
214+
}

0 commit comments

Comments
 (0)