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 0dfb08eea..3463bf514 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..62e5d3d3e --- /dev/null +++ b/docs/interceptors.md @@ -0,0 +1,203 @@ +# 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 +``` + +Managed interceptors can attach extra IAM permissions to their execution role with `--additional-policies` (a +comma-separated list of JSON policy-document file paths relative to the interceptor's code directory, or managed-policy +ARNs): + +```bash +agentcore add interceptor \ + --name auth-check \ + --gateway my-gateway \ + --interception-points REQUEST \ + --template jwt-scope-authorizer \ + --additional-policies execution-role-policy.json,arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess +``` + +## 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`. + +## Interactive (TUI) + +Running `agentcore add interceptor` with no flags (in a TTY) launches the interactive wizard. It is also reachable from +the top-level `agentcore add` menu under **Interceptor**. The wizard collects the same fields as the flags above: + +``` +name → gateway → interception points → mode + ├─ managed: template → runtime → advanced → confirm + └─ external: Lambda ARN → confirm +``` + +The **Advanced** step is a multi-select for the optional managed-mode settings — pick only the ones you need and the +wizard injects just those sub-steps: + +| Advanced setting | Sub-step | Default | +| ----------------------- | ------------------------------------------------- | -------- | +| Lambda timeout | Timeout in seconds (1–300) | `30` | +| Additional IAM policies | Comma-separated policy file paths or managed ARNs | _(none)_ | +| Pass request headers | Yes / No | `Yes` | + +Removal is also interactive: `agentcore remove interceptor` (or the **Interceptor** entry in `agentcore remove`) lists +your interceptors, previews exactly what will change — including the scaffolded `app//` directory for managed mode +— and confirms before writing. + +## 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. + +`agentcore status` lists interceptors under their own section, showing deployment state (`deployed`, `local-only`, +`pending-removal`) alongside mode and interception points (e.g. `managed — REQUEST+RESPONSE`). + +## 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/npm-shrinkwrap.json b/npm-shrinkwrap.json index ab77a3a37..0625c5fca 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.16.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.16.0", + "version": "0.17.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -20,6 +20,7 @@ "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", "@aws-sdk/client-efs": "^3.1049.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-s3files": "^3.1049.0", @@ -244,21 +245,21 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "53.28.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.28.0.tgz", - "integrity": "sha512-pZS+9bLGv2tCqcgxfA0WD3XjcqT3yE4ICvKeJEicw6aTdCxBl8FQ/AUsorY/6f2JrMS3kUQgvhXxA30MWcji0A==", + "version": "53.18.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.18.0.tgz", + "integrity": "sha512-/fa6rOpokkfa5tVIdhsaexQq5MVVTSsZSD1Tu45YcrdyGRusGrM9RlPMCPrwvMS1UfdVFBhcgO9dl9ODWAWOeQ==", "bundleDependencies": ["jsonschema", "semver"], "license": "Apache-2.0", "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.0" + "jsonschema": "~1.4.1", + "semver": "^7.7.4" }, "engines": { "node": ">= 18.0.0" } }, "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { - "version": "1.5.0", + "version": "1.4.1", "inBundle": true, "license": "MIT", "engines": { @@ -266,7 +267,7 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.8.0", + "version": "7.7.4", "inBundle": true, "license": "ISC", "bin": { @@ -933,20 +934,20 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1057.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1057.0.tgz", - "integrity": "sha512-REASfgMI9i8k55OJSdSWn7rcoJIKllWMfffoR/tbu4+JLcbrV9j7uPKQg085d0w3Vx3NRrjoNlBjijq7W2dIeQ==", + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1048.0.tgz", + "integrity": "sha512-l8ghHdi/re43kWU+Zsz/I0i2vJIoybTfkMverrViIe2f7y7nYIh+ULdMTCaJeRUFf6WjqM4CC8iDJ2Weml7EqQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/credential-provider-node": "^3.972.47", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1508,20 +1509,20 @@ } }, "node_modules/@aws-sdk/client-efs": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-efs/-/client-efs-3.1049.0.tgz", - "integrity": "sha512-gFjP27S8OYbpm/HUrCcYriqTprD3bYBdbOP1eEtZkrKnDKE9GsX+hZiFRd/mFjzoEHcduK9Emtw7U3oNYrX4DA==", + "version": "3.1060.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-efs/-/client-efs-3.1060.0.tgz", + "integrity": "sha512-U8NCLtUvBExz/cND3gY7GhdIhe18ht3OylZIg375bk6s04+dp8EBEcgB7EgWxOTn1Px+6xoJ1TuO1rQW3HT4rg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-node": "^3.972.43", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/credential-provider-node": "^3.972.50", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1904,20 +1905,20 @@ } }, "node_modules/@aws-sdk/client-s3files": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3files/-/client-s3files-3.1049.0.tgz", - "integrity": "sha512-nqOZ5SGNmaaUV/AmTFulGVWzDclYt/1Yk/rPvbqdre40aBi+2rlY0EauVcSDszXzUc5AjPMNrOINQ9z3SXq1dA==", + "version": "3.1060.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3files/-/client-s3files-3.1060.0.tgz", + "integrity": "sha512-6F3ReLewb4AKOZ6SdlKL1x0q7rv1kPGd1srvqr719lp5+AQCvhwlMebSRwSis6E3aktpfKtVPskbKeWmZDlBsQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-node": "^3.972.43", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/credential-provider-node": "^3.972.50", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2177,17 +2178,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", - "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "version": "3.974.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.17.tgz", + "integrity": "sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@aws-sdk/xml-builder": "^3.972.26", + "@aws-sdk/types": "^3.973.10", + "@aws-sdk/xml-builder": "^3.972.27", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.5", - "@smithy/signature-v4": "^5.4.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2259,15 +2260,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", - "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.43.tgz", + "integrity": "sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2275,17 +2276,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", - "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.45.tgz", + "integrity": "sha512-w9PuOoKCt6+xoESvY+zlV0u3PKQ0mVL259PcsVR6a3S/uYJJHnIi4r1NxdJHEcNldUVRIciltWnFMGBR4YEm3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2293,23 +2294,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.46.tgz", - "integrity": "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.48.tgz", + "integrity": "sha512-+6BQ6Lrnc+EyAGElLRW6j+Sa+RirPHnIJsobvYO6nnyK+oGKmz1ne/ieclbLWyjyDKEU3/JVJWcWY3VLFPvGtQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/credential-provider-env": "^3.972.41", - "@aws-sdk/credential-provider-http": "^3.972.43", - "@aws-sdk/credential-provider-login": "^3.972.45", - "@aws-sdk/credential-provider-process": "^3.972.41", - "@aws-sdk/credential-provider-sso": "^3.972.45", - "@aws-sdk/credential-provider-web-identity": "^3.972.45", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/credential-provider-imds": "^4.3.6", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/credential-provider-env": "^3.972.43", + "@aws-sdk/credential-provider-http": "^3.972.45", + "@aws-sdk/credential-provider-login": "^3.972.47", + "@aws-sdk/credential-provider-process": "^3.972.43", + "@aws-sdk/credential-provider-sso": "^3.972.47", + "@aws-sdk/credential-provider-web-identity": "^3.972.47", + "@aws-sdk/nested-clients": "^3.997.15", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2317,16 +2318,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.45", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", - "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "version": "3.972.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.47.tgz", + "integrity": "sha512-Iy2ebWVgrZBH05464uJiQYu6HSSiROnwVZptthEFXx2gWjo1ORCxEAFZB5Cr2MdfrSnZ+0QUPkZ1ZpCqpkUrLQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/nested-clients": "^3.997.15", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2334,21 +2335,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.47", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.47.tgz", - "integrity": "sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==", + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.50.tgz", + "integrity": "sha512-b05Aelq5cqAvCCDQjCYacl0XmR8QhBNSqLbsdISkQmlQBa5oPS66zYPteWcSp5LswbpoIe552EUGjluKiadBig==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.41", - "@aws-sdk/credential-provider-http": "^3.972.43", - "@aws-sdk/credential-provider-ini": "^3.972.46", - "@aws-sdk/credential-provider-process": "^3.972.41", - "@aws-sdk/credential-provider-sso": "^3.972.45", - "@aws-sdk/credential-provider-web-identity": "^3.972.45", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/credential-provider-imds": "^4.3.6", - "@smithy/types": "^4.14.2", + "@aws-sdk/credential-provider-env": "^3.972.43", + "@aws-sdk/credential-provider-http": "^3.972.45", + "@aws-sdk/credential-provider-ini": "^3.972.48", + "@aws-sdk/credential-provider-process": "^3.972.43", + "@aws-sdk/credential-provider-sso": "^3.972.47", + "@aws-sdk/credential-provider-web-identity": "^3.972.47", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2356,15 +2357,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", - "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.43.tgz", + "integrity": "sha512-GPokLNyvTfCmuaHk+v3GKVs4ZT3cMu5kgS2a+NPkOMt96cq6fSIK0g+mZHpGS6Cd4QGrPKesANEaLUKgOskTzg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2372,17 +2373,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.45", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", - "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "version": "3.972.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.47.tgz", + "integrity": "sha512-0AzvLrzlvJs0DzbeWGvNj+bX3Uzd7VNS6vDqCOdZzBlCGKGd78uxctJSW9iK/Rt/nxiJqpTvrYQlVJ4guVM2Dw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/token-providers": "3.1056.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/nested-clients": "^3.997.15", + "@aws-sdk/token-providers": "3.1060.0", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2390,16 +2391,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.45", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", - "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "version": "3.972.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.47.tgz", + "integrity": "sha512-eksfbUErOejUAGWBAcNqaP7IX21oUOEo73d9R56k9Ua4d57qS90NEYkWJsuSGzTXMFulCu17qXJI/qGmM7hvoA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/nested-clients": "^3.997.15", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2739,20 +2740,20 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", - "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "version": "3.997.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.15.tgz", + "integrity": "sha512-Fpri1/PXKMKveORZ7E00VLTlWS5DkfZkW70PUE+bOnpWpAeHAQLoiDHhkzN3kNWbbSsGg64+IZYiq/EZgME3Mg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/signature-v4-multi-region": "^3.996.30", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/signature-v4-multi-region": "^3.996.31", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2776,14 +2777,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", - "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "version": "3.996.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.31.tgz", + "integrity": "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/signature-v4": "^5.4.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/types": "^3.973.10", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2791,16 +2792,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1056.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", - "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "version": "3.1060.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1060.0.tgz", + "integrity": "sha512-6NZaMKkFhpaNiwLpHi1sZaYjidL/lCJE6ME6NxwA8gv9vQna+Kr0j4OFwVoz6tANRWM3WbGz6jiPsGX/Vkjwow==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.17", + "@aws-sdk/nested-clients": "^3.997.15", + "@aws-sdk/types": "^3.973.10", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2808,12 +2809,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", - "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "version": "3.973.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.10.tgz", + "integrity": "sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5009,13 +5010,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", - "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz", + "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5023,13 +5024,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz", - "integrity": "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.7.tgz", + "integrity": "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5107,13 +5108,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", - "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", + "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5301,13 +5302,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", - "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.6.tgz", + "integrity": "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5393,13 +5394,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", - "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", + "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5425,9 +5426,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz", + "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6916,9 +6917,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.257.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.257.0.tgz", - "integrity": "sha512-GoHfWklrBJcMwLtDlY64pvaT7cD2KyDXC8sik89DR6jHl6nQsBtYTKSJCM+C/k4jgXaecbv8myNX75FySejq0A==", + "version": "2.250.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.250.0.tgz", + "integrity": "sha512-8U8/S9VcmKSc3MHZWiB7P0IecgXoohI8Ya3dgtZMgbzC4mB+MEQmsYBeNgm4vzGQdRos8HjQLnFX1IBlZh7jQA==", "bundleDependencies": [ "@balena/dockerignore", "@aws-cdk/cloud-assembly-api", @@ -6938,8 +6939,8 @@ "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.273", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", - "@aws-cdk/cloud-assembly-api": "^2.2.4", - "@aws-cdk/cloud-assembly-schema": "^53.25.0", + "@aws-cdk/cloud-assembly-api": "^2.2.0", + "@aws-cdk/cloud-assembly-schema": "^53.0.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.3", @@ -6960,23 +6961,33 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.4", + "version": "2.2.0", + "bundleDependencies": ["jsonschema", "semver"], "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.0" + "jsonschema": "~1.4.1", + "semver": "^7.7.4" }, "engines": { "node": ">= 18.0.0" }, "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.25.0" + "@aws-cdk/cloud-assembly-schema": ">=53.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { + "version": "1.4.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { - "version": "7.8.0", + "version": "7.7.4", "dev": true, "inBundle": true, "license": "ISC", @@ -7103,7 +7114,7 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/fast-uri": { - "version": "3.1.2", + "version": "3.1.0", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 4da5e7f11..f93ac36cf 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", "@aws-sdk/client-efs": "^3.1049.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-s3files": "^3.1049.0", diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 94512c4b6..a7ab6bc6b 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; @@ -362,15 +370,25 @@ export class AgentCoreStack extends Stack { } this.application = new AgentCoreApplication(this, 'Application', appProps as any); - // 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 @@ -419,8 +437,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" } } @@ -517,6 +535,30 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "evaluators/python-lambda/lambda_function.py", "evaluators/python-lambda/pyproject.toml", "harness/invoke.py.template", + "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 f2518baf3..06a3985f7 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 91f4a6a91..4cc24a1d6 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -63,15 +63,25 @@ export class AgentCoreStack extends Stack { } this.application = new AgentCoreApplication(this, 'Application', appProps as any); - // 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..9d9e33f91 --- /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 (preflight.ts) + * - logs/invoke interceptor external-mode remediation messages + * (interceptor-mode-check.ts) + * + * 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 f5b1173bb..c7d9e7f64 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -3,6 +3,7 @@ import type { DatasetDeployedState, DeployedState, EvaluatorDeployedState, + InterceptorDeployedState, MemoryDeployedState, OnlineEvalDeployedState, PolicyDeployedState, @@ -244,6 +245,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. * @@ -434,6 +486,8 @@ export interface BuildDeployedStateOptions { } >; datasets?: Record; + /** Interceptor states keyed by interceptor name. Stored under mcp.interceptors. */ + interceptors?: Record; } /** @@ -456,6 +510,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta runtimeEndpoints, harnesses, datasets, + interceptors, } = opts; const targetState: TargetDeployedState = { resources: { @@ -468,10 +523,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 13f696587..1a3d6e9a5 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -11,6 +11,7 @@ import { parseDatasetOutputs, parseEvaluatorOutputs, parseGatewayOutputs, + parseInterceptorOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, parsePolicyEngineOutputs, @@ -504,6 +505,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise d.name); const datasets = parseDatasetOutputs(outputs, datasetNames); + // Parse interceptor outputs. Mode is read directly from the project spec + // (CDK echoes it as a CFN output too, but we already know it locally and + // skipping the round-trip keeps the parser simple). + const interceptorSpecs = (context.projectSpec.interceptors ?? []).map(i => ({ + name: i.name, + mode: i.config.managed ? ('managed' as const) : ('external' as const), + })); + const interceptors = parseInterceptorOutputs(outputs, interceptorSpecs); + endStep('success'); // Post-CDK: deploy imperative resources (harness) — preview mode only @@ -572,6 +582,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { } } ); + + // ── 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 f7fcabc3c..500a24292 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -65,6 +65,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + interceptors: [], }, deployedState: { targets: { @@ -131,6 +132,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + interceptors: [], }, }); const result = resolveAgentContext(context, {}); @@ -177,6 +179,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + interceptors: [], }, deployedState: { targets: { @@ -233,6 +236,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + 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 39a50f33b..e238cdf4c 100644 --- a/src/cli/commands/logs/command.tsx +++ b/src/cli/commands/logs/command.tsx @@ -2,8 +2,10 @@ import { COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; import { handleLogsEval } from '../../operations/eval'; import type { LogsEvalOptions } from '../../operations/eval'; +import { runCliCommand } from '../../telemetry/cli-command-run.js'; 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