diff --git a/.prettierignore b/.prettierignore index d7d9cd713..865bcfafe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,5 @@ src/assets/**/*.md .github/scripts/prompts/ src/assets/**/*.ts src/assets/**/*.json +src/assets/**/*.mjs src/assets/**/*.template diff --git a/README.md b/README.md index 42aeda5ec..0748edea2 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,24 @@ agentcore invoke ### Resource Management -| Command | Description | -| -------- | ---------------------------------------------------- | -| `add` | Add agents, memory, credentials, evaluators, targets | -| `remove` | Remove resources from project | +| Command | Description | +| -------- | ------------------------------------------------------------------ | +| `add` | Add agents, memory, credentials, evaluators, targets, interceptors | +| `remove` | Remove resources from project | > **Note**: Run `agentcore deploy` after `add` or `remove` to update resources in AWS. +#### Interceptors + +| Command | Description | +| ------------------------------- | ------------------------------------------------------------------- | +| `add interceptor` | Add a Lambda interceptor (managed scaffold or BYO ARN) to a gateway | +| `remove interceptor` | Remove an interceptor | +| `logs interceptor --name ` | Tail or search managed interceptor CloudWatch logs | +| `invoke interceptor --name ` | Invoke a managed interceptor with a synthetic payload | + +See [docs/interceptors.md](docs/interceptors.md) for templates, schema, and the cross-account behavior. + ### Observability | Command | Description | diff --git a/docs/interceptors.md b/docs/interceptors.md new file mode 100644 index 000000000..d7d301e6f --- /dev/null +++ b/docs/interceptors.md @@ -0,0 +1,163 @@ +# Lambda Interceptors + +AgentCore Gateway Interceptors are customer-owned Lambda functions that the gateway invokes on every MCP request to +inspect, transform, or short-circuit traffic. They run at one of two interception points: + +- **REQUEST** — before the gateway invokes the target. +- **RESPONSE** — after the target returns, before the gateway replies to the caller. + +A gateway can carry up to **2 interceptors** (one REQUEST + one RESPONSE), or a single interceptor wired to both points. + +## Modes + +The CLI supports two first-class modes, mirroring the existing code-based evaluator pattern: + +| Mode | What the CLI owns | When to use | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| **Managed** (default) | Scaffolds a templated Lambda project under `app//`, packages it, deploys it, renders the resulting ARN into the gateway's `InterceptorConfigurations`. | You want the CLI to own the source tree and deploy artifact end-to-end. | +| **External** | You pass an already-deployed Lambda ARN with `--lambda-arn`. The CLI plugs the ARN into the gateway and grants `lambda:InvokeFunction` to the gateway role. | You have a centralized auth Lambda or a third-party-owned function. | + +## Quick start — managed + +```bash +# Single REQUEST-point interceptor with the JWT scope authorizer template +agentcore add interceptor \ + --name auth-check \ + --gateway my-gateway \ + --interception-points REQUEST \ + --template jwt-scope-authorizer \ + --runtime python3.12 + +# Edit app/auth-check/handler.py with your scope rules, then: +agentcore deploy +``` + +## Quick start — external (BYO ARN) + +```bash +agentcore add interceptor \ + --name central-auth \ + --gateway my-gateway \ + --interception-points REQUEST \ + --lambda-arn arn:aws:lambda:us-east-1:111111111111:function:central-auth-prod +``` + +The CLI does not scaffold any code; the only artifact is the JSON entry in `agentcore.json`. + +## Dual-point on a single Lambda + +A single interceptor can serve both REQUEST and RESPONSE on the same gateway: + +```bash +agentcore add interceptor \ + --name dual-point \ + --gateway my-gateway \ + --interception-points REQUEST,RESPONSE \ + --template pass-through \ + --runtime python3.12 +``` + +This counts as one interceptor against the cardinality cap. + +## Templates (managed mode) + +| Template | Point(s) | Purpose | +| ---------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `pass-through` | REQUEST or RESPONSE | Minimal compliant handler. Demonstrates the input/output envelope and the streaming guard. | +| `jwt-scope-authorizer` | REQUEST | Decodes the inbound `Authorization` JWT and short-circuits with a structured 403 if the required scope is missing. | +| `tools-list-filter` | RESPONSE | Strips unauthorized tools from `tools/list` responses based on a customer-supplied `is_authorized()` predicate. | + +Each template ships in both Python 3.12 and Node.js 22.x. Pick with `--runtime python3.12` (default) or +`--runtime nodejs22.x`. + +## Operational verbs + +```bash +# Tail logs for a managed interceptor +agentcore logs interceptor --name auth-check --follow + +# Search logs by time window +agentcore logs interceptor --name auth-check --since 1h --until now + +# Invoke synthetically with a payload file +agentcore invoke interceptor --name auth-check --payload-file ./test-event.json +``` + +For external interceptors, both verbs print a copy-pasteable `aws` CLI remediation and exit non-zero — the CLI doesn't +own those Lambdas. + +## Cross-account external interceptors + +When `--lambda-arn`'s account ID does not match the deploy target's account, the CLI emits a **warning** at preflight +(with masked account IDs) and **continues** the deploy. The deploy itself succeeds — the gateway role's identity policy +grants `lambda:InvokeFunction` on the foreign ARN. What doesn't work yet is the first invocation: AWS Lambda requires a +matching resource-based policy on the function granting the gateway role permission to invoke it. + +Example warning (account IDs masked to last 4 digits): + +``` +WARNING: Cross-account interceptor detected for "central-auth". + Gateway account(s): ****1947 + Lambda: arn:aws:lambda:us-east-1:****1111:function:central-auth-prod + +Deploy will succeed, but the first interceptor invocation will fail until +you add a resource-based policy to the Lambda. Run this in the Lambda's +account (once per interceptor) before sending traffic through the gateway: + + aws lambda add-permission \ + --function-name \ + --statement-id GatewayServiceRoleInvoke \ + --action lambda:InvokeFunction \ + --principal + +Continuing with deploy... +``` + +Run the snippet once in the Lambda's account, before sending traffic through the gateway. + +## Schema + +```jsonc +{ + "interceptors": [ + { + "name": "auth-check", + "gatewayName": "my-gateway", + "interceptionPoints": ["REQUEST"], + "passRequestHeaders": true, + "config": { + "managed": { + "codeLocation": "app/auth-check/", + "entrypoint": "handler.lambda_handler", + "timeoutSeconds": 30, + "runtime": "python3.12", + "additionalPolicies": ["execution-role-policy.json"], + }, + }, + }, + { + "name": "central-auth", + "gatewayName": "my-gateway", + "interceptionPoints": ["RESPONSE"], + "passRequestHeaders": true, + "config": { + "external": { + "lambdaArn": "arn:aws:lambda:us-east-1:111111111111:function:central-auth-prod", + }, + }, + }, + ], +} +``` + +`config.managed` and `config.external` are mutually exclusive (exactly one must be set). + +## Removal + +```bash +agentcore remove interceptor --name auth-check +agentcore deploy +``` + +Managed-mode removal also deletes the scaffolded `app//` directory. External-mode removal touches only the JSON +entry. The next `deploy` reconciles the gateway via CloudFormation — no imperative `UpdateGateway` calls. diff --git a/package.json b/package.json index 3491e443f..c753f4f27 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", + "@aws-sdk/client-lambda": "^3.893.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/client-sts": "^3.893.0", diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 9fad266c2..7e328b892 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -77,13 +77,21 @@ async function main() { // Gateway fields are stored in agentcore.json but may not yet be on the // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them // dynamically and cast the resulting object. + // + // Interceptors live at root in agentcore.json (\`interceptors[]\`). The CDK + // package's AgentCoreMcpSpec carries them in the same MCP-scoped object as + // gateways/runtimes, so we copy the field across at this boundary. Empty + // arrays are normalized to undefined to keep CDK synth clean when no + // interceptors are defined. // eslint-disable-next-line @typescript-eslint/no-explicit-any const specAny = spec as any; - const mcpSpec = specAny.agentCoreGateways?.length + const hasMcp = specAny.agentCoreGateways?.length || specAny.interceptors?.length; + const mcpSpec = hasMcp ? { - agentCoreGateways: specAny.agentCoreGateways, + agentCoreGateways: specAny.agentCoreGateways ?? [], mcpRuntimeTools: specAny.mcpRuntimeTools, unassignedTargets: specAny.unassignedTargets, + interceptors: specAny.interceptors?.length ? specAny.interceptors : undefined, } : undefined; @@ -300,15 +308,25 @@ export class AgentCoreStack extends Stack { spec, }); - // Create AgentCoreMcp if there are gateways configured - if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { - new AgentCoreMcp(this, 'Mcp', { - projectName: spec.name, - mcpSpec, - agentCoreApplication: this.application, - credentials, - projectTags: spec.tags, - }); + // Create AgentCoreMcp if there are gateways or interceptors configured. + // Interceptors are MCP-scoped via the gatewayName reference, so they + // never appear without gateways under valid schema, but the OR guard + // here is defensive — it prevents interceptors from silently vanishing + // if the spec ever reaches synth in a partially-validated state. + if (mcpSpec) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the bundled CDK type may not yet declare interceptors + const interceptorsAny = (mcpSpec as any).interceptors; + const hasGateways = mcpSpec.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; + const hasInterceptors = interceptorsAny && interceptorsAny.length > 0; + if (hasGateways || hasInterceptors) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } } // Stack-level output @@ -357,8 +375,8 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/package.json should m "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", + "@aws/agentcore-cdk": "^0.1.0-alpha.29", + "aws-cdk-lib": "^2.252.0", "constructs": "^10.0.0" } } @@ -451,6 +469,30 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "evaluators/python-lambda/execution-role-policy.json", "evaluators/python-lambda/lambda_function.py", "evaluators/python-lambda/pyproject.toml", + "interceptors/node-lambda/jwt-scope-authorizer/README.md", + "interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json", + "interceptors/node-lambda/jwt-scope-authorizer/index.mjs", + "interceptors/node-lambda/jwt-scope-authorizer/package.json", + "interceptors/node-lambda/pass-through/README.md", + "interceptors/node-lambda/pass-through/execution-role-policy.json", + "interceptors/node-lambda/pass-through/index.mjs", + "interceptors/node-lambda/pass-through/package.json", + "interceptors/node-lambda/tools-list-filter/README.md", + "interceptors/node-lambda/tools-list-filter/execution-role-policy.json", + "interceptors/node-lambda/tools-list-filter/index.mjs", + "interceptors/node-lambda/tools-list-filter/package.json", + "interceptors/python-lambda/jwt-scope-authorizer/README.md", + "interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json", + "interceptors/python-lambda/jwt-scope-authorizer/handler.py", + "interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml", + "interceptors/python-lambda/pass-through/README.md", + "interceptors/python-lambda/pass-through/execution-role-policy.json", + "interceptors/python-lambda/pass-through/handler.py", + "interceptors/python-lambda/pass-through/pyproject.toml", + "interceptors/python-lambda/tools-list-filter/README.md", + "interceptors/python-lambda/tools-list-filter/execution-role-policy.json", + "interceptors/python-lambda/tools-list-filter/handler.py", + "interceptors/python-lambda/tools-list-filter/pyproject.toml", "mcp/python-lambda/README.md", "mcp/python-lambda/handler.py", "mcp/python-lambda/pyproject.toml", diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 7a78b71cd..bdb8ae7bd 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -32,13 +32,21 @@ async function main() { // Gateway fields are stored in agentcore.json but may not yet be on the // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them // dynamically and cast the resulting object. + // + // Interceptors live at root in agentcore.json (`interceptors[]`). The CDK + // package's AgentCoreMcpSpec carries them in the same MCP-scoped object as + // gateways/runtimes, so we copy the field across at this boundary. Empty + // arrays are normalized to undefined to keep CDK synth clean when no + // interceptors are defined. // eslint-disable-next-line @typescript-eslint/no-explicit-any const specAny = spec as any; - const mcpSpec = specAny.agentCoreGateways?.length + const hasMcp = specAny.agentCoreGateways?.length || specAny.interceptors?.length; + const mcpSpec = hasMcp ? { - agentCoreGateways: specAny.agentCoreGateways, + agentCoreGateways: specAny.agentCoreGateways ?? [], mcpRuntimeTools: specAny.mcpRuntimeTools, unassignedTargets: specAny.unassignedTargets, + interceptors: specAny.interceptors?.length ? specAny.interceptors : undefined, } : undefined; diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index a4d277821..bfcc3d6c2 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -42,15 +42,25 @@ export class AgentCoreStack extends Stack { spec, }); - // Create AgentCoreMcp if there are gateways configured - if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { - new AgentCoreMcp(this, 'Mcp', { - projectName: spec.name, - mcpSpec, - agentCoreApplication: this.application, - credentials, - projectTags: spec.tags, - }); + // Create AgentCoreMcp if there are gateways or interceptors configured. + // Interceptors are MCP-scoped via the gatewayName reference, so they + // never appear without gateways under valid schema, but the OR guard + // here is defensive — it prevents interceptors from silently vanishing + // if the spec ever reaches synth in a partially-validated state. + if (mcpSpec) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the bundled CDK type may not yet declare interceptors + const interceptorsAny = (mcpSpec as any).interceptors; + const hasGateways = mcpSpec.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; + const hasInterceptors = interceptorsAny && interceptorsAny.length > 0; + if (hasGateways || hasInterceptors) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } } // Stack-level output diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index aa58892c2..23d01fc90 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -23,8 +23,8 @@ "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", + "@aws/agentcore-cdk": "^0.1.0-alpha.29", + "aws-cdk-lib": "^2.252.0", "constructs": "^10.0.0" } } diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/README.md b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/README.md new file mode 100644 index 000000000..152456041 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/README.md @@ -0,0 +1,23 @@ +# {{ Name }} — jwt-scope-authorizer (REQUEST, Node.js 22.x) + +Decode the inbound `Authorization: Bearer …` JWT, read the `scope`/`scp` claim, +and short-circuit unauthorized requests with a structured 403. + +## What you must edit + +Update `ALLOWED_SCOPES` in `index.mjs` to reflect your scope vocabulary. + +## Why REQUEST-only + +The structured 403 lives in `transformedGatewayResponse`, which the gateway +serves directly to the caller. RESPONSE-point interceptors should not authorize; +that's too late in the lifecycle. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. + +## Structured errors over exceptions + +Don't throw on auth failure. Throwing tells the gateway to retry, double-invoking +your handler. Always return the deny envelope. diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/index.mjs b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/index.mjs new file mode 100644 index 000000000..ceca504e0 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/index.mjs @@ -0,0 +1,76 @@ +/** + * AgentCore Gateway Interceptor — jwt-scope-authorizer (REQUEST point). + * + * Reads the JWT scope claim from the inbound `Authorization` header and either + * allows the request through unchanged or denies it with a structured 403. + * + * The handler does NOT validate the JWT signature — the gateway's CUSTOM_JWT + * authorizer already did that. We only read the `scope` claim and authorize + * the business action. + * + * Edit `ALLOWED_SCOPES` below to match your scope vocabulary. + */ + +const ALLOWED_SCOPES = new Set(['agentcore:invoke']); + +const decodeJwtPayload = token => { + const parts = token.split('.'); + if (parts.length < 2) return {}; + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const json = Buffer.from(padded, 'base64').toString('utf-8'); + return JSON.parse(json); + } catch { + return {}; + } +}; + +const scopesFromPayload = payload => { + const raw = payload.scope ?? payload.scp; + if (typeof raw === 'string') return raw.split(/\s+/); + if (Array.isArray(raw)) return raw.map(String); + return []; +}; + +const deny = reason => ({ + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayResponse: { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'forbidden', reason }, + }, + }, +}); + +export const handler = async event => { + const request = event?.mcp?.gatewayRequest ?? {}; + const headers = Object.fromEntries( + Object.entries(request.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]) + ); + const authz = headers.authorization ?? ''; + + if (!authz.toLowerCase().startsWith('bearer ')) { + return deny('missing-or-malformed-authorization-header'); + } + + const token = authz.slice('Bearer '.length).trim(); + const payload = decodeJwtPayload(token); + const scopes = new Set(scopesFromPayload(payload)); + + const intersect = [...scopes].some(s => ALLOWED_SCOPES.has(s)); + if (!intersect) { + return deny('required-scope-missing'); + } + + return { + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayRequest: { + headers: request.headers ?? {}, + body: request.body ?? {}, + }, + }, + }; +}; diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/package.json b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/package.json new file mode 100644 index 000000000..18e157f35 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/package.json @@ -0,0 +1,10 @@ +{ + "name": "{{ Name }}", + "version": "0.1.0", + "description": "AgentCore Gateway Interceptor — JWT scope authorizer", + "type": "module", + "main": "index.mjs", + "engines": { + "node": ">=22" + } +} diff --git a/src/assets/interceptors/node-lambda/pass-through/README.md b/src/assets/interceptors/node-lambda/pass-through/README.md new file mode 100644 index 000000000..974602688 --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/README.md @@ -0,0 +1,39 @@ +# {{ Name }} — pass-through interceptor (Node.js 22.x) + +A minimal AgentCore Gateway Interceptor that returns the request/response unchanged. + +## Envelope + +The handler reads `event.interceptorInputVersion === "1.0"` and returns +`{ interceptorOutputVersion: "1.0", mcp: ... }`. **The output version is mandatory**; +missing it causes the gateway to silently reject the response. + +## Structured errors over exceptions + +Don't throw. Return a structured envelope so the gateway doesn't retry the same +event and double-invoke. Example: + +```js +return { + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayResponse: { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Authorization denied' }, + }, + }, +}; +``` + +## Idempotency + +For interceptors with external side effects (writing to S3, calling a third-party +API), use `event.mcp.invocationId` as the idempotency key — see the commented-in +example in `index.mjs`. + +## Cold start + +Lambda cold starts can push the first invocation past the gateway's interceptor +budget. Configure provisioned concurrency on the function if telemetry shows +first-invocation timeouts. diff --git a/src/assets/interceptors/node-lambda/pass-through/execution-role-policy.json b/src/assets/interceptors/node-lambda/pass-through/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/node-lambda/pass-through/index.mjs b/src/assets/interceptors/node-lambda/pass-through/index.mjs new file mode 100644 index 000000000..b395cac7a --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/index.mjs @@ -0,0 +1,52 @@ +/** + * AgentCore Gateway Interceptor — pass-through (REQUEST or RESPONSE point). + * + * Inputs (REQUEST point): + * event.interceptorInputVersion === "1.0" + * event.mcp.gatewayRequest === { path, httpMethod, headers, body } + * + * Inputs (RESPONSE point): + * event.mcp.gatewayResponse === { statusCode, headers, body } + * + * Outputs (always): + * { interceptorOutputVersion: "1.0", mcp: {...} } + * + * Foot-guns avoided by this template: + * - interceptorOutputVersion is always set (missing → silent rejection). + * - Errors are returned as structured response envelopes, never thrown + * (throwing triggers gateway retries — fires the interceptor twice). + * + * Streaming guard (RESPONSE only — uncomment if your gateway streams): + * // const invocationIndex = event?.mcp?.invocationIndex ?? 0; + * // if (invocationIndex > 0) { + * // // Subsequent invocations: do not mutate headers/statusCode. + * // } + * + * Idempotency (uncomment if your handler has external side effects): + * // const key = event?.mcp?.invocationId; + * // if (key && seen(key)) return cachedResponse(key); + */ +export const handler = async event => { + const request = event?.mcp?.gatewayRequest; + const response = event?.mcp?.gatewayResponse; + + const outputMcp = {}; + if (request) { + outputMcp.transformedGatewayRequest = { + headers: request.headers ?? {}, + body: request.body ?? {}, + }; + } + if (response) { + outputMcp.transformedGatewayResponse = { + statusCode: response.statusCode ?? 200, + headers: response.headers ?? {}, + body: response.body ?? {}, + }; + } + + return { + interceptorOutputVersion: '1.0', + mcp: outputMcp, + }; +}; diff --git a/src/assets/interceptors/node-lambda/pass-through/package.json b/src/assets/interceptors/node-lambda/pass-through/package.json new file mode 100644 index 000000000..07fc87ea2 --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/package.json @@ -0,0 +1,10 @@ +{ + "name": "{{ Name }}", + "version": "0.1.0", + "description": "AgentCore Gateway Interceptor — pass-through", + "type": "module", + "main": "index.mjs", + "engines": { + "node": ">=22" + } +} diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/README.md b/src/assets/interceptors/node-lambda/tools-list-filter/README.md new file mode 100644 index 000000000..5200bec6f --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/README.md @@ -0,0 +1,27 @@ +# {{ Name }} — tools-list-filter (RESPONSE, Node.js 22.x) + +Strip unauthorized tools from `tools/list` responses before they reach the agent. +Other MCP method responses pass through unchanged. + +## What you must edit + +Replace the placeholder `isAuthorized()` function with your real logic. Common +patterns: + +- Read groups/roles from a JWT in `requestHeaders.authorization`. +- Look up an entitlement record in DynamoDB. +- Consult a Cedar / OPA policy engine. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. + +## Structured errors over exceptions + +If you must error out, return a structured response envelope (e.g., `502` with +a JSON error body) — never throw. Throwing fires the interceptor twice. + +## Cold start + +This handler runs once per `tools/list` request, which is infrequent compared +to per-tool-invocation interceptors. Cold starts are usually fine here. diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/execution-role-policy.json b/src/assets/interceptors/node-lambda/tools-list-filter/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/index.mjs b/src/assets/interceptors/node-lambda/tools-list-filter/index.mjs new file mode 100644 index 000000000..4574205d7 --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/index.mjs @@ -0,0 +1,48 @@ +/** + * AgentCore Gateway Interceptor — tools-list-filter (RESPONSE point). + * + * When the gateway answers a `tools/list` MCP call, strip the response body of + * any tools the calling principal isn't allowed to see. Other MCP method + * responses pass through unchanged. + * + * Replace the placeholder `isAuthorized()` with your real logic. + */ + +const isAuthorized = (toolName, requestHeaders) => { + // Default: allow everything. Replace with real logic (read JWT, check + // groups, consult policy engine, etc.). + void toolName; + void requestHeaders; + return true; +}; + +export const handler = async event => { + const response = event?.mcp?.gatewayResponse; + if (!response) { + return { interceptorOutputVersion: '1.0', mcp: {} }; + } + + const requestHeaders = event?.mcp?.gatewayRequest?.headers ?? {}; + const body = response.body ?? {}; + const requestMethod = event?.mcp?.gatewayRequest?.body?.method; + const isToolsList = requestMethod === 'tools/list' || (body?.result && Array.isArray(body.result.tools)); + + if (!isToolsList) { + return { interceptorOutputVersion: '1.0', mcp: { transformedGatewayResponse: response } }; + } + + const result = body.result ?? {}; + const tools = Array.isArray(result.tools) ? result.tools : []; + const filtered = tools.filter(t => isAuthorized(String(t?.name ?? ''), requestHeaders)); + + return { + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayResponse: { + statusCode: response.statusCode ?? 200, + headers: response.headers ?? {}, + body: { ...body, result: { ...result, tools: filtered } }, + }, + }, + }; +}; diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/package.json b/src/assets/interceptors/node-lambda/tools-list-filter/package.json new file mode 100644 index 000000000..2cce898bd --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/package.json @@ -0,0 +1,10 @@ +{ + "name": "{{ Name }}", + "version": "0.1.0", + "description": "AgentCore Gateway Interceptor — tools/list filter", + "type": "module", + "main": "index.mjs", + "engines": { + "node": ">=22" + } +} diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/README.md b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/README.md new file mode 100644 index 000000000..8ac579e8f --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/README.md @@ -0,0 +1,25 @@ +# {{ Name }} — jwt-scope-authorizer (REQUEST) + +Decode the inbound `Authorization: Bearer …` JWT, read the `scope`/`scp` claim, +and short-circuit unauthorized requests with a structured 403. + +## What you must edit + +Update `ALLOWED_SCOPES` in `handler.py` to reflect your scope vocabulary. + +## Why this is REQUEST-only + +The structured 403 lives in `transformedGatewayResponse`, which the gateway +serves directly to the caller. RESPONSE-point interceptors should not authorize; +that's too late in the lifecycle. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. Missing it +causes the gateway to silently reject the response and serve the upstream +result unmodified. + +## Structured errors over exceptions + +Don't throw on auth failure. Throwing tells the gateway to retry, double-invoking +your handler. Always return the deny envelope. diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/handler.py b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/handler.py new file mode 100644 index 000000000..837c527ba --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/handler.py @@ -0,0 +1,83 @@ +""" +AgentCore Gateway Interceptor — jwt-scope-authorizer (REQUEST point). + +Reads the JWT scope claim from the inbound `Authorization` header and either +allows the request through unchanged or denies it with a structured 403. + +Envelope contract: + Inputs: event["mcp"]["gatewayRequest"]["headers"]["authorization"] + Outputs: {"interceptorOutputVersion": "1.0", "mcp": {...}} + +This handler does NOT validate the JWT signature -- the gateway's CUSTOM_JWT +authorizer already did that. We only read the `scope` claim and authorize the +business action. + +Edit the ALLOWED_SCOPES set below to match your scope vocabulary. +""" +import base64 +import json +from typing import Any, Dict, Iterable + +ALLOWED_SCOPES: frozenset[str] = frozenset({"agentcore:invoke"}) + + +def _decode_jwt_payload(token: str) -> Dict[str, Any]: + parts = token.split(".") + if len(parts) < 2: + return {} + payload = parts[1] + # Pad base64url to a multiple of 4 chars before decoding. + payload += "=" * (-len(payload) % 4) + try: + return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return {} + + +def _scopes_from_payload(payload: Dict[str, Any]) -> Iterable[str]: + raw = payload.get("scope") or payload.get("scp") + if isinstance(raw, str): + return raw.split() + if isinstance(raw, list): + return [str(s) for s in raw] + return [] + + +def _deny(reason: str) -> Dict[str, Any]: + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "statusCode": 403, + "headers": {"Content-Type": "application/json"}, + "body": {"error": "forbidden", "reason": reason}, + } + }, + } + + +def lambda_handler(event, context): + request = event.get("mcp", {}).get("gatewayRequest", {}) + headers = {k.lower(): v for k, v in request.get("headers", {}).items()} + authz = headers.get("authorization", "") + + if not authz.lower().startswith("bearer "): + return _deny("missing-or-malformed-authorization-header") + + token = authz[len("Bearer ") :].strip() + payload = _decode_jwt_payload(token) + scopes = set(_scopes_from_payload(payload)) + + if not scopes & ALLOWED_SCOPES: + return _deny("required-scope-missing") + + # Allow: pass through unchanged. + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayRequest": { + "headers": request.get("headers", {}), + "body": request.get("body", {}), + } + }, + } diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml new file mode 100644 index 000000000..7984f8191 --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ PackageName }}" +version = "0.1.0" +description = "AgentCore Lambda Interceptor — JWT scope authorizer" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/interceptors/python-lambda/pass-through/README.md b/src/assets/interceptors/python-lambda/pass-through/README.md new file mode 100644 index 000000000..1b04c92ca --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/README.md @@ -0,0 +1,40 @@ +# {{ Name }} — pass-through interceptor + +A minimal AgentCore Gateway Interceptor that returns the request/response unchanged. + +## Envelope + +The handler reads `event["interceptorInputVersion"] == "1.0"` and returns +`{"interceptorOutputVersion": "1.0", "mcp": ...}`. **The output version is mandatory**; +missing it causes the gateway to silently reject the response. + +## Structured errors over exceptions + +Don't throw. Return a structured envelope so the gateway doesn't retry the same +event and double-invoke. Example: + +```python +return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "statusCode": 403, + "headers": {"Content-Type": "application/json"}, + "body": {"error": "Authorization denied"}, + } + }, +} +``` + +## Idempotency + +For interceptors with external side effects (writing to S3, calling a third-party +API), use `event["mcp"]["invocationId"]` as the idempotency key — see the +commented-in example in `handler.py`. + +## Cold start + +Lambda cold starts can push the first invocation past the gateway's interceptor +budget. If telemetry shows a steady stream of first-invocation timeouts, configure +provisioned concurrency on the function. The schema's default `timeoutSeconds: 30` +is a comfortable upper bound for typical workloads. diff --git a/src/assets/interceptors/python-lambda/pass-through/execution-role-policy.json b/src/assets/interceptors/python-lambda/pass-through/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/python-lambda/pass-through/handler.py b/src/assets/interceptors/python-lambda/pass-through/handler.py new file mode 100644 index 000000000..c8f24cc93 --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/handler.py @@ -0,0 +1,53 @@ +""" +AgentCore Gateway Interceptor — pass-through (REQUEST or RESPONSE point). + +Inputs (REQUEST point): + event["interceptorInputVersion"] == "1.0" + event["mcp"]["gatewayRequest"] == {path, httpMethod, headers, body} + +Inputs (RESPONSE point): + event["mcp"]["gatewayResponse"] == {statusCode, headers, body} + +Outputs (always): + interceptorOutputVersion: "1.0" + mcp: { transformedGatewayRequest? , transformedGatewayResponse? } + +Foot-guns avoided by this template: + - interceptorOutputVersion is always set (missing -> silent rejection). + - Errors are returned as structured response envelopes, never thrown + (throwing triggers gateway retries -- fires the interceptor twice). + +Streaming guard (RESPONSE only -- uncomment if your gateway streams): + # invocation_index = event.get("mcp", {}).get("invocationIndex", 0) + # if invocation_index > 0: + # # Subsequent invocations: do not mutate headers/statusCode. + # pass + +Idempotency (uncomment if your handler has external side effects): + # idempotency_key = event.get("mcp", {}).get("invocationId") + # if idempotency_key and seen(idempotency_key): + # return cached_response(idempotency_key) +""" + + +def lambda_handler(event, context): + request = event.get("mcp", {}).get("gatewayRequest") + response = event.get("mcp", {}).get("gatewayResponse") + + output_mcp = {} + if request is not None: + output_mcp["transformedGatewayRequest"] = { + "headers": request.get("headers", {}), + "body": request.get("body", {}), + } + if response is not None: + output_mcp["transformedGatewayResponse"] = { + "statusCode": response.get("statusCode", 200), + "headers": response.get("headers", {}), + "body": response.get("body", {}), + } + + return { + "interceptorOutputVersion": "1.0", + "mcp": output_mcp, + } diff --git a/src/assets/interceptors/python-lambda/pass-through/pyproject.toml b/src/assets/interceptors/python-lambda/pass-through/pyproject.toml new file mode 100644 index 000000000..a520cc2be --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ PackageName }}" +version = "0.1.0" +description = "AgentCore Lambda Interceptor — pass-through" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/README.md b/src/assets/interceptors/python-lambda/tools-list-filter/README.md new file mode 100644 index 000000000..008026230 --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/README.md @@ -0,0 +1,27 @@ +# {{ Name }} — tools-list-filter (RESPONSE) + +Strip unauthorized tools from `tools/list` responses before they reach the agent. +Other MCP method responses pass through unchanged. + +## What you must edit + +Replace the placeholder `is_authorized()` function with your real logic. Common +patterns: + +- Read groups/roles from a JWT in `request_headers["authorization"]`. +- Look up an entitlement record in DynamoDB. +- Consult a Cedar / OPA policy engine. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. + +## Structured errors over exceptions + +If you must error out, return a structured response envelope (e.g., `502` with +a JSON error body) — never throw. Throwing fires the interceptor twice. + +## Cold start + +This handler runs once per `tools/list` request, which is infrequent compared +to per-tool-invocation interceptors. Cold starts are usually fine here. diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/execution-role-policy.json b/src/assets/interceptors/python-lambda/tools-list-filter/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/handler.py b/src/assets/interceptors/python-lambda/tools-list-filter/handler.py new file mode 100644 index 000000000..c8671215a --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/handler.py @@ -0,0 +1,66 @@ +""" +AgentCore Gateway Interceptor — tools-list-filter (RESPONSE point). + +When the gateway answers a `tools/list` MCP call, strip the response body of +any tools the calling principal isn't allowed to see. Other MCP method +responses pass through unchanged. + +Edit the `is_authorized()` predicate to match your authorization model. + +Envelope contract: + Inputs: event["mcp"]["gatewayResponse"]["body"]["result"]["tools"] + Outputs: {"interceptorOutputVersion": "1.0", "mcp": {...}} +""" +from typing import Any, Dict, List + + +def is_authorized(tool_name: str, request_headers: Dict[str, str]) -> bool: + """Return True if the caller is allowed to see this tool. + + Default implementation: allow everything. Replace with real logic + (read groups from JWT, check feature flag, consult policy engine, etc.). + """ + _ = (tool_name, request_headers) + return True + + +def lambda_handler(event, context): + response = event.get("mcp", {}).get("gatewayResponse") + if response is None: + # Defensive: should not happen at RESPONSE point. + return {"interceptorOutputVersion": "1.0", "mcp": {}} + + request_headers: Dict[str, str] = ( + event.get("mcp", {}).get("gatewayRequest", {}).get("headers", {}) or {} + ) + + body = response.get("body") or {} + method = body.get("method") or (event.get("mcp", {}).get("gatewayRequest", {}).get("body", {}) or {}).get("method") + is_tools_list = method == "tools/list" or "tools" in (body.get("result") or {}) + + if not is_tools_list: + # Pass through unchanged. + return { + "interceptorOutputVersion": "1.0", + "mcp": {"transformedGatewayResponse": response}, + } + + result = body.get("result") or {} + tools: List[Dict[str, Any]] = result.get("tools") or [] + filtered: List[Dict[str, Any]] = [t for t in tools if is_authorized(str(t.get("name", "")), request_headers)] + + new_body = dict(body) + new_result = dict(result) + new_result["tools"] = filtered + new_body["result"] = new_result + + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "statusCode": response.get("statusCode", 200), + "headers": response.get("headers", {}), + "body": new_body, + } + }, + } diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/pyproject.toml b/src/assets/interceptors/python-lambda/tools-list-filter/pyproject.toml new file mode 100644 index 000000000..b9e410dc7 --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ PackageName }}" +version = "0.1.0" +description = "AgentCore Lambda Interceptor — tools/list filter" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/cli/aws/__tests__/mask.test.ts b/src/cli/aws/__tests__/mask.test.ts new file mode 100644 index 000000000..1427d48b5 --- /dev/null +++ b/src/cli/aws/__tests__/mask.test.ts @@ -0,0 +1,61 @@ +import { accountIdFromArn, maskAccountId } from '../mask'; +import { describe, expect, it } from 'vitest'; + +describe('maskAccountId', () => { + it('masks a 12-digit account ID in a Lambda ARN', () => { + expect(maskAccountId('arn:aws:lambda:us-east-1:111111111111:function:foo')).toBe( + 'arn:aws:lambda:us-east-1:****1111:function:foo' + ); + }); + + it('preserves the last 4 digits', () => { + expect(maskAccountId('arn:aws:lambda:us-east-1:603141041947:function:foo')).toBe( + 'arn:aws:lambda:us-east-1:****1947:function:foo' + ); + }); + + it('handles non-aws partitions', () => { + expect(maskAccountId('arn:aws-us-gov:lambda:us-gov-west-1:222222222222:function:bar')).toBe( + 'arn:aws-us-gov:lambda:us-gov-west-1:****2222:function:bar' + ); + }); + + it('masks multiple ARNs in one string', () => { + const input = + 'gateway: arn:aws:lambda:us-east-1:111111111111:function:a\n ' + + 'lambda: arn:aws:lambda:us-east-1:222222222222:function:b'; + expect(maskAccountId(input)).toContain('****1111'); + expect(maskAccountId(input)).toContain('****2222'); + }); + + it('passes through non-ARN strings unchanged', () => { + expect(maskAccountId('foo bar baz')).toBe('foo bar baz'); + }); + + it('does not mask numbers shorter than 12 digits', () => { + expect(maskAccountId('port 8080 timeout 30')).toBe('port 8080 timeout 30'); + }); + + it('is idempotent on already-masked output', () => { + const once = maskAccountId('arn:aws:lambda:us-east-1:111111111111:function:foo'); + expect(maskAccountId(once)).toBe(once); + }); + + it('handles empty input', () => { + expect(maskAccountId('')).toBe(''); + }); +}); + +describe('accountIdFromArn', () => { + it('extracts the account ID from a Lambda ARN', () => { + expect(accountIdFromArn('arn:aws:lambda:us-east-1:111111111111:function:foo')).toBe('111111111111'); + }); + + it('returns undefined for masked ARNs', () => { + expect(accountIdFromArn('arn:aws:lambda:us-east-1:****1111:function:foo')).toBeUndefined(); + }); + + it('returns undefined for non-ARN strings', () => { + expect(accountIdFromArn('not-an-arn')).toBeUndefined(); + }); +}); diff --git a/src/cli/aws/mask.ts b/src/cli/aws/mask.ts new file mode 100644 index 000000000..dff20c437 --- /dev/null +++ b/src/cli/aws/mask.ts @@ -0,0 +1,43 @@ +/** + * PII masking utility for AWS account IDs. + * + * Account IDs are sensitive — they uniquely identify the customer's AWS account + * in any system that aggregates user-visible CLI output (logs, telemetry, + * support tickets, screenshots). The masker rewrites any 12-digit account-ID + * segment in an ARN to `****` so the rest of the ARN structure is + * preserved while the account ID itself is obscured. + * + * Used by: + * - The cross-account interceptor preflight warning + * - `agentcore status` rendering for interceptor ARNs + * - Telemetry attributes that carry interceptor ARNs + * + * Idempotent on already-masked input. + */ + +const ACCOUNT_ID_RE = /\b\d{12}\b/g; +// Recognize an already-masked account-ID-like segment (`****1234`) to keep the +// helper idempotent. +const MASKED_RE = /\*{4}\d{4}/; + +/** + * Replace any 12-digit account-ID segment with `****`. + * + * Handles ARNs (`arn:aws:lambda:us-east-1:111111111111:function:foo`), + * multi-ARN strings, and bare 12-digit IDs. Non-12-digit numbers are left + * alone (no false positives on resource IDs, port numbers, timeouts, etc.). + */ +export function maskAccountId(input: string): string { + if (!input) return input; + return input.replace(ACCOUNT_ID_RE, m => `****${m.slice(-4)}`); +} + +/** + * Extract the 12-digit account ID from an ARN. Returns `undefined` if the + * input is not an ARN or already masked. + */ +export function accountIdFromArn(arn: string): string | undefined { + if (MASKED_RE.test(arn)) return undefined; + const m = /:(\d{12}):/.exec(arn); + return m?.[1]; +} diff --git a/src/cli/cloudformation/__tests__/interceptor-outputs.test.ts b/src/cli/cloudformation/__tests__/interceptor-outputs.test.ts new file mode 100644 index 000000000..6505402ec --- /dev/null +++ b/src/cli/cloudformation/__tests__/interceptor-outputs.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for parseInterceptorOutputs and the interceptor branch of buildDeployedState. + * + * The CDK construct emits the following CFN outputs (per Phase 1 wiring): + * - Interceptor{PascalName}ArnOutput + * - Interceptor{PascalName}ModeOutput + * - Interceptor{PascalName}RoleArnOutput (managed-only) + * - Interceptor{PascalName}FunctionNameOutput (managed-only) + * + * The CFN output keys carry an auto-deduplication hash suffix ("Output3E11FAB4"). + * The parser uses startsWith to find the right key. + */ +import { buildDeployedState, parseInterceptorOutputs } from '../outputs'; +import { describe, expect, it } from 'vitest'; + +describe('parseInterceptorOutputs', () => { + it('parses managed-mode entries with all four fields', () => { + const outputs = { + InterceptorAuthCheckArnOutputAAAA: 'arn:aws:lambda:us-east-1:111111111111:function:p-interceptor-auth-check', + InterceptorAuthCheckModeOutputBBBB: 'managed', + InterceptorAuthCheckRoleArnOutputCCCC: 'arn:aws:iam::111111111111:role/auth-check-role', + InterceptorAuthCheckFunctionNameOutputDDDD: 'p-interceptor-auth-check', + }; + const result = parseInterceptorOutputs(outputs, [{ name: 'auth-check', mode: 'managed' }]); + expect(result['auth-check']).toEqual({ + mode: 'managed', + interceptorArn: 'arn:aws:lambda:us-east-1:111111111111:function:p-interceptor-auth-check', + interceptorRoleArn: 'arn:aws:iam::111111111111:role/auth-check-role', + interceptorFunctionName: 'p-interceptor-auth-check', + }); + }); + + it('parses external-mode entries with only mode + ARN', () => { + const outputs = { + InterceptorCentralAuthArnOutputAAAA: 'arn:aws:lambda:us-east-1:222222222222:function:central-auth', + InterceptorCentralAuthModeOutputBBBB: 'external', + }; + const result = parseInterceptorOutputs(outputs, [{ name: 'central-auth', mode: 'external' }]); + expect(result['central-auth']).toEqual({ + mode: 'external', + interceptorArn: 'arn:aws:lambda:us-east-1:222222222222:function:central-auth', + }); + expect(result['central-auth']?.interceptorRoleArn).toBeUndefined(); + expect(result['central-auth']?.interceptorFunctionName).toBeUndefined(); + }); + + it('returns an empty record when no interceptor outputs are present', () => { + expect(parseInterceptorOutputs({}, [{ name: 'absent', mode: 'managed' }])).toEqual({}); + }); + + it('skips entries missing the Arn or Mode output', () => { + const outputs = { + InterceptorIncompleteArnOutputAAAA: 'arn:aws:lambda:us-east-1:111111111111:function:incomplete', + // Mode missing + }; + expect(parseInterceptorOutputs(outputs, [{ name: 'incomplete', mode: 'managed' }])).toEqual({}); + }); +}); + +describe('buildDeployedState — interceptor placement', () => { + it('writes interceptors under mcp.interceptors when present', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'MyStack', + agents: {}, + gateways: {}, + interceptors: { + 'auth-check': { + mode: 'managed', + interceptorArn: 'arn:aws:lambda:us-east-1:111111111111:function:auth-check', + interceptorRoleArn: 'arn:aws:iam::111111111111:role/auth-check-role', + interceptorFunctionName: 'auth-check', + }, + }, + }); + expect(state.targets.default?.resources?.mcp?.interceptors?.['auth-check']?.mode).toBe('managed'); + }); + + it('does not create an mcp block when both gateways and interceptors are empty', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'MyStack', + agents: {}, + gateways: {}, + }); + expect(state.targets.default?.resources?.mcp).toBeUndefined(); + }); + + it('co-locates gateways and interceptors under mcp', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'MyStack', + agents: {}, + gateways: { + 'my-gw': { gatewayId: 'g-1', gatewayArn: 'arn:gw' }, + }, + interceptors: { + 'auth-check': { + mode: 'external', + interceptorArn: 'arn:aws:lambda:us-east-1:111111111111:function:auth-check', + }, + }, + }); + expect(state.targets.default?.resources?.mcp?.gateways?.['my-gw']).toBeDefined(); + expect(state.targets.default?.resources?.mcp?.interceptors?.['auth-check']).toBeDefined(); + }); +}); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 377cc3e9d..2b3a32a12 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -2,6 +2,7 @@ import type { AgentCoreDeployedState, DeployedState, EvaluatorDeployedState, + InterceptorDeployedState, MemoryDeployedState, OnlineEvalDeployedState, PolicyDeployedState, @@ -243,6 +244,57 @@ export function parseEvaluatorOutputs( return evaluators; } +/** + * Parse stack outputs into deployed state for Lambda interceptors. + * + * Output key pattern: Interceptor{PascalName}(Arn|Mode|RoleArn|FunctionName)Output{Hash} + * + * Per Phase 1 wiring, the CDK construct emits: + * - Arn (always) + * - Mode (always — `managed` | `external`) + * - RoleArn (managed only) + * - FunctionName (managed only) + * + * The CLI consumes these into `targets[X].resources.mcp.interceptors[name]`. + */ +export function parseInterceptorOutputs( + outputs: StackOutputs, + interceptorSpecs: { name: string; mode: 'managed' | 'external' }[] +): Record { + const interceptors: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const spec of interceptorSpecs) { + const pascal = toPascalId('Interceptor', spec.name); + const arnPrefix = `${pascal}ArnOutput`; + const modePrefix = `${pascal}ModeOutput`; + const roleArnPrefix = `${pascal}RoleArnOutput`; + const fnNamePrefix = `${pascal}FunctionNameOutput`; + + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + const modeKey = outputKeys.find(k => k.startsWith(modePrefix)); + + if (!arnKey || !modeKey) continue; + + const mode = outputs[modeKey] === 'managed' ? 'managed' : 'external'; + const state: InterceptorDeployedState = { + mode, + interceptorArn: outputs[arnKey]!, + }; + + if (mode === 'managed') { + const roleArnKey = outputKeys.find(k => k.startsWith(roleArnPrefix)); + const fnNameKey = outputKeys.find(k => k.startsWith(fnNamePrefix)); + if (roleArnKey) state.interceptorRoleArn = outputs[roleArnKey]; + if (fnNameKey) state.interceptorFunctionName = outputs[fnNameKey]; + } + + interceptors[spec.name] = state; + } + + return interceptors; +} + /** * Parse stack outputs into deployed state for online evaluation configs. * @@ -389,6 +441,8 @@ export interface BuildDeployedStateOptions { policyEngines?: Record; policies?: Record; runtimeEndpoints?: Record; + /** Interceptor states keyed by interceptor name. Stored under mcp.interceptors. */ + interceptors?: Record; } /** @@ -409,6 +463,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta policyEngines, policies, runtimeEndpoints, + interceptors, } = opts; const targetState: TargetDeployedState = { resources: { @@ -421,10 +476,15 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta }, }; - // Add MCP state if gateways exist - if (Object.keys(gateways).length > 0) { + // Add MCP state if gateways or interceptors exist. Both nest under `mcp` + // because interceptors attach to gateways logically (and the deployed-state + // schema reflects that hierarchy). + const hasGateways = Object.keys(gateways).length > 0; + const hasInterceptors = interceptors && Object.keys(interceptors).length > 0; + if (hasGateways || hasInterceptors) { targetState.resources!.mcp = { - gateways, + ...(hasGateways && { gateways }), + ...(hasInterceptors && { interceptors }), }; } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index eba2ab113..1db7c2238 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -10,6 +10,7 @@ import { parseAgentOutputs, parseEvaluatorOutputs, parseGatewayOutputs, + parseInterceptorOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, parsePolicyEngineOutputs, @@ -463,6 +464,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ + name: i.name, + mode: i.config.managed ? ('managed' as const) : ('external' as const), + })); + const interceptors = parseInterceptorOutputs(outputs, interceptorSpecs); + const existingState = await configIO.readDeployedState().catch(() => undefined); let deployedState = buildDeployedState({ targetName: target.name, @@ -478,6 +488,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { it('missing authorizerType: defaults to NONE', () => { const gw = makeGateway(); // Simulate undefined authorizerType by deleting after construction - // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (gw as any).authorizerType; const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); diff --git a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts index 3624ce545..b2eed2b32 100644 --- a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts @@ -52,7 +52,6 @@ describe('toGatewayTargetSpec — apiGateway', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('apiGateway'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const apigw = (result as any).apiGateway; expect(apigw.restApiId).toBe('abc123'); expect(apigw.stage).toBe('prod'); @@ -84,7 +83,6 @@ describe('toGatewayTargetSpec — apiGateway', () => { const onProgress = vi.fn(); const result = toGatewayTargetSpec(detail, new Map(), onProgress); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const apigw = (result as any).apiGateway; expect(apigw.apiGatewayToolConfiguration.toolOverrides).toEqual([ { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, @@ -110,7 +108,6 @@ describe('toGatewayTargetSpec — apiGateway', () => { const onProgress = vi.fn(); const result = toGatewayTargetSpec(detail, new Map(), onProgress); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const apigw = (result as any).apiGateway; expect(apigw.apiGatewayToolConfiguration.toolOverrides).toBeUndefined(); }); @@ -176,7 +173,6 @@ describe('toGatewayTargetSpec — openApiSchema', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('openApiSchema'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const schemaSource = (result as any).schemaSource; expect(schemaSource.s3.uri).toBe('s3://my-bucket/schema.yaml'); expect(schemaSource.s3.bucketOwnerAccountId).toBe('123456789012'); @@ -222,7 +218,6 @@ describe('toGatewayTargetSpec — smithyModel', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('smithyModel'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const schemaSource = (result as any).schemaSource; expect(schemaSource.s3.uri).toBe('s3://models-bucket/model.json'); expect(schemaSource.s3.bucketOwnerAccountId).toBeUndefined(); @@ -269,7 +264,6 @@ describe('toGatewayTargetSpec — lambda', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('lambdaFunctionArn'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const lambdaConfig = (result as any).lambdaFunctionArn; expect(lambdaConfig.lambdaArn).toBe('arn:aws:lambda:us-west-2:123456789012:function:my-func'); expect(lambdaConfig.toolSchemaFile).toBe('s3://schemas/tools.json'); diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 1359232c3..75ffa829a 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,12 +1,13 @@ import { type Result, ValidationError, serializeResult } from '../../../lib'; import { getErrorMessage } from '../../errors'; -import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import { runCliCommand, withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; import { InvokeScreen } from '../../tui/screens/invoke'; import { parseHeaderFlags } from '../shared/header-utils'; import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action'; +import { type InvokeInterceptorOptions, handleInvokeInterceptor } from './interceptor'; import { resolvePrompt } from './resolve-prompt'; import type { InvokeOptions, InvokeResult } from './types'; import { validateInvokeOptions } from './validate'; @@ -97,10 +98,9 @@ function printInvokeResult(result: InvokeResult, options: InvokeOptions): void { } export const registerInvoke = (program: Command) => { - program - .command('invoke') - .alias('i') - .description(COMMAND_DESCRIPTIONS.invoke) + const invokeCmd = program.command('invoke').alias('i').description(COMMAND_DESCRIPTIONS.invoke); + + invokeCmd .argument( '[prompt]', 'Prompt to send to the agent. Also accepts piped stdin when no prompt is provided and stdin is not a TTY [non-interactive]' @@ -279,4 +279,38 @@ export const registerInvoke = (program: Command) => { } } ); + + // ── agentcore invoke interceptor ────────────────────────────────────────── + // Subcommand for managed Lambda interceptors. External interceptors + // short-circuit via the shared mode-check helper. Commander matches + // registered subcommand names before treating positional args as the + // runtime prompt, so `agentcore invoke "say hello"` continues to route to + // the existing root-level handler unaffected. + invokeCmd + .command('interceptor') + .description('Invoke a Lambda interceptor (managed mode only)') + .option('--name ', 'Interceptor name (required)') + .option('--target ', 'Deployment target (defaults to first target)') + .option('--payload ', 'Inline JSON payload') + .option('--payload-file ', 'Path to a JSON file containing the payload') + .option('--json', 'Output as JSON') + .action(async (cliOptions: InvokeInterceptorOptions) => { + requireProject(); + try { + await runCliCommand('invoke.interceptor', !!cliOptions.json, async () => { + const r = await handleInvokeInterceptor(cliOptions); + if (!r.success) { + throw r.error; + } + return { mode: 'managed' as const, has_payload_file: !!cliOptions.payloadFile }; + }); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render({getErrorMessage(error)}); + } + process.exit(1); + } + }); }; diff --git a/src/cli/commands/invoke/interceptor.ts b/src/cli/commands/invoke/interceptor.ts new file mode 100644 index 000000000..21024fa9f --- /dev/null +++ b/src/cli/commands/invoke/interceptor.ts @@ -0,0 +1,123 @@ +import { ConfigIO, type Result, ValidationError } from '../../../lib'; +import { getCredentialProvider } from '../../aws/account'; +import { ensureManagedForInvoke } from '../shared/interceptor-mode-check'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { readFile } from 'node:fs/promises'; + +export interface InvokeInterceptorOptions { + /** Interceptor name (required) */ + name?: string; + /** Deployment target name (defaults to first target). */ + target?: string; + /** Inline JSON payload. */ + payload?: string; + /** Path to a JSON file containing the payload. */ + payloadFile?: string; + /** Output as JSON. */ + json?: boolean; +} + +/** + * `agentcore invoke interceptor --name [--payload | --payload-file]`. + * + * Routes to `lambda:Invoke` against the managed interceptor's deployed-state + * ARN. External interceptors short-circuit via `ensureManagedForInvoke`, + * which throws a structured ValidationError carrying the `aws lambda invoke` + * remediation. + */ +export async function handleInvokeInterceptor( + options: InvokeInterceptorOptions +): Promise> { + if (!options.name) { + return { success: false, error: new ValidationError('--name is required') }; + } + + try { + const { entry, targetName } = await ensureManagedForInvoke(options.name, options.target); + + const configIO = new ConfigIO(); + const targets = await configIO.resolveAWSDeploymentTargets(); + const target = targets.find(t => t.name === targetName) ?? targets[0]; + if (!target) { + return { success: false, error: new ValidationError('No AWS deployment targets configured.') }; + } + + let payloadJson: string | undefined; + if (options.payloadFile) { + try { + payloadJson = await readFile(options.payloadFile, 'utf-8'); + } catch (readErr) { + const msg = readErr instanceof Error ? readErr.message : String(readErr); + return { + success: false, + error: new ValidationError(`Cannot read --payload-file "${options.payloadFile}": ${msg}`), + }; + } + } else if (options.payload) { + payloadJson = options.payload; + } + + if (payloadJson) { + try { + JSON.parse(payloadJson); + } catch { + return { + success: false, + error: new ValidationError( + 'Payload is not valid JSON. Provide an object via --payload or a JSON file via --payload-file.' + ), + }; + } + } + + const client = new LambdaClient({ region: target.region, credentials: getCredentialProvider() }); + const response = await client.send( + new InvokeCommand({ + FunctionName: entry.interceptorArn, + ...(payloadJson ? { Payload: new TextEncoder().encode(payloadJson) } : {}), + }) + ); + + let decoded: unknown; + if (response.Payload) { + const text = new TextDecoder().decode(response.Payload); + try { + decoded = JSON.parse(text); + } catch { + decoded = text; + } + } + + if (options.json) { + process.stdout.write( + `${JSON.stringify({ statusCode: response.StatusCode, payload: decoded, functionError: response.FunctionError })}\n` + ); + } else { + if (response.FunctionError) { + process.stderr.write(`FunctionError: ${response.FunctionError}\n`); + } + process.stdout.write(`${typeof decoded === 'string' ? decoded : JSON.stringify(decoded, null, 2)}\n`); + } + + if (response.FunctionError) { + // Lambda-level errors must produce a non-zero exit so scripted callers + // can detect them via $? — payload is preserved for diagnostics in the + // structured error. + return { + success: false, + error: new ValidationError(`Lambda FunctionError: ${response.FunctionError}`), + }; + } + + return { + success: true, + payload: decoded, + ...(response.StatusCode !== undefined && { statusCode: response.StatusCode }), + }; + } catch (err) { + if (err instanceof Error) { + return { success: false, error: err }; + } + return { success: false, error: new Error(String(err)) }; + } +} diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 1cd58c625..95e2ce300 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -63,6 +63,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, deployedState: { targets: { @@ -127,6 +128,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, }); const result = resolveAgentContext(context, {}); @@ -171,6 +173,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, deployedState: { targets: { @@ -225,6 +228,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/logs/__tests__/interceptor.test.ts b/src/cli/commands/logs/__tests__/interceptor.test.ts new file mode 100644 index 000000000..ed6ba8ab2 --- /dev/null +++ b/src/cli/commands/logs/__tests__/interceptor.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for `handleLogsInterceptor` — the new error paths added in deep-research: + * - SIGINT/AbortSignal cleanly returns success + * - ResourceNotFoundException maps to a user-friendly remediation + * - `--limit` validates as positive integer + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockEnsureManagedForLogs = vi.fn(); +const mockResolveTargets = vi.fn(); +const mockStreamLogs = vi.fn(); +const mockSearchLogs = vi.fn(); + +vi.mock('../../../../lib', () => ({ + ConfigIO: class { + resolveAWSDeploymentTargets = mockResolveTargets; + }, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + ValidationError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ValidationError'; + } + }, +})); + +vi.mock('../../../aws/cloudwatch', () => ({ + streamLogs: (opts: unknown) => mockStreamLogs(opts), + searchLogs: (opts: unknown) => mockSearchLogs(opts), +})); + +vi.mock('../../shared/interceptor-mode-check', () => ({ + ensureManagedForLogs: mockEnsureManagedForLogs, +})); + +const { handleLogsInterceptor } = await import('../interceptor'); + +const baseEntry = { + mode: 'managed' as const, + interceptorArn: 'arn:aws:lambda:us-east-1:111111111111:function:p-interceptor-auth', + interceptorFunctionName: 'p-interceptor-auth', + interceptorRoleArn: 'arn:aws:iam::111111111111:role/auth-role', +}; + +const baseTarget = { name: 'default', account: '111111111111', region: 'us-east-1', profile: 'deploy' }; + +async function* yieldNothing() { + // Empty async generator +} + +// eslint-disable-next-line require-yield, @typescript-eslint/require-await -- async generator fixture that throws synchronously to simulate CloudWatch errors +async function* throwResourceNotFound() { + const err = new Error('Log group does not exist.'); + err.name = 'ResourceNotFoundException'; + throw err; +} + +// eslint-disable-next-line require-yield, @typescript-eslint/require-await -- async generator fixture that throws synchronously to simulate AbortController errors +async function* throwAbortError() { + const err = new Error('aborted'); + err.name = 'AbortError'; + throw err; +} + +describe('handleLogsInterceptor', () => { + beforeEach(() => { + mockEnsureManagedForLogs.mockReset(); + mockResolveTargets.mockReset(); + mockStreamLogs.mockReset(); + mockSearchLogs.mockReset(); + + mockEnsureManagedForLogs.mockResolvedValue({ entry: baseEntry, targetName: 'default' }); + mockResolveTargets.mockResolvedValue([baseTarget]); + }); + + afterEach(() => vi.clearAllMocks()); + + it('returns failure with --name remediation when --name missing', async () => { + const r = await handleLogsInterceptor({}); + expect(r.success).toBe(false); + }); + + it('returns failure when interceptorFunctionName missing in deployed-state', async () => { + mockEnsureManagedForLogs.mockResolvedValue({ + entry: { ...baseEntry, interceptorFunctionName: undefined }, + targetName: 'default', + }); + const r = await handleLogsInterceptor({ name: 'auth' }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.message).toMatch(/has no Lambda function/); + expect(r.error.message).toMatch(/agentcore deploy/); + } + }); + + it('maps ResourceNotFoundException to a structured ResourceNotFoundError with invoke remediation', async () => { + mockStreamLogs.mockImplementation(() => throwResourceNotFound()); + const r = await handleLogsInterceptor({ name: 'auth', follow: true }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.name).toBe('ResourceNotFoundError'); + expect(r.error.message).toMatch(/Log group/); + expect(r.error.message).toMatch(/agentcore invoke interceptor --name auth/); + } + }); + + it('returns success when streamLogs aborts via AbortError (Ctrl-C path)', async () => { + mockStreamLogs.mockImplementation(() => throwAbortError()); + const r = await handleLogsInterceptor({ name: 'auth', follow: true }); + expect(r.success).toBe(true); + }); + + it('rejects --limit when value is not a positive integer', async () => { + mockSearchLogs.mockImplementation(() => yieldNothing()); + const r = await handleLogsInterceptor({ name: 'auth', since: '1h', limit: 'abc' }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.message).toMatch(/--limit must be a positive integer/); + } + }); + + it('rejects --limit when value is zero', async () => { + mockSearchLogs.mockImplementation(() => yieldNothing()); + const r = await handleLogsInterceptor({ name: 'auth', since: '1h', limit: '0' }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.message).toMatch(/--limit must be a positive integer/); + } + }); + + it('removes the SIGINT listener after a successful streamLogs run', async () => { + const before = process.listenerCount('SIGINT'); + mockStreamLogs.mockImplementation(() => yieldNothing()); + await handleLogsInterceptor({ name: 'auth', follow: true }); + const after = process.listenerCount('SIGINT'); + expect(after).toBe(before); + }); + + it('removes the SIGINT listener after streamLogs aborts', async () => { + const before = process.listenerCount('SIGINT'); + mockStreamLogs.mockImplementation(() => throwAbortError()); + await handleLogsInterceptor({ name: 'auth', follow: true }); + const after = process.listenerCount('SIGINT'); + expect(after).toBe(before); + }); + + it('passes a populated abortSignal to streamLogs in follow mode', async () => { + mockStreamLogs.mockImplementation(() => yieldNothing()); + await handleLogsInterceptor({ name: 'auth', follow: true }); + expect(mockStreamLogs).toHaveBeenCalledOnce(); + const arg = mockStreamLogs.mock.calls[0]![0] as { abortSignal?: AbortSignal }; + expect(arg.abortSignal).toBeDefined(); + // signal is fresh (not yet aborted) at the time of the call + expect(arg.abortSignal!.aborted).toBe(false); + }); +}); diff --git a/src/cli/commands/logs/command.tsx b/src/cli/commands/logs/command.tsx index ba6d1ef21..d036fca1d 100644 --- a/src/cli/commands/logs/command.tsx +++ b/src/cli/commands/logs/command.tsx @@ -1,9 +1,11 @@ import { getErrorMessage } from '../../errors'; import { handleLogsEval } from '../../operations/eval'; import type { LogsEvalOptions } from '../../operations/eval'; +import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import { handleLogs } from './action'; +import { type LogsInterceptorOptions, handleLogsInterceptor } from './interceptor'; import type { LogsOptions } from './types'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; @@ -67,4 +69,34 @@ export const registerLogs = (program: Command) => { process.exit(1); } }); + + logsCmd + .command('interceptor') + .description('Stream or search Lambda interceptor logs (managed mode only)') + .option('--name ', 'Interceptor name (required)') + .option('--target ', 'Deployment target (defaults to first target)') + .option('--since