From 1d81a949f0aec6ec21d659c7ac31d0efeeca9127 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 20 May 2026 15:58:30 +0000 Subject: [PATCH 1/6] feat: add Lambda interceptors for AgentCore gateways Adds end-to-end support for Lambda interceptors at gateway REQUEST and RESPONSE points: a new InterceptorPrimitive, three vended templates (pass-through, jwt-scope-authorizer, tools-list-filter) for both Python and Node.js, deploy-side preflight, and `logs interceptor` / `invoke interceptor` verbs. Primitive surface ----------------- - `agentcore add interceptor --name --gateway --interception-points --template --runtime [--timeout] [--no-pass-request-headers] [--additional-policies]` for managed mode - External mode via direct `agentcore.json` edit referencing a customer-provided unqualified Lambda ARN - `agentcore remove interceptor --name` reconciles deploy state on next `agentcore deploy` - `agentcore logs interceptor --name [--follow|--since|--until]` tails CloudWatch for managed interceptors; external interceptors short- circuit to a remediation message pointing at `aws logs tail` - `agentcore invoke interceptor --name [--payload]` invokes the underlying Lambda for managed interceptors with the same external- mode remediation message Schema ------ - `interceptors` array on `AgentCoreProjectSpec` with cross-field superRefine for cardinality (max 1 per point per gateway, max 2 per gateway) and gateway-name reference checks - Lambda ARN regex tightened to reject version/alias qualifiers - `passRequestHeaders`, `entrypoint`, `timeoutSeconds`, `runtime` are optional with consumption-time defaults so user-edited `agentcore.json` stays sparse across CLI round-trips Deploy ------ - `validateInterceptors()` checks managed-mode codeLocation existence and emits a masked cross-account WARN for external mode (with full `aws lambda add-permission` remediation snippet); also wired into `agentcore validate` so the pre-check works without invoking deploy - `validateGatewayTargetLambdas()` does a best-effort `lambda:GetFunction` per `lambda-function-arn` target and WARNs on any failure (NotFound, AccessDenied, throttle) so a typo'd ARN surfaces before CFN ROLLBACK - Account IDs go through `maskAccountId()` for all user-visible warning output (PII masking) Templates --------- - `pass-through`, `jwt-scope-authorizer`, `tools-list-filter` for Python (`pyproject.toml` + hatchling) and Node.js (`index.mjs` + `package.json`) - `PackageName` Handlebars var for NPM-safe lowercase package naming Telemetry --------- - `has_cross_account_warning` boolean on the InterceptorPrimitive command-run telemetry schema --- README.md | 19 +- docs/interceptors.md | 172 ++++ npm-shrinkwrap.json | 826 +++++++++++++----- package.json | 1 + schemas/agentcore.schema.v1.json | 100 +++ .../assets.snapshot.test.ts.snap | 69 +- src/assets/cdk/bin/cdk.ts | 12 +- src/assets/cdk/lib/cdk-stack.ts | 28 +- src/assets/cdk/package.json | 4 +- src/assets/cdk/test/cdk.test.ts | 1 + .../jwt-scope-authorizer/README.md | 23 + .../execution-role-policy.json | 10 + .../jwt-scope-authorizer/index.mjs | 76 ++ .../jwt-scope-authorizer/package.json | 10 + .../node-lambda/pass-through/README.md | 39 + .../pass-through/execution-role-policy.json | 10 + .../node-lambda/pass-through/index.mjs | 52 ++ .../node-lambda/pass-through/package.json | 10 + .../node-lambda/tools-list-filter/README.md | 27 + .../execution-role-policy.json | 10 + .../node-lambda/tools-list-filter/index.mjs | 48 + .../tools-list-filter/package.json | 10 + .../jwt-scope-authorizer/README.md | 25 + .../execution-role-policy.json | 10 + .../jwt-scope-authorizer/handler.py | 83 ++ .../jwt-scope-authorizer/pyproject.toml | 13 + .../python-lambda/pass-through/README.md | 40 + .../pass-through/execution-role-policy.json | 10 + .../python-lambda/pass-through/handler.py | 53 ++ .../python-lambda/pass-through/pyproject.toml | 13 + .../python-lambda/tools-list-filter/README.md | 27 + .../execution-role-policy.json | 10 + .../tools-list-filter/handler.py | 66 ++ .../tools-list-filter/pyproject.toml | 13 + src/cli/aws/__tests__/mask.test.ts | 61 ++ src/cli/aws/mask.ts | 43 + .../__tests__/interceptor-outputs.test.ts | 107 +++ src/cli/cloudformation/outputs.ts | 66 +- src/cli/commands/deploy/actions.ts | 11 + .../__tests__/import-gateway-spec.test.ts | 2 +- .../__tests__/import-gateway-targets.test.ts | 6 - src/cli/commands/invoke/command.tsx | 44 +- src/cli/commands/invoke/interceptor.ts | 123 +++ .../commands/logs/__tests__/action.test.ts | 4 + .../logs/__tests__/interceptor.test.ts | 161 ++++ src/cli/commands/logs/command.tsx | 32 + src/cli/commands/logs/interceptor.ts | 157 ++++ src/cli/commands/remove/command.tsx | 1 + src/cli/commands/remove/types.ts | 1 + .../__tests__/interceptor-mode-check.test.ts | 142 +++ .../commands/shared/interceptor-mode-check.ts | 98 +++ src/cli/commands/validate/action.ts | 15 +- .../__tests__/checks-extended.test.ts | 10 + src/cli/logging/remove-logger.ts | 1 + .../ab-test/__tests__/promote.test.ts | 1 + .../agent/generate/write-agent-to-project.ts | 1 + .../__tests__/post-deploy-ab-tests.test.ts | 1 + .../post-deploy-config-bundles.test.ts | 1 + .../post-deploy-http-gateways.test.ts | 1 + .../__tests__/preflight-interceptors.test.ts | 161 ++++ src/cli/operations/deploy/preflight.ts | 163 +++- src/cli/operations/deploy/teardown.ts | 2 +- .../operations/dev/__tests__/config.test.ts | 21 + .../__tests__/resolve-ui-dist-dir.test.ts | 3 +- src/cli/primitives/GatewayTargetPrimitive.ts | 2 +- src/cli/primitives/InterceptorPrimitive.ts | 428 +++++++++ .../__tests__/GatewayPrimitive.test.ts | 1 + .../__tests__/InterceptorPrimitive.test.ts | 306 +++++++ .../primitives/__tests__/auth-utils.test.ts | 1 + src/cli/primitives/index.ts | 3 + src/cli/primitives/registry.ts | 3 + src/cli/project.ts | 1 + src/cli/telemetry/schemas/command-run.ts | 19 + src/cli/templates/InterceptorRenderer.ts | 36 + src/cli/tui/screens/add/AddFlow.tsx | 25 + src/cli/tui/screens/add/AddScreen.tsx | 1 + src/cli/tui/screens/deploy/useDeployFlow.ts | 9 + src/cli/tui/screens/remove/RemoveFlow.tsx | 25 + src/lib/packaging/node.ts | 24 +- src/schema/schemas/agentcore-project.ts | 80 ++ src/schema/schemas/deployed-state.ts | 19 + .../primitives/__tests__/interceptor.test.ts | 254 ++++++ src/schema/schemas/primitives/index.ts | 21 + src/schema/schemas/primitives/interceptor.ts | 121 +++ 84 files changed, 4481 insertions(+), 258 deletions(-) create mode 100644 docs/interceptors.md create mode 100644 src/assets/interceptors/node-lambda/jwt-scope-authorizer/README.md create mode 100644 src/assets/interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json create mode 100644 src/assets/interceptors/node-lambda/jwt-scope-authorizer/index.mjs create mode 100644 src/assets/interceptors/node-lambda/jwt-scope-authorizer/package.json create mode 100644 src/assets/interceptors/node-lambda/pass-through/README.md create mode 100644 src/assets/interceptors/node-lambda/pass-through/execution-role-policy.json create mode 100644 src/assets/interceptors/node-lambda/pass-through/index.mjs create mode 100644 src/assets/interceptors/node-lambda/pass-through/package.json create mode 100644 src/assets/interceptors/node-lambda/tools-list-filter/README.md create mode 100644 src/assets/interceptors/node-lambda/tools-list-filter/execution-role-policy.json create mode 100644 src/assets/interceptors/node-lambda/tools-list-filter/index.mjs create mode 100644 src/assets/interceptors/node-lambda/tools-list-filter/package.json create mode 100644 src/assets/interceptors/python-lambda/jwt-scope-authorizer/README.md create mode 100644 src/assets/interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json create mode 100644 src/assets/interceptors/python-lambda/jwt-scope-authorizer/handler.py create mode 100644 src/assets/interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml create mode 100644 src/assets/interceptors/python-lambda/pass-through/README.md create mode 100644 src/assets/interceptors/python-lambda/pass-through/execution-role-policy.json create mode 100644 src/assets/interceptors/python-lambda/pass-through/handler.py create mode 100644 src/assets/interceptors/python-lambda/pass-through/pyproject.toml create mode 100644 src/assets/interceptors/python-lambda/tools-list-filter/README.md create mode 100644 src/assets/interceptors/python-lambda/tools-list-filter/execution-role-policy.json create mode 100644 src/assets/interceptors/python-lambda/tools-list-filter/handler.py create mode 100644 src/assets/interceptors/python-lambda/tools-list-filter/pyproject.toml create mode 100644 src/cli/aws/__tests__/mask.test.ts create mode 100644 src/cli/aws/mask.ts create mode 100644 src/cli/cloudformation/__tests__/interceptor-outputs.test.ts create mode 100644 src/cli/commands/invoke/interceptor.ts create mode 100644 src/cli/commands/logs/__tests__/interceptor.test.ts create mode 100644 src/cli/commands/logs/interceptor.ts create mode 100644 src/cli/commands/shared/__tests__/interceptor-mode-check.test.ts create mode 100644 src/cli/commands/shared/interceptor-mode-check.ts create mode 100644 src/cli/operations/deploy/__tests__/preflight-interceptors.test.ts create mode 100644 src/cli/primitives/InterceptorPrimitive.ts create mode 100644 src/cli/primitives/__tests__/InterceptorPrimitive.test.ts create mode 100644 src/cli/templates/InterceptorRenderer.ts create mode 100644 src/schema/schemas/primitives/__tests__/interceptor.test.ts create mode 100644 src/schema/schemas/primitives/interceptor.ts diff --git a/README.md b/README.md index 42aeda5ec..0748edea2 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,24 @@ agentcore invoke ### Resource Management -| Command | Description | -| -------- | ---------------------------------------------------- | -| `add` | Add agents, memory, credentials, evaluators, targets | -| `remove` | Remove resources from project | +| Command | Description | +| -------- | ------------------------------------------------------------------ | +| `add` | Add agents, memory, credentials, evaluators, targets, interceptors | +| `remove` | Remove resources from project | > **Note**: Run `agentcore deploy` after `add` or `remove` to update resources in AWS. +#### Interceptors + +| Command | Description | +| ------------------------------- | ------------------------------------------------------------------- | +| `add interceptor` | Add a Lambda interceptor (managed scaffold or BYO ARN) to a gateway | +| `remove interceptor` | Remove an interceptor | +| `logs interceptor --name ` | Tail or search managed interceptor CloudWatch logs | +| `invoke interceptor --name ` | Invoke a managed interceptor with a synthetic payload | + +See [docs/interceptors.md](docs/interceptors.md) for templates, schema, and the cross-account behavior. + ### Observability | Command | Description | diff --git a/docs/interceptors.md b/docs/interceptors.md new file mode 100644 index 000000000..b6c37e8a2 --- /dev/null +++ b/docs/interceptors.md @@ -0,0 +1,172 @@ +# Lambda Interceptors + +AgentCore Gateway Interceptors are customer-owned Lambda functions that the gateway invokes on every MCP request to +inspect, transform, or short-circuit traffic. They run at one of two interception points: + +- **REQUEST** — before the gateway invokes the target. +- **RESPONSE** — after the target returns, before the gateway replies to the caller. + +A gateway can carry up to **2 interceptors** (one REQUEST + one RESPONSE), or a single interceptor wired to both points. + +## Modes + +The CLI supports two first-class modes, mirroring the existing code-based evaluator pattern: + +| Mode | What the CLI owns | When to use | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| **Managed** (default) | Scaffolds a templated Lambda project under `app//`, packages it, deploys it, renders the resulting ARN into the gateway's `InterceptorConfigurations`. | You want the CLI to own the source tree and deploy artifact end-to-end. | +| **External** | You pass an already-deployed Lambda ARN with `--lambda-arn`. The CLI plugs the ARN into the gateway and grants `lambda:InvokeFunction` to the gateway role. | You have a centralized auth Lambda or a third-party-owned function. | + +## Quick start — managed + +```bash +# Single REQUEST-point interceptor with the JWT scope authorizer template +agentcore add interceptor \ + --name auth-check \ + --gateway my-gateway \ + --interception-points REQUEST \ + --template jwt-scope-authorizer \ + --runtime python3.12 + +# Edit app/auth-check/handler.py with your scope rules, then: +agentcore deploy +``` + +## Quick start — external (BYO ARN) + +```bash +agentcore add interceptor \ + --name central-auth \ + --gateway my-gateway \ + --interception-points REQUEST \ + --lambda-arn arn:aws:lambda:us-east-1:111111111111:function:central-auth-prod +``` + +The CLI does not scaffold any code; the only artifact is the JSON entry in `agentcore.json`. + +## Dual-point on a single Lambda + +A single interceptor can serve both REQUEST and RESPONSE on the same gateway: + +```bash +agentcore add interceptor \ + --name dual-point \ + --gateway my-gateway \ + --interception-points REQUEST,RESPONSE \ + --template pass-through \ + --runtime python3.12 +``` + +This counts as one interceptor against the cardinality cap. + +## Templates (managed mode) + +| Template | Point(s) | Purpose | +| ---------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `pass-through` | REQUEST or RESPONSE | Minimal compliant handler. Demonstrates the input/output envelope and the streaming guard. | +| `jwt-scope-authorizer` | REQUEST | Decodes the inbound `Authorization` JWT and short-circuits with a structured 403 if the required scope is missing. | +| `tools-list-filter` | RESPONSE | Strips unauthorized tools from `tools/list` responses based on a customer-supplied `is_authorized()` predicate. | + +Each template ships in both Python 3.12 and Node.js 22.x. Pick with `--runtime python3.12` (default) or +`--runtime nodejs22.x`. + +## Operational verbs + +```bash +# Tail logs for a managed interceptor +agentcore logs interceptor --name auth-check --follow + +# Search logs by time window +agentcore logs interceptor --name auth-check --since 1h --until now + +# Invoke synthetically with a payload file +agentcore invoke interceptor --name auth-check --payload-file ./test-event.json +``` + +For external interceptors, both verbs print a copy-pasteable `aws` CLI remediation and exit non-zero — the CLI doesn't +own those Lambdas. + +## Cross-account external interceptors + +When `--lambda-arn`'s account ID does not match the deploy target's account, the CLI emits a **warning** at preflight +(with masked account IDs) and **continues** the deploy. The deploy itself succeeds — the gateway role's identity policy +grants `lambda:InvokeFunction` on the foreign ARN. What doesn't work yet is the first invocation: AWS Lambda requires a +matching resource-based policy on the function granting the gateway role permission to invoke it. + +Example warning (account IDs masked to last 4 digits): + +``` +WARNING: Cross-account interceptor detected for "central-auth". + Gateway account(s): ****1947 + Lambda: arn:aws:lambda:us-east-1:****1111:function:central-auth-prod + +Deploy will succeed, but the first interceptor invocation will fail until +you add a resource-based policy to the Lambda. Run this in the Lambda's +account (once per interceptor) before sending traffic through the gateway: + + aws lambda add-permission \ + --function-name \ + --statement-id GatewayServiceRoleInvoke \ + --action lambda:InvokeFunction \ + --principal + +Continuing with deploy... +``` + +Run the snippet once in the Lambda's account, before sending traffic through the gateway. + +## Schema + +```jsonc +{ + "interceptors": [ + { + "name": "auth-check", + "gatewayName": "my-gateway", + "interceptionPoints": ["REQUEST"], + "passRequestHeaders": true, + "config": { + "managed": { + "codeLocation": "app/auth-check/", + "entrypoint": "handler.lambda_handler", + "timeoutSeconds": 30, + "runtime": "python3.12", + "additionalPolicies": ["execution-role-policy.json"], + }, + }, + }, + { + "name": "central-auth", + "gatewayName": "my-gateway", + "interceptionPoints": ["RESPONSE"], + "passRequestHeaders": true, + "config": { + "external": { + "lambdaArn": "arn:aws:lambda:us-east-1:111111111111:function:central-auth-prod", + }, + }, + }, + ], +} +``` + +`config.managed` and `config.external` are mutually exclusive (exactly one must be set). + +## Removal + +```bash +agentcore remove interceptor --name auth-check +agentcore deploy +``` + +Managed-mode removal also deletes the scaffolded `app//` directory. External-mode removal touches only the JSON +entry. The next `deploy` reconciles the gateway via CloudFormation — no imperative `UpdateGateway` calls. + +## Limitations / out of scope (P0) + +- PII-redaction template (requires customer-specific patterns). +- Audit-logging template (OpenSearch / S3 wiring). +- Provisioned concurrency. +- Multi-gateway shared interceptor pools. +- Console UX parity. +- Streaming-aware first-invocation guard as active code (shipped commented-in). diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index df58d345c..010ae1141 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -19,6 +19,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", + "@aws-sdk/client-lambda": "^3.893.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/client-sts": "^3.893.0", @@ -208,7 +209,10 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-api/-/cloud-assembly-api-2.2.1.tgz", "integrity": "sha512-24ARpDQzF39UTickUgDH6RIs5otPG4aaKJZ93XUSNwiPSR9T+h7gXSF982+NZVYK+7SetQaqrVbm4lcF6dmXWw==", - "bundleDependencies": ["jsonschema", "semver"], + "bundleDependencies": [ + "jsonschema", + "semver" + ], "license": "Apache-2.0", "dependencies": { "jsonschema": "~1.4.1", @@ -244,7 +248,10 @@ "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"], + "bundleDependencies": [ + "jsonschema", + "semver" + ], "license": "Apache-2.0", "dependencies": { "jsonschema": "~1.4.1", @@ -298,7 +305,10 @@ "version": "2.244.0", "resolved": "https://registry.npmjs.org/@aws-cdk/cx-api/-/cx-api-2.244.0.tgz", "integrity": "sha512-QE1BRNaxKe3+BbH9etBMdVen1AJ555O4R1l0s3CRTP66sx8FW6qYRi1JukquwkEmpf61Oi5fAUNRf8W0IGIoig==", - "bundleDependencies": ["semver", "@aws-cdk/cloud-assembly-api"], + "bundleDependencies": [ + "semver", + "@aws-cdk/cloud-assembly-api" + ], "license": "Apache-2.0", "dependencies": { "@aws-cdk/cloud-assembly-api": "^2.1.1", @@ -313,7 +323,10 @@ }, "node_modules/@aws-cdk/cx-api/node_modules/@aws-cdk/cloud-assembly-api": { "version": "2.1.1", - "bundleDependencies": ["jsonschema", "semver"], + "bundleDependencies": [ + "jsonschema", + "semver" + ], "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -430,7 +443,10 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-api/-/cloud-assembly-api-2.2.2.tgz", "integrity": "sha512-iiypKqfpHMqQ9z6Nwxx42Ha4NCevLVDQ8sphIbqHxSJE5kDe/DCzvh8b2HtlAshWjo44HMhYdfKNLR96S3T4sA==", - "bundleDependencies": ["jsonschema", "semver"], + "bundleDependencies": [ + "jsonschema", + "semver" + ], "license": "Apache-2.0", "dependencies": { "jsonschema": "~1.4.1", @@ -3221,11 +3237,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": ["ppc64"], + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["aix"], + "os": [ + "aix" + ], "engines": { "node": ">=18" } @@ -3234,11 +3254,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": ">=18" } @@ -3247,11 +3271,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": ">=18" } @@ -3260,11 +3288,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": ">=18" } @@ -3273,11 +3305,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": ">=18" } @@ -3286,11 +3322,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": ">=18" } @@ -3299,11 +3339,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["freebsd"], + "os": [ + "freebsd" + ], "engines": { "node": ">=18" } @@ -3312,11 +3356,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["freebsd"], + "os": [ + "freebsd" + ], "engines": { "node": ">=18" } @@ -3325,11 +3373,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3338,11 +3390,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3351,11 +3407,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": ["ia32"], + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3364,11 +3424,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": ["loong64"], + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3377,11 +3441,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": ["mips64el"], + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3390,11 +3458,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": ["ppc64"], + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3403,11 +3475,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": ["riscv64"], + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3416,11 +3492,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": ["s390x"], + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3429,11 +3509,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -3442,11 +3526,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["netbsd"], + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } @@ -3455,11 +3543,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["netbsd"], + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } @@ -3468,11 +3560,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["openbsd"], + "os": [ + "openbsd" + ], "engines": { "node": ">=18" } @@ -3481,11 +3577,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["openbsd"], + "os": [ + "openbsd" + ], "engines": { "node": ">=18" } @@ -3494,11 +3594,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["openharmony"], + "os": [ + "openharmony" + ], "engines": { "node": ">=18" } @@ -3507,11 +3611,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["sunos"], + "os": [ + "sunos" + ], "engines": { "node": ">=18" } @@ -3520,11 +3628,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">=18" } @@ -3533,11 +3645,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": ["ia32"], + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">=18" } @@ -3546,11 +3662,15 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">=18" } @@ -4550,11 +4670,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4563,11 +4687,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4576,11 +4704,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4589,11 +4721,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["freebsd"], + "os": [ + "freebsd" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4602,11 +4738,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4615,11 +4755,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4628,11 +4772,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4641,11 +4789,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", - "cpu": ["ppc64"], + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4654,11 +4806,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", - "cpu": ["s390x"], + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4667,11 +4823,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4680,11 +4840,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4693,11 +4857,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["openharmony"], + "os": [ + "openharmony" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4706,7 +4874,9 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", - "cpu": ["wasm32"], + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", "optional": true, @@ -4723,11 +4893,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -4736,11 +4910,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6178,157 +6356,219 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"] + "os": [ + "android" + ] }, "node_modules/@unrs/resolver-binding-android-arm64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"] + "os": [ + "android" + ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"] + "os": [ + "darwin" + ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"] + "os": [ + "darwin" + ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["freebsd"] + "os": [ + "freebsd" + ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": ["ppc64"], + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": ["riscv64"], + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": ["riscv64"], + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": ["s390x"], + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"] + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": ["wasm32"], + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", "optional": true, @@ -6356,31 +6596,43 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"] + "os": [ + "win32" + ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": ["ia32"], + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"] + "os": [ + "win32" + ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"] + "os": [ + "win32" + ] }, "node_modules/@vitest/coverage-v8": { "version": "4.1.6", @@ -6532,7 +6784,9 @@ "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", "dev": true, "license": "MIT", - "workspaces": ["addons/*"] + "workspaces": [ + "addons/*" + ] }, "node_modules/abort-controller": { "version": "3.0.0", @@ -6977,7 +7231,10 @@ }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { "version": "2.2.0", - "bundleDependencies": ["jsonschema", "semver"], + "bundleDependencies": [ + "jsonschema", + "semver" + ], "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -8555,7 +8812,10 @@ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", "license": "MIT", - "workspaces": ["docs", "benchmarks"] + "workspaces": [ + "docs", + "benchmarks" + ] }, "node_modules/esbuild": { "version": "0.28.0", @@ -9582,7 +9842,9 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -11194,11 +11456,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": ">= 12.0.0" }, @@ -11211,11 +11477,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": ">= 12.0.0" }, @@ -11228,11 +11498,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": ">= 12.0.0" }, @@ -11245,11 +11519,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["freebsd"], + "os": [ + "freebsd" + ], "engines": { "node": ">= 12.0.0" }, @@ -11262,11 +11540,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">= 12.0.0" }, @@ -11279,11 +11561,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">= 12.0.0" }, @@ -11296,11 +11582,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">= 12.0.0" }, @@ -11313,11 +11603,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">= 12.0.0" }, @@ -11330,11 +11624,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">= 12.0.0" }, @@ -11347,11 +11645,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">= 12.0.0" }, @@ -11364,11 +11666,15 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MPL-2.0", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">= 12.0.0" }, @@ -13014,7 +13320,10 @@ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, - "funding": ["https://github.com/sponsors/sxzz", "https://opencollective.com/debug"], + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT" }, "node_modules/on-finished": { @@ -13386,7 +13695,9 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -15256,11 +15567,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": ["ppc64"], + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["aix"], + "os": [ + "aix" + ], "engines": { "node": ">=18" } @@ -15269,11 +15584,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": ">=18" } @@ -15282,11 +15601,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": ">=18" } @@ -15295,11 +15618,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["android"], + "os": [ + "android" + ], "engines": { "node": ">=18" } @@ -15308,11 +15635,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": ">=18" } @@ -15321,11 +15652,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["darwin"], + "os": [ + "darwin" + ], "engines": { "node": ">=18" } @@ -15334,11 +15669,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["freebsd"], + "os": [ + "freebsd" + ], "engines": { "node": ">=18" } @@ -15347,11 +15686,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["freebsd"], + "os": [ + "freebsd" + ], "engines": { "node": ">=18" } @@ -15360,11 +15703,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": ["arm"], + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15373,11 +15720,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15386,11 +15737,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": ["ia32"], + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15399,11 +15754,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": ["loong64"], + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15412,11 +15771,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": ["mips64el"], + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15425,11 +15788,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": ["ppc64"], + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15438,11 +15805,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": ["riscv64"], + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15451,11 +15822,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": ["s390x"], + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15464,11 +15839,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["linux"], + "os": [ + "linux" + ], "engines": { "node": ">=18" } @@ -15477,11 +15856,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["netbsd"], + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } @@ -15490,11 +15873,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["netbsd"], + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } @@ -15503,11 +15890,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["openbsd"], + "os": [ + "openbsd" + ], "engines": { "node": ">=18" } @@ -15516,11 +15907,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["openbsd"], + "os": [ + "openbsd" + ], "engines": { "node": ">=18" } @@ -15529,11 +15924,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["openharmony"], + "os": [ + "openharmony" + ], "engines": { "node": ">=18" } @@ -15542,11 +15941,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["sunos"], + "os": [ + "sunos" + ], "engines": { "node": ">=18" } @@ -15555,11 +15958,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": ["arm64"], + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">=18" } @@ -15568,11 +15975,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": ["ia32"], + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">=18" } @@ -15581,11 +15992,15 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": ["x64"], + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "os": ["win32"], + "os": [ + "win32" + ], "engines": { "node": ">=18" } @@ -16025,7 +16440,10 @@ "version": "11.1.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": ["https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan"], + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { "uuid": "dist/esm/bin/uuid" diff --git a/package.json b/package.json index 3491e443f..c753f4f27 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", + "@aws-sdk/client-lambda": "^3.893.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/client-sts": "^3.893.0", diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index f40695419..a5327b7b9 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -2162,6 +2162,106 @@ "required": ["name", "runtimeRef"], "additionalProperties": false } + }, + "interceptors": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 24, + "pattern": "^[a-zA-Z][a-zA-Z0-9-]{0,23}$" + }, + "gatewayName": { + "type": "string", + "minLength": 1 + }, + "interceptionPoints": { + "minItems": 1, + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "enum": ["REQUEST", "RESPONSE"] + } + }, + "passRequestHeaders": { + "default": true, + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "managed": { + "type": "object", + "properties": { + "codeLocation": { + "type": "string", + "minLength": 1 + }, + "entrypoint": { + "default": "handler.lambda_handler", + "type": "string", + "minLength": 1 + }, + "timeoutSeconds": { + "default": 30, + "type": "integer", + "minimum": 1, + "maximum": 300 + }, + "runtime": { + "default": "python3.12", + "type": "string", + "enum": ["python3.12", "nodejs22.x"] + }, + "additionalPolicies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["codeLocation"], + "additionalProperties": false + }, + "external": { + "type": "object", + "properties": { + "lambdaArn": { + "type": "string", + "minLength": 1, + "pattern": "^arn:[^:]+:lambda:[a-z0-9-]+:\\d{12}:function:[a-zA-Z0-9_-]+$" + } + }, + "required": ["lambdaArn"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "tags": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[\\p{L}\\p{N}\\s_.:/=+\\-@]*$" + }, + "additionalProperties": { + "type": "string", + "maxLength": 256, + "pattern": "^[\\p{L}\\p{N}\\s_.:/=+\\-@]*$" + } + } + }, + "required": ["name", "gatewayName", "interceptionPoints", "config"], + "additionalProperties": false + } } }, "required": ["name", "version"], diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 9fad266c2..9a2eb6731 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -77,13 +77,21 @@ async function main() { // Gateway fields are stored in agentcore.json but may not yet be on the // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them // dynamically and cast the resulting object. + // + // Interceptors live at root in agentcore.json (\`interceptors[]\`). The CDK + // package's AgentCoreMcpSpec carries them in the same MCP-scoped object as + // gateways/runtimes, so we copy the field across at this boundary. Empty + // arrays are normalized to undefined to keep CDK synth clean when no + // interceptors are defined. // eslint-disable-next-line @typescript-eslint/no-explicit-any const specAny = spec as any; - const mcpSpec = specAny.agentCoreGateways?.length + const hasMcp = specAny.agentCoreGateways?.length || specAny.interceptors?.length; + const mcpSpec = hasMcp ? { - agentCoreGateways: specAny.agentCoreGateways, + agentCoreGateways: specAny.agentCoreGateways ?? [], mcpRuntimeTools: specAny.mcpRuntimeTools, unassignedTargets: specAny.unassignedTargets, + interceptors: specAny.interceptors?.length ? specAny.interceptors : undefined, } : undefined; @@ -300,15 +308,25 @@ export class AgentCoreStack extends Stack { spec, }); - // Create AgentCoreMcp if there are gateways configured - if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { - new AgentCoreMcp(this, 'Mcp', { - projectName: spec.name, - mcpSpec, - agentCoreApplication: this.application, - credentials, - projectTags: spec.tags, - }); + // Create AgentCoreMcp if there are gateways or interceptors configured. + // Interceptors are MCP-scoped via the gatewayName reference, so they + // never appear without gateways under valid schema, but the OR guard + // here is defensive — it prevents interceptors from silently vanishing + // if the spec ever reaches synth in a partially-validated state. + if (mcpSpec) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the bundled CDK type may not yet declare interceptors + const interceptorsAny = (mcpSpec as any).interceptors; + const hasGateways = mcpSpec.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; + const hasInterceptors = interceptorsAny && interceptorsAny.length > 0; + if (hasGateways || hasInterceptors) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } } // Stack-level output @@ -357,8 +375,8 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/package.json should m "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", + "@aws/agentcore-cdk": "^0.1.0-alpha.29", + "aws-cdk-lib": "^2.252.0", "constructs": "^10.0.0" } } @@ -386,6 +404,7 @@ test('AgentCoreStack synthesizes with empty spec', () => { agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], + interceptors: [], }, }); const template = Template.fromStack(stack); @@ -451,6 +470,30 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "evaluators/python-lambda/execution-role-policy.json", "evaluators/python-lambda/lambda_function.py", "evaluators/python-lambda/pyproject.toml", + "interceptors/node-lambda/jwt-scope-authorizer/README.md", + "interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json", + "interceptors/node-lambda/jwt-scope-authorizer/index.mjs", + "interceptors/node-lambda/jwt-scope-authorizer/package.json", + "interceptors/node-lambda/pass-through/README.md", + "interceptors/node-lambda/pass-through/execution-role-policy.json", + "interceptors/node-lambda/pass-through/index.mjs", + "interceptors/node-lambda/pass-through/package.json", + "interceptors/node-lambda/tools-list-filter/README.md", + "interceptors/node-lambda/tools-list-filter/execution-role-policy.json", + "interceptors/node-lambda/tools-list-filter/index.mjs", + "interceptors/node-lambda/tools-list-filter/package.json", + "interceptors/python-lambda/jwt-scope-authorizer/README.md", + "interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json", + "interceptors/python-lambda/jwt-scope-authorizer/handler.py", + "interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml", + "interceptors/python-lambda/pass-through/README.md", + "interceptors/python-lambda/pass-through/execution-role-policy.json", + "interceptors/python-lambda/pass-through/handler.py", + "interceptors/python-lambda/pass-through/pyproject.toml", + "interceptors/python-lambda/tools-list-filter/README.md", + "interceptors/python-lambda/tools-list-filter/execution-role-policy.json", + "interceptors/python-lambda/tools-list-filter/handler.py", + "interceptors/python-lambda/tools-list-filter/pyproject.toml", "mcp/python-lambda/README.md", "mcp/python-lambda/handler.py", "mcp/python-lambda/pyproject.toml", diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 7a78b71cd..bdb8ae7bd 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -32,13 +32,21 @@ async function main() { // Gateway fields are stored in agentcore.json but may not yet be on the // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them // dynamically and cast the resulting object. + // + // Interceptors live at root in agentcore.json (`interceptors[]`). The CDK + // package's AgentCoreMcpSpec carries them in the same MCP-scoped object as + // gateways/runtimes, so we copy the field across at this boundary. Empty + // arrays are normalized to undefined to keep CDK synth clean when no + // interceptors are defined. // eslint-disable-next-line @typescript-eslint/no-explicit-any const specAny = spec as any; - const mcpSpec = specAny.agentCoreGateways?.length + const hasMcp = specAny.agentCoreGateways?.length || specAny.interceptors?.length; + const mcpSpec = hasMcp ? { - agentCoreGateways: specAny.agentCoreGateways, + agentCoreGateways: specAny.agentCoreGateways ?? [], mcpRuntimeTools: specAny.mcpRuntimeTools, unassignedTargets: specAny.unassignedTargets, + interceptors: specAny.interceptors?.length ? specAny.interceptors : undefined, } : undefined; diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index a4d277821..bfcc3d6c2 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -42,15 +42,25 @@ export class AgentCoreStack extends Stack { spec, }); - // Create AgentCoreMcp if there are gateways configured - if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { - new AgentCoreMcp(this, 'Mcp', { - projectName: spec.name, - mcpSpec, - agentCoreApplication: this.application, - credentials, - projectTags: spec.tags, - }); + // Create AgentCoreMcp if there are gateways or interceptors configured. + // Interceptors are MCP-scoped via the gatewayName reference, so they + // never appear without gateways under valid schema, but the OR guard + // here is defensive — it prevents interceptors from silently vanishing + // if the spec ever reaches synth in a partially-validated state. + if (mcpSpec) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the bundled CDK type may not yet declare interceptors + const interceptorsAny = (mcpSpec as any).interceptors; + const hasGateways = mcpSpec.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; + const hasInterceptors = interceptorsAny && interceptorsAny.length > 0; + if (hasGateways || hasInterceptors) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } } // Stack-level output diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index aa58892c2..23d01fc90 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -23,8 +23,8 @@ "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", + "@aws/agentcore-cdk": "^0.1.0-alpha.29", + "aws-cdk-lib": "^2.252.0", "constructs": "^10.0.0" } } diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index df5c767f9..01893da3f 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -18,6 +18,7 @@ test('AgentCoreStack synthesizes with empty spec', () => { agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], + interceptors: [], }, }); const template = Template.fromStack(stack); diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/README.md b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/README.md new file mode 100644 index 000000000..152456041 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/README.md @@ -0,0 +1,23 @@ +# {{ Name }} — jwt-scope-authorizer (REQUEST, Node.js 22.x) + +Decode the inbound `Authorization: Bearer …` JWT, read the `scope`/`scp` claim, +and short-circuit unauthorized requests with a structured 403. + +## What you must edit + +Update `ALLOWED_SCOPES` in `index.mjs` to reflect your scope vocabulary. + +## Why REQUEST-only + +The structured 403 lives in `transformedGatewayResponse`, which the gateway +serves directly to the caller. RESPONSE-point interceptors should not authorize; +that's too late in the lifecycle. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. + +## Structured errors over exceptions + +Don't throw on auth failure. Throwing tells the gateway to retry, double-invoking +your handler. Always return the deny envelope. diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/index.mjs b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/index.mjs new file mode 100644 index 000000000..ceca504e0 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/index.mjs @@ -0,0 +1,76 @@ +/** + * AgentCore Gateway Interceptor — jwt-scope-authorizer (REQUEST point). + * + * Reads the JWT scope claim from the inbound `Authorization` header and either + * allows the request through unchanged or denies it with a structured 403. + * + * The handler does NOT validate the JWT signature — the gateway's CUSTOM_JWT + * authorizer already did that. We only read the `scope` claim and authorize + * the business action. + * + * Edit `ALLOWED_SCOPES` below to match your scope vocabulary. + */ + +const ALLOWED_SCOPES = new Set(['agentcore:invoke']); + +const decodeJwtPayload = token => { + const parts = token.split('.'); + if (parts.length < 2) return {}; + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const json = Buffer.from(padded, 'base64').toString('utf-8'); + return JSON.parse(json); + } catch { + return {}; + } +}; + +const scopesFromPayload = payload => { + const raw = payload.scope ?? payload.scp; + if (typeof raw === 'string') return raw.split(/\s+/); + if (Array.isArray(raw)) return raw.map(String); + return []; +}; + +const deny = reason => ({ + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayResponse: { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'forbidden', reason }, + }, + }, +}); + +export const handler = async event => { + const request = event?.mcp?.gatewayRequest ?? {}; + const headers = Object.fromEntries( + Object.entries(request.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]) + ); + const authz = headers.authorization ?? ''; + + if (!authz.toLowerCase().startsWith('bearer ')) { + return deny('missing-or-malformed-authorization-header'); + } + + const token = authz.slice('Bearer '.length).trim(); + const payload = decodeJwtPayload(token); + const scopes = new Set(scopesFromPayload(payload)); + + const intersect = [...scopes].some(s => ALLOWED_SCOPES.has(s)); + if (!intersect) { + return deny('required-scope-missing'); + } + + return { + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayRequest: { + headers: request.headers ?? {}, + body: request.body ?? {}, + }, + }, + }; +}; diff --git a/src/assets/interceptors/node-lambda/jwt-scope-authorizer/package.json b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/package.json new file mode 100644 index 000000000..18e157f35 --- /dev/null +++ b/src/assets/interceptors/node-lambda/jwt-scope-authorizer/package.json @@ -0,0 +1,10 @@ +{ + "name": "{{ Name }}", + "version": "0.1.0", + "description": "AgentCore Gateway Interceptor — JWT scope authorizer", + "type": "module", + "main": "index.mjs", + "engines": { + "node": ">=22" + } +} diff --git a/src/assets/interceptors/node-lambda/pass-through/README.md b/src/assets/interceptors/node-lambda/pass-through/README.md new file mode 100644 index 000000000..974602688 --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/README.md @@ -0,0 +1,39 @@ +# {{ Name }} — pass-through interceptor (Node.js 22.x) + +A minimal AgentCore Gateway Interceptor that returns the request/response unchanged. + +## Envelope + +The handler reads `event.interceptorInputVersion === "1.0"` and returns +`{ interceptorOutputVersion: "1.0", mcp: ... }`. **The output version is mandatory**; +missing it causes the gateway to silently reject the response. + +## Structured errors over exceptions + +Don't throw. Return a structured envelope so the gateway doesn't retry the same +event and double-invoke. Example: + +```js +return { + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayResponse: { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'Authorization denied' }, + }, + }, +}; +``` + +## Idempotency + +For interceptors with external side effects (writing to S3, calling a third-party +API), use `event.mcp.invocationId` as the idempotency key — see the commented-in +example in `index.mjs`. + +## Cold start + +Lambda cold starts can push the first invocation past the gateway's interceptor +budget. Configure provisioned concurrency on the function if telemetry shows +first-invocation timeouts. diff --git a/src/assets/interceptors/node-lambda/pass-through/execution-role-policy.json b/src/assets/interceptors/node-lambda/pass-through/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/node-lambda/pass-through/index.mjs b/src/assets/interceptors/node-lambda/pass-through/index.mjs new file mode 100644 index 000000000..b395cac7a --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/index.mjs @@ -0,0 +1,52 @@ +/** + * AgentCore Gateway Interceptor — pass-through (REQUEST or RESPONSE point). + * + * Inputs (REQUEST point): + * event.interceptorInputVersion === "1.0" + * event.mcp.gatewayRequest === { path, httpMethod, headers, body } + * + * Inputs (RESPONSE point): + * event.mcp.gatewayResponse === { statusCode, headers, body } + * + * Outputs (always): + * { interceptorOutputVersion: "1.0", mcp: {...} } + * + * Foot-guns avoided by this template: + * - interceptorOutputVersion is always set (missing → silent rejection). + * - Errors are returned as structured response envelopes, never thrown + * (throwing triggers gateway retries — fires the interceptor twice). + * + * Streaming guard (RESPONSE only — uncomment if your gateway streams): + * // const invocationIndex = event?.mcp?.invocationIndex ?? 0; + * // if (invocationIndex > 0) { + * // // Subsequent invocations: do not mutate headers/statusCode. + * // } + * + * Idempotency (uncomment if your handler has external side effects): + * // const key = event?.mcp?.invocationId; + * // if (key && seen(key)) return cachedResponse(key); + */ +export const handler = async event => { + const request = event?.mcp?.gatewayRequest; + const response = event?.mcp?.gatewayResponse; + + const outputMcp = {}; + if (request) { + outputMcp.transformedGatewayRequest = { + headers: request.headers ?? {}, + body: request.body ?? {}, + }; + } + if (response) { + outputMcp.transformedGatewayResponse = { + statusCode: response.statusCode ?? 200, + headers: response.headers ?? {}, + body: response.body ?? {}, + }; + } + + return { + interceptorOutputVersion: '1.0', + mcp: outputMcp, + }; +}; diff --git a/src/assets/interceptors/node-lambda/pass-through/package.json b/src/assets/interceptors/node-lambda/pass-through/package.json new file mode 100644 index 000000000..07fc87ea2 --- /dev/null +++ b/src/assets/interceptors/node-lambda/pass-through/package.json @@ -0,0 +1,10 @@ +{ + "name": "{{ Name }}", + "version": "0.1.0", + "description": "AgentCore Gateway Interceptor — pass-through", + "type": "module", + "main": "index.mjs", + "engines": { + "node": ">=22" + } +} diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/README.md b/src/assets/interceptors/node-lambda/tools-list-filter/README.md new file mode 100644 index 000000000..5200bec6f --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/README.md @@ -0,0 +1,27 @@ +# {{ Name }} — tools-list-filter (RESPONSE, Node.js 22.x) + +Strip unauthorized tools from `tools/list` responses before they reach the agent. +Other MCP method responses pass through unchanged. + +## What you must edit + +Replace the placeholder `isAuthorized()` function with your real logic. Common +patterns: + +- Read groups/roles from a JWT in `requestHeaders.authorization`. +- Look up an entitlement record in DynamoDB. +- Consult a Cedar / OPA policy engine. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. + +## Structured errors over exceptions + +If you must error out, return a structured response envelope (e.g., `502` with +a JSON error body) — never throw. Throwing fires the interceptor twice. + +## Cold start + +This handler runs once per `tools/list` request, which is infrequent compared +to per-tool-invocation interceptors. Cold starts are usually fine here. diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/execution-role-policy.json b/src/assets/interceptors/node-lambda/tools-list-filter/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/index.mjs b/src/assets/interceptors/node-lambda/tools-list-filter/index.mjs new file mode 100644 index 000000000..4574205d7 --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/index.mjs @@ -0,0 +1,48 @@ +/** + * AgentCore Gateway Interceptor — tools-list-filter (RESPONSE point). + * + * When the gateway answers a `tools/list` MCP call, strip the response body of + * any tools the calling principal isn't allowed to see. Other MCP method + * responses pass through unchanged. + * + * Replace the placeholder `isAuthorized()` with your real logic. + */ + +const isAuthorized = (toolName, requestHeaders) => { + // Default: allow everything. Replace with real logic (read JWT, check + // groups, consult policy engine, etc.). + void toolName; + void requestHeaders; + return true; +}; + +export const handler = async event => { + const response = event?.mcp?.gatewayResponse; + if (!response) { + return { interceptorOutputVersion: '1.0', mcp: {} }; + } + + const requestHeaders = event?.mcp?.gatewayRequest?.headers ?? {}; + const body = response.body ?? {}; + const requestMethod = event?.mcp?.gatewayRequest?.body?.method; + const isToolsList = requestMethod === 'tools/list' || (body?.result && Array.isArray(body.result.tools)); + + if (!isToolsList) { + return { interceptorOutputVersion: '1.0', mcp: { transformedGatewayResponse: response } }; + } + + const result = body.result ?? {}; + const tools = Array.isArray(result.tools) ? result.tools : []; + const filtered = tools.filter(t => isAuthorized(String(t?.name ?? ''), requestHeaders)); + + return { + interceptorOutputVersion: '1.0', + mcp: { + transformedGatewayResponse: { + statusCode: response.statusCode ?? 200, + headers: response.headers ?? {}, + body: { ...body, result: { ...result, tools: filtered } }, + }, + }, + }; +}; diff --git a/src/assets/interceptors/node-lambda/tools-list-filter/package.json b/src/assets/interceptors/node-lambda/tools-list-filter/package.json new file mode 100644 index 000000000..2cce898bd --- /dev/null +++ b/src/assets/interceptors/node-lambda/tools-list-filter/package.json @@ -0,0 +1,10 @@ +{ + "name": "{{ Name }}", + "version": "0.1.0", + "description": "AgentCore Gateway Interceptor — tools/list filter", + "type": "module", + "main": "index.mjs", + "engines": { + "node": ">=22" + } +} diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/README.md b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/README.md new file mode 100644 index 000000000..8ac579e8f --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/README.md @@ -0,0 +1,25 @@ +# {{ Name }} — jwt-scope-authorizer (REQUEST) + +Decode the inbound `Authorization: Bearer …` JWT, read the `scope`/`scp` claim, +and short-circuit unauthorized requests with a structured 403. + +## What you must edit + +Update `ALLOWED_SCOPES` in `handler.py` to reflect your scope vocabulary. + +## Why this is REQUEST-only + +The structured 403 lives in `transformedGatewayResponse`, which the gateway +serves directly to the caller. RESPONSE-point interceptors should not authorize; +that's too late in the lifecycle. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. Missing it +causes the gateway to silently reject the response and serve the upstream +result unmodified. + +## Structured errors over exceptions + +Don't throw on auth failure. Throwing tells the gateway to retry, double-invoking +your handler. Always return the deny envelope. diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/handler.py b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/handler.py new file mode 100644 index 000000000..837c527ba --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/handler.py @@ -0,0 +1,83 @@ +""" +AgentCore Gateway Interceptor — jwt-scope-authorizer (REQUEST point). + +Reads the JWT scope claim from the inbound `Authorization` header and either +allows the request through unchanged or denies it with a structured 403. + +Envelope contract: + Inputs: event["mcp"]["gatewayRequest"]["headers"]["authorization"] + Outputs: {"interceptorOutputVersion": "1.0", "mcp": {...}} + +This handler does NOT validate the JWT signature -- the gateway's CUSTOM_JWT +authorizer already did that. We only read the `scope` claim and authorize the +business action. + +Edit the ALLOWED_SCOPES set below to match your scope vocabulary. +""" +import base64 +import json +from typing import Any, Dict, Iterable + +ALLOWED_SCOPES: frozenset[str] = frozenset({"agentcore:invoke"}) + + +def _decode_jwt_payload(token: str) -> Dict[str, Any]: + parts = token.split(".") + if len(parts) < 2: + return {} + payload = parts[1] + # Pad base64url to a multiple of 4 chars before decoding. + payload += "=" * (-len(payload) % 4) + try: + return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return {} + + +def _scopes_from_payload(payload: Dict[str, Any]) -> Iterable[str]: + raw = payload.get("scope") or payload.get("scp") + if isinstance(raw, str): + return raw.split() + if isinstance(raw, list): + return [str(s) for s in raw] + return [] + + +def _deny(reason: str) -> Dict[str, Any]: + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "statusCode": 403, + "headers": {"Content-Type": "application/json"}, + "body": {"error": "forbidden", "reason": reason}, + } + }, + } + + +def lambda_handler(event, context): + request = event.get("mcp", {}).get("gatewayRequest", {}) + headers = {k.lower(): v for k, v in request.get("headers", {}).items()} + authz = headers.get("authorization", "") + + if not authz.lower().startswith("bearer "): + return _deny("missing-or-malformed-authorization-header") + + token = authz[len("Bearer ") :].strip() + payload = _decode_jwt_payload(token) + scopes = set(_scopes_from_payload(payload)) + + if not scopes & ALLOWED_SCOPES: + return _deny("required-scope-missing") + + # Allow: pass through unchanged. + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayRequest": { + "headers": request.get("headers", {}), + "body": request.get("body", {}), + } + }, + } diff --git a/src/assets/interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml new file mode 100644 index 000000000..7984f8191 --- /dev/null +++ b/src/assets/interceptors/python-lambda/jwt-scope-authorizer/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ PackageName }}" +version = "0.1.0" +description = "AgentCore Lambda Interceptor — JWT scope authorizer" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/interceptors/python-lambda/pass-through/README.md b/src/assets/interceptors/python-lambda/pass-through/README.md new file mode 100644 index 000000000..1b04c92ca --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/README.md @@ -0,0 +1,40 @@ +# {{ Name }} — pass-through interceptor + +A minimal AgentCore Gateway Interceptor that returns the request/response unchanged. + +## Envelope + +The handler reads `event["interceptorInputVersion"] == "1.0"` and returns +`{"interceptorOutputVersion": "1.0", "mcp": ...}`. **The output version is mandatory**; +missing it causes the gateway to silently reject the response. + +## Structured errors over exceptions + +Don't throw. Return a structured envelope so the gateway doesn't retry the same +event and double-invoke. Example: + +```python +return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "statusCode": 403, + "headers": {"Content-Type": "application/json"}, + "body": {"error": "Authorization denied"}, + } + }, +} +``` + +## Idempotency + +For interceptors with external side effects (writing to S3, calling a third-party +API), use `event["mcp"]["invocationId"]` as the idempotency key — see the +commented-in example in `handler.py`. + +## Cold start + +Lambda cold starts can push the first invocation past the gateway's interceptor +budget. If telemetry shows a steady stream of first-invocation timeouts, configure +provisioned concurrency on the function. The schema's default `timeoutSeconds: 30` +is a comfortable upper bound for typical workloads. diff --git a/src/assets/interceptors/python-lambda/pass-through/execution-role-policy.json b/src/assets/interceptors/python-lambda/pass-through/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/python-lambda/pass-through/handler.py b/src/assets/interceptors/python-lambda/pass-through/handler.py new file mode 100644 index 000000000..c8f24cc93 --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/handler.py @@ -0,0 +1,53 @@ +""" +AgentCore Gateway Interceptor — pass-through (REQUEST or RESPONSE point). + +Inputs (REQUEST point): + event["interceptorInputVersion"] == "1.0" + event["mcp"]["gatewayRequest"] == {path, httpMethod, headers, body} + +Inputs (RESPONSE point): + event["mcp"]["gatewayResponse"] == {statusCode, headers, body} + +Outputs (always): + interceptorOutputVersion: "1.0" + mcp: { transformedGatewayRequest? , transformedGatewayResponse? } + +Foot-guns avoided by this template: + - interceptorOutputVersion is always set (missing -> silent rejection). + - Errors are returned as structured response envelopes, never thrown + (throwing triggers gateway retries -- fires the interceptor twice). + +Streaming guard (RESPONSE only -- uncomment if your gateway streams): + # invocation_index = event.get("mcp", {}).get("invocationIndex", 0) + # if invocation_index > 0: + # # Subsequent invocations: do not mutate headers/statusCode. + # pass + +Idempotency (uncomment if your handler has external side effects): + # idempotency_key = event.get("mcp", {}).get("invocationId") + # if idempotency_key and seen(idempotency_key): + # return cached_response(idempotency_key) +""" + + +def lambda_handler(event, context): + request = event.get("mcp", {}).get("gatewayRequest") + response = event.get("mcp", {}).get("gatewayResponse") + + output_mcp = {} + if request is not None: + output_mcp["transformedGatewayRequest"] = { + "headers": request.get("headers", {}), + "body": request.get("body", {}), + } + if response is not None: + output_mcp["transformedGatewayResponse"] = { + "statusCode": response.get("statusCode", 200), + "headers": response.get("headers", {}), + "body": response.get("body", {}), + } + + return { + "interceptorOutputVersion": "1.0", + "mcp": output_mcp, + } diff --git a/src/assets/interceptors/python-lambda/pass-through/pyproject.toml b/src/assets/interceptors/python-lambda/pass-through/pyproject.toml new file mode 100644 index 000000000..a520cc2be --- /dev/null +++ b/src/assets/interceptors/python-lambda/pass-through/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ PackageName }}" +version = "0.1.0" +description = "AgentCore Lambda Interceptor — pass-through" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/README.md b/src/assets/interceptors/python-lambda/tools-list-filter/README.md new file mode 100644 index 000000000..008026230 --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/README.md @@ -0,0 +1,27 @@ +# {{ Name }} — tools-list-filter (RESPONSE) + +Strip unauthorized tools from `tools/list` responses before they reach the agent. +Other MCP method responses pass through unchanged. + +## What you must edit + +Replace the placeholder `is_authorized()` function with your real logic. Common +patterns: + +- Read groups/roles from a JWT in `request_headers["authorization"]`. +- Look up an entitlement record in DynamoDB. +- Consult a Cedar / OPA policy engine. + +## Envelope + +`interceptorOutputVersion: "1.0"` is mandatory on every return path. + +## Structured errors over exceptions + +If you must error out, return a structured response envelope (e.g., `502` with +a JSON error body) — never throw. Throwing fires the interceptor twice. + +## Cold start + +This handler runs once per `tools/list` request, which is infrequent compared +to per-tool-invocation interceptors. Cold starts are usually fine here. diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/execution-role-policy.json b/src/assets/interceptors/python-lambda/tools-list-filter/execution-role-policy.json new file mode 100644 index 000000000..b3b98be42 --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/handler.py b/src/assets/interceptors/python-lambda/tools-list-filter/handler.py new file mode 100644 index 000000000..c8671215a --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/handler.py @@ -0,0 +1,66 @@ +""" +AgentCore Gateway Interceptor — tools-list-filter (RESPONSE point). + +When the gateway answers a `tools/list` MCP call, strip the response body of +any tools the calling principal isn't allowed to see. Other MCP method +responses pass through unchanged. + +Edit the `is_authorized()` predicate to match your authorization model. + +Envelope contract: + Inputs: event["mcp"]["gatewayResponse"]["body"]["result"]["tools"] + Outputs: {"interceptorOutputVersion": "1.0", "mcp": {...}} +""" +from typing import Any, Dict, List + + +def is_authorized(tool_name: str, request_headers: Dict[str, str]) -> bool: + """Return True if the caller is allowed to see this tool. + + Default implementation: allow everything. Replace with real logic + (read groups from JWT, check feature flag, consult policy engine, etc.). + """ + _ = (tool_name, request_headers) + return True + + +def lambda_handler(event, context): + response = event.get("mcp", {}).get("gatewayResponse") + if response is None: + # Defensive: should not happen at RESPONSE point. + return {"interceptorOutputVersion": "1.0", "mcp": {}} + + request_headers: Dict[str, str] = ( + event.get("mcp", {}).get("gatewayRequest", {}).get("headers", {}) or {} + ) + + body = response.get("body") or {} + method = body.get("method") or (event.get("mcp", {}).get("gatewayRequest", {}).get("body", {}) or {}).get("method") + is_tools_list = method == "tools/list" or "tools" in (body.get("result") or {}) + + if not is_tools_list: + # Pass through unchanged. + return { + "interceptorOutputVersion": "1.0", + "mcp": {"transformedGatewayResponse": response}, + } + + result = body.get("result") or {} + tools: List[Dict[str, Any]] = result.get("tools") or [] + filtered: List[Dict[str, Any]] = [t for t in tools if is_authorized(str(t.get("name", "")), request_headers)] + + new_body = dict(body) + new_result = dict(result) + new_result["tools"] = filtered + new_body["result"] = new_result + + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "statusCode": response.get("statusCode", 200), + "headers": response.get("headers", {}), + "body": new_body, + } + }, + } diff --git a/src/assets/interceptors/python-lambda/tools-list-filter/pyproject.toml b/src/assets/interceptors/python-lambda/tools-list-filter/pyproject.toml new file mode 100644 index 000000000..b9e410dc7 --- /dev/null +++ b/src/assets/interceptors/python-lambda/tools-list-filter/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ PackageName }}" +version = "0.1.0" +description = "AgentCore Lambda Interceptor — tools/list filter" +requires-python = ">=3.10" +dependencies = [] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/cli/aws/__tests__/mask.test.ts b/src/cli/aws/__tests__/mask.test.ts new file mode 100644 index 000000000..1427d48b5 --- /dev/null +++ b/src/cli/aws/__tests__/mask.test.ts @@ -0,0 +1,61 @@ +import { accountIdFromArn, maskAccountId } from '../mask'; +import { describe, expect, it } from 'vitest'; + +describe('maskAccountId', () => { + it('masks a 12-digit account ID in a Lambda ARN', () => { + expect(maskAccountId('arn:aws:lambda:us-east-1:111111111111:function:foo')).toBe( + 'arn:aws:lambda:us-east-1:****1111:function:foo' + ); + }); + + it('preserves the last 4 digits', () => { + expect(maskAccountId('arn:aws:lambda:us-east-1:603141041947:function:foo')).toBe( + 'arn:aws:lambda:us-east-1:****1947:function:foo' + ); + }); + + it('handles non-aws partitions', () => { + expect(maskAccountId('arn:aws-us-gov:lambda:us-gov-west-1:222222222222:function:bar')).toBe( + 'arn:aws-us-gov:lambda:us-gov-west-1:****2222:function:bar' + ); + }); + + it('masks multiple ARNs in one string', () => { + const input = + 'gateway: arn:aws:lambda:us-east-1:111111111111:function:a\n ' + + 'lambda: arn:aws:lambda:us-east-1:222222222222:function:b'; + expect(maskAccountId(input)).toContain('****1111'); + expect(maskAccountId(input)).toContain('****2222'); + }); + + it('passes through non-ARN strings unchanged', () => { + expect(maskAccountId('foo bar baz')).toBe('foo bar baz'); + }); + + it('does not mask numbers shorter than 12 digits', () => { + expect(maskAccountId('port 8080 timeout 30')).toBe('port 8080 timeout 30'); + }); + + it('is idempotent on already-masked output', () => { + const once = maskAccountId('arn:aws:lambda:us-east-1:111111111111:function:foo'); + expect(maskAccountId(once)).toBe(once); + }); + + it('handles empty input', () => { + expect(maskAccountId('')).toBe(''); + }); +}); + +describe('accountIdFromArn', () => { + it('extracts the account ID from a Lambda ARN', () => { + expect(accountIdFromArn('arn:aws:lambda:us-east-1:111111111111:function:foo')).toBe('111111111111'); + }); + + it('returns undefined for masked ARNs', () => { + expect(accountIdFromArn('arn:aws:lambda:us-east-1:****1111:function:foo')).toBeUndefined(); + }); + + it('returns undefined for non-ARN strings', () => { + expect(accountIdFromArn('not-an-arn')).toBeUndefined(); + }); +}); diff --git a/src/cli/aws/mask.ts b/src/cli/aws/mask.ts new file mode 100644 index 000000000..dff20c437 --- /dev/null +++ b/src/cli/aws/mask.ts @@ -0,0 +1,43 @@ +/** + * PII masking utility for AWS account IDs. + * + * Account IDs are sensitive — they uniquely identify the customer's AWS account + * in any system that aggregates user-visible CLI output (logs, telemetry, + * support tickets, screenshots). The masker rewrites any 12-digit account-ID + * segment in an ARN to `****` so the rest of the ARN structure is + * preserved while the account ID itself is obscured. + * + * Used by: + * - The cross-account interceptor preflight warning + * - `agentcore status` rendering for interceptor ARNs + * - Telemetry attributes that carry interceptor ARNs + * + * Idempotent on already-masked input. + */ + +const ACCOUNT_ID_RE = /\b\d{12}\b/g; +// Recognize an already-masked account-ID-like segment (`****1234`) to keep the +// helper idempotent. +const MASKED_RE = /\*{4}\d{4}/; + +/** + * Replace any 12-digit account-ID segment with `****`. + * + * Handles ARNs (`arn:aws:lambda:us-east-1:111111111111:function:foo`), + * multi-ARN strings, and bare 12-digit IDs. Non-12-digit numbers are left + * alone (no false positives on resource IDs, port numbers, timeouts, etc.). + */ +export function maskAccountId(input: string): string { + if (!input) return input; + return input.replace(ACCOUNT_ID_RE, m => `****${m.slice(-4)}`); +} + +/** + * Extract the 12-digit account ID from an ARN. Returns `undefined` if the + * input is not an ARN or already masked. + */ +export function accountIdFromArn(arn: string): string | undefined { + if (MASKED_RE.test(arn)) return undefined; + const m = /:(\d{12}):/.exec(arn); + return m?.[1]; +} diff --git a/src/cli/cloudformation/__tests__/interceptor-outputs.test.ts b/src/cli/cloudformation/__tests__/interceptor-outputs.test.ts new file mode 100644 index 000000000..6505402ec --- /dev/null +++ b/src/cli/cloudformation/__tests__/interceptor-outputs.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for parseInterceptorOutputs and the interceptor branch of buildDeployedState. + * + * The CDK construct emits the following CFN outputs (per Phase 1 wiring): + * - Interceptor{PascalName}ArnOutput + * - Interceptor{PascalName}ModeOutput + * - Interceptor{PascalName}RoleArnOutput (managed-only) + * - Interceptor{PascalName}FunctionNameOutput (managed-only) + * + * The CFN output keys carry an auto-deduplication hash suffix ("Output3E11FAB4"). + * The parser uses startsWith to find the right key. + */ +import { buildDeployedState, parseInterceptorOutputs } from '../outputs'; +import { describe, expect, it } from 'vitest'; + +describe('parseInterceptorOutputs', () => { + it('parses managed-mode entries with all four fields', () => { + const outputs = { + InterceptorAuthCheckArnOutputAAAA: 'arn:aws:lambda:us-east-1:111111111111:function:p-interceptor-auth-check', + InterceptorAuthCheckModeOutputBBBB: 'managed', + InterceptorAuthCheckRoleArnOutputCCCC: 'arn:aws:iam::111111111111:role/auth-check-role', + InterceptorAuthCheckFunctionNameOutputDDDD: 'p-interceptor-auth-check', + }; + const result = parseInterceptorOutputs(outputs, [{ name: 'auth-check', mode: 'managed' }]); + expect(result['auth-check']).toEqual({ + mode: 'managed', + interceptorArn: 'arn:aws:lambda:us-east-1:111111111111:function:p-interceptor-auth-check', + interceptorRoleArn: 'arn:aws:iam::111111111111:role/auth-check-role', + interceptorFunctionName: 'p-interceptor-auth-check', + }); + }); + + it('parses external-mode entries with only mode + ARN', () => { + const outputs = { + InterceptorCentralAuthArnOutputAAAA: 'arn:aws:lambda:us-east-1:222222222222:function:central-auth', + InterceptorCentralAuthModeOutputBBBB: 'external', + }; + const result = parseInterceptorOutputs(outputs, [{ name: 'central-auth', mode: 'external' }]); + expect(result['central-auth']).toEqual({ + mode: 'external', + interceptorArn: 'arn:aws:lambda:us-east-1:222222222222:function:central-auth', + }); + expect(result['central-auth']?.interceptorRoleArn).toBeUndefined(); + expect(result['central-auth']?.interceptorFunctionName).toBeUndefined(); + }); + + it('returns an empty record when no interceptor outputs are present', () => { + expect(parseInterceptorOutputs({}, [{ name: 'absent', mode: 'managed' }])).toEqual({}); + }); + + it('skips entries missing the Arn or Mode output', () => { + const outputs = { + InterceptorIncompleteArnOutputAAAA: 'arn:aws:lambda:us-east-1:111111111111:function:incomplete', + // Mode missing + }; + expect(parseInterceptorOutputs(outputs, [{ name: 'incomplete', mode: 'managed' }])).toEqual({}); + }); +}); + +describe('buildDeployedState — interceptor placement', () => { + it('writes interceptors under mcp.interceptors when present', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'MyStack', + agents: {}, + gateways: {}, + interceptors: { + 'auth-check': { + mode: 'managed', + interceptorArn: 'arn:aws:lambda:us-east-1:111111111111:function:auth-check', + interceptorRoleArn: 'arn:aws:iam::111111111111:role/auth-check-role', + interceptorFunctionName: 'auth-check', + }, + }, + }); + expect(state.targets.default?.resources?.mcp?.interceptors?.['auth-check']?.mode).toBe('managed'); + }); + + it('does not create an mcp block when both gateways and interceptors are empty', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'MyStack', + agents: {}, + gateways: {}, + }); + expect(state.targets.default?.resources?.mcp).toBeUndefined(); + }); + + it('co-locates gateways and interceptors under mcp', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'MyStack', + agents: {}, + gateways: { + 'my-gw': { gatewayId: 'g-1', gatewayArn: 'arn:gw' }, + }, + interceptors: { + 'auth-check': { + mode: 'external', + interceptorArn: 'arn:aws:lambda:us-east-1:111111111111:function:auth-check', + }, + }, + }); + expect(state.targets.default?.resources?.mcp?.gateways?.['my-gw']).toBeDefined(); + expect(state.targets.default?.resources?.mcp?.interceptors?.['auth-check']).toBeDefined(); + }); +}); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 377cc3e9d..2b3a32a12 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -2,6 +2,7 @@ import type { AgentCoreDeployedState, DeployedState, EvaluatorDeployedState, + InterceptorDeployedState, MemoryDeployedState, OnlineEvalDeployedState, PolicyDeployedState, @@ -243,6 +244,57 @@ export function parseEvaluatorOutputs( return evaluators; } +/** + * Parse stack outputs into deployed state for Lambda interceptors. + * + * Output key pattern: Interceptor{PascalName}(Arn|Mode|RoleArn|FunctionName)Output{Hash} + * + * Per Phase 1 wiring, the CDK construct emits: + * - Arn (always) + * - Mode (always — `managed` | `external`) + * - RoleArn (managed only) + * - FunctionName (managed only) + * + * The CLI consumes these into `targets[X].resources.mcp.interceptors[name]`. + */ +export function parseInterceptorOutputs( + outputs: StackOutputs, + interceptorSpecs: { name: string; mode: 'managed' | 'external' }[] +): Record { + const interceptors: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const spec of interceptorSpecs) { + const pascal = toPascalId('Interceptor', spec.name); + const arnPrefix = `${pascal}ArnOutput`; + const modePrefix = `${pascal}ModeOutput`; + const roleArnPrefix = `${pascal}RoleArnOutput`; + const fnNamePrefix = `${pascal}FunctionNameOutput`; + + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + const modeKey = outputKeys.find(k => k.startsWith(modePrefix)); + + if (!arnKey || !modeKey) continue; + + const mode = outputs[modeKey] === 'managed' ? 'managed' : 'external'; + const state: InterceptorDeployedState = { + mode, + interceptorArn: outputs[arnKey]!, + }; + + if (mode === 'managed') { + const roleArnKey = outputKeys.find(k => k.startsWith(roleArnPrefix)); + const fnNameKey = outputKeys.find(k => k.startsWith(fnNamePrefix)); + if (roleArnKey) state.interceptorRoleArn = outputs[roleArnKey]; + if (fnNameKey) state.interceptorFunctionName = outputs[fnNameKey]; + } + + interceptors[spec.name] = state; + } + + return interceptors; +} + /** * Parse stack outputs into deployed state for online evaluation configs. * @@ -389,6 +441,8 @@ export interface BuildDeployedStateOptions { policyEngines?: Record; policies?: Record; runtimeEndpoints?: Record; + /** Interceptor states keyed by interceptor name. Stored under mcp.interceptors. */ + interceptors?: Record; } /** @@ -409,6 +463,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta policyEngines, policies, runtimeEndpoints, + interceptors, } = opts; const targetState: TargetDeployedState = { resources: { @@ -421,10 +476,15 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta }, }; - // Add MCP state if gateways exist - if (Object.keys(gateways).length > 0) { + // Add MCP state if gateways or interceptors exist. Both nest under `mcp` + // because interceptors attach to gateways logically (and the deployed-state + // schema reflects that hierarchy). + const hasGateways = Object.keys(gateways).length > 0; + const hasInterceptors = interceptors && Object.keys(interceptors).length > 0; + if (hasGateways || hasInterceptors) { targetState.resources!.mcp = { - gateways, + ...(hasGateways && { gateways }), + ...(hasInterceptors && { interceptors }), }; } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index eba2ab113..1db7c2238 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -10,6 +10,7 @@ import { parseAgentOutputs, parseEvaluatorOutputs, parseGatewayOutputs, + parseInterceptorOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, parsePolicyEngineOutputs, @@ -463,6 +464,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ + name: i.name, + mode: i.config.managed ? ('managed' as const) : ('external' as const), + })); + const interceptors = parseInterceptorOutputs(outputs, interceptorSpecs); + const existingState = await configIO.readDeployedState().catch(() => undefined); let deployedState = buildDeployedState({ targetName: target.name, @@ -478,6 +488,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { it('missing authorizerType: defaults to NONE', () => { const gw = makeGateway(); // Simulate undefined authorizerType by deleting after construction - // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (gw as any).authorizerType; const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); diff --git a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts index 3624ce545..b2eed2b32 100644 --- a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts @@ -52,7 +52,6 @@ describe('toGatewayTargetSpec — apiGateway', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('apiGateway'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const apigw = (result as any).apiGateway; expect(apigw.restApiId).toBe('abc123'); expect(apigw.stage).toBe('prod'); @@ -84,7 +83,6 @@ describe('toGatewayTargetSpec — apiGateway', () => { const onProgress = vi.fn(); const result = toGatewayTargetSpec(detail, new Map(), onProgress); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const apigw = (result as any).apiGateway; expect(apigw.apiGatewayToolConfiguration.toolOverrides).toEqual([ { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, @@ -110,7 +108,6 @@ describe('toGatewayTargetSpec — apiGateway', () => { const onProgress = vi.fn(); const result = toGatewayTargetSpec(detail, new Map(), onProgress); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const apigw = (result as any).apiGateway; expect(apigw.apiGatewayToolConfiguration.toolOverrides).toBeUndefined(); }); @@ -176,7 +173,6 @@ describe('toGatewayTargetSpec — openApiSchema', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('openApiSchema'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const schemaSource = (result as any).schemaSource; expect(schemaSource.s3.uri).toBe('s3://my-bucket/schema.yaml'); expect(schemaSource.s3.bucketOwnerAccountId).toBe('123456789012'); @@ -222,7 +218,6 @@ describe('toGatewayTargetSpec — smithyModel', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('smithyModel'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const schemaSource = (result as any).schemaSource; expect(schemaSource.s3.uri).toBe('s3://models-bucket/model.json'); expect(schemaSource.s3.bucketOwnerAccountId).toBeUndefined(); @@ -269,7 +264,6 @@ describe('toGatewayTargetSpec — lambda', () => { expect(result!.name).toBe('test_target'); expect(result!.targetType).toBe('lambdaFunctionArn'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const lambdaConfig = (result as any).lambdaFunctionArn; expect(lambdaConfig.lambdaArn).toBe('arn:aws:lambda:us-west-2:123456789012:function:my-func'); expect(lambdaConfig.toolSchemaFile).toBe('s3://schemas/tools.json'); diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 1359232c3..75ffa829a 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,12 +1,13 @@ import { type Result, ValidationError, serializeResult } from '../../../lib'; import { getErrorMessage } from '../../errors'; -import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import { runCliCommand, withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; import { InvokeScreen } from '../../tui/screens/invoke'; import { parseHeaderFlags } from '../shared/header-utils'; import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action'; +import { type InvokeInterceptorOptions, handleInvokeInterceptor } from './interceptor'; import { resolvePrompt } from './resolve-prompt'; import type { InvokeOptions, InvokeResult } from './types'; import { validateInvokeOptions } from './validate'; @@ -97,10 +98,9 @@ function printInvokeResult(result: InvokeResult, options: InvokeOptions): void { } export const registerInvoke = (program: Command) => { - program - .command('invoke') - .alias('i') - .description(COMMAND_DESCRIPTIONS.invoke) + const invokeCmd = program.command('invoke').alias('i').description(COMMAND_DESCRIPTIONS.invoke); + + invokeCmd .argument( '[prompt]', 'Prompt to send to the agent. Also accepts piped stdin when no prompt is provided and stdin is not a TTY [non-interactive]' @@ -279,4 +279,38 @@ export const registerInvoke = (program: Command) => { } } ); + + // ── agentcore invoke interceptor ────────────────────────────────────────── + // Subcommand for managed Lambda interceptors. External interceptors + // short-circuit via the shared mode-check helper. Commander matches + // registered subcommand names before treating positional args as the + // runtime prompt, so `agentcore invoke "say hello"` continues to route to + // the existing root-level handler unaffected. + invokeCmd + .command('interceptor') + .description('Invoke a Lambda interceptor (managed mode only)') + .option('--name ', 'Interceptor name (required)') + .option('--target ', 'Deployment target (defaults to first target)') + .option('--payload ', 'Inline JSON payload') + .option('--payload-file ', 'Path to a JSON file containing the payload') + .option('--json', 'Output as JSON') + .action(async (cliOptions: InvokeInterceptorOptions) => { + requireProject(); + try { + await runCliCommand('invoke.interceptor', !!cliOptions.json, async () => { + const r = await handleInvokeInterceptor(cliOptions); + if (!r.success) { + throw r.error; + } + return { mode: 'managed' as const, has_payload_file: !!cliOptions.payloadFile }; + }); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render({getErrorMessage(error)}); + } + process.exit(1); + } + }); }; diff --git a/src/cli/commands/invoke/interceptor.ts b/src/cli/commands/invoke/interceptor.ts new file mode 100644 index 000000000..21024fa9f --- /dev/null +++ b/src/cli/commands/invoke/interceptor.ts @@ -0,0 +1,123 @@ +import { ConfigIO, type Result, ValidationError } from '../../../lib'; +import { getCredentialProvider } from '../../aws/account'; +import { ensureManagedForInvoke } from '../shared/interceptor-mode-check'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { readFile } from 'node:fs/promises'; + +export interface InvokeInterceptorOptions { + /** Interceptor name (required) */ + name?: string; + /** Deployment target name (defaults to first target). */ + target?: string; + /** Inline JSON payload. */ + payload?: string; + /** Path to a JSON file containing the payload. */ + payloadFile?: string; + /** Output as JSON. */ + json?: boolean; +} + +/** + * `agentcore invoke interceptor --name [--payload | --payload-file]`. + * + * Routes to `lambda:Invoke` against the managed interceptor's deployed-state + * ARN. External interceptors short-circuit via `ensureManagedForInvoke`, + * which throws a structured ValidationError carrying the `aws lambda invoke` + * remediation. + */ +export async function handleInvokeInterceptor( + options: InvokeInterceptorOptions +): Promise> { + if (!options.name) { + return { success: false, error: new ValidationError('--name is required') }; + } + + try { + const { entry, targetName } = await ensureManagedForInvoke(options.name, options.target); + + const configIO = new ConfigIO(); + const targets = await configIO.resolveAWSDeploymentTargets(); + const target = targets.find(t => t.name === targetName) ?? targets[0]; + if (!target) { + return { success: false, error: new ValidationError('No AWS deployment targets configured.') }; + } + + let payloadJson: string | undefined; + if (options.payloadFile) { + try { + payloadJson = await readFile(options.payloadFile, 'utf-8'); + } catch (readErr) { + const msg = readErr instanceof Error ? readErr.message : String(readErr); + return { + success: false, + error: new ValidationError(`Cannot read --payload-file "${options.payloadFile}": ${msg}`), + }; + } + } else if (options.payload) { + payloadJson = options.payload; + } + + if (payloadJson) { + try { + JSON.parse(payloadJson); + } catch { + return { + success: false, + error: new ValidationError( + 'Payload is not valid JSON. Provide an object via --payload or a JSON file via --payload-file.' + ), + }; + } + } + + const client = new LambdaClient({ region: target.region, credentials: getCredentialProvider() }); + const response = await client.send( + new InvokeCommand({ + FunctionName: entry.interceptorArn, + ...(payloadJson ? { Payload: new TextEncoder().encode(payloadJson) } : {}), + }) + ); + + let decoded: unknown; + if (response.Payload) { + const text = new TextDecoder().decode(response.Payload); + try { + decoded = JSON.parse(text); + } catch { + decoded = text; + } + } + + if (options.json) { + process.stdout.write( + `${JSON.stringify({ statusCode: response.StatusCode, payload: decoded, functionError: response.FunctionError })}\n` + ); + } else { + if (response.FunctionError) { + process.stderr.write(`FunctionError: ${response.FunctionError}\n`); + } + process.stdout.write(`${typeof decoded === 'string' ? decoded : JSON.stringify(decoded, null, 2)}\n`); + } + + if (response.FunctionError) { + // Lambda-level errors must produce a non-zero exit so scripted callers + // can detect them via $? — payload is preserved for diagnostics in the + // structured error. + return { + success: false, + error: new ValidationError(`Lambda FunctionError: ${response.FunctionError}`), + }; + } + + return { + success: true, + payload: decoded, + ...(response.StatusCode !== undefined && { statusCode: response.StatusCode }), + }; + } catch (err) { + if (err instanceof Error) { + return { success: false, error: err }; + } + return { success: false, error: new Error(String(err)) }; + } +} diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 1cd58c625..95e2ce300 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -63,6 +63,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, deployedState: { targets: { @@ -127,6 +128,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, }); const result = resolveAgentContext(context, {}); @@ -171,6 +173,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, deployedState: { targets: { @@ -225,6 +228,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + interceptors: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/logs/__tests__/interceptor.test.ts b/src/cli/commands/logs/__tests__/interceptor.test.ts new file mode 100644 index 000000000..a9d86d369 --- /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(/interceptorFunctionName/); + expect(r.error.message).toMatch(/agentcore deploy/); + } + }); + + it('maps ResourceNotFoundException to a structured ResourceNotFoundError with invoke remediation', async () => { + mockStreamLogs.mockImplementation(() => throwResourceNotFound()); + const r = await handleLogsInterceptor({ name: 'auth', follow: true }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.name).toBe('ResourceNotFoundError'); + expect(r.error.message).toMatch(/Log group/); + expect(r.error.message).toMatch(/agentcore invoke interceptor --name auth/); + } + }); + + it('returns success when streamLogs aborts via AbortError (Ctrl-C path)', async () => { + mockStreamLogs.mockImplementation(() => throwAbortError()); + const r = await handleLogsInterceptor({ name: 'auth', follow: true }); + expect(r.success).toBe(true); + }); + + it('rejects --limit when value is not a positive integer', async () => { + mockSearchLogs.mockImplementation(() => yieldNothing()); + const r = await handleLogsInterceptor({ name: 'auth', since: '1h', limit: 'abc' }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.message).toMatch(/--limit must be a positive integer/); + } + }); + + it('rejects --limit when value is zero', async () => { + mockSearchLogs.mockImplementation(() => yieldNothing()); + const r = await handleLogsInterceptor({ name: 'auth', since: '1h', limit: '0' }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.message).toMatch(/--limit must be a positive integer/); + } + }); + + it('removes the SIGINT listener after a successful streamLogs run', async () => { + const before = process.listenerCount('SIGINT'); + mockStreamLogs.mockImplementation(() => yieldNothing()); + await handleLogsInterceptor({ name: 'auth', follow: true }); + const after = process.listenerCount('SIGINT'); + expect(after).toBe(before); + }); + + it('removes the SIGINT listener after streamLogs aborts', async () => { + const before = process.listenerCount('SIGINT'); + mockStreamLogs.mockImplementation(() => throwAbortError()); + await handleLogsInterceptor({ name: 'auth', follow: true }); + const after = process.listenerCount('SIGINT'); + expect(after).toBe(before); + }); + + it('passes a populated abortSignal to streamLogs in follow mode', async () => { + mockStreamLogs.mockImplementation(() => yieldNothing()); + await handleLogsInterceptor({ name: 'auth', follow: true }); + expect(mockStreamLogs).toHaveBeenCalledOnce(); + const arg = mockStreamLogs.mock.calls[0]![0] as { abortSignal?: AbortSignal }; + expect(arg.abortSignal).toBeDefined(); + // signal is fresh (not yet aborted) at the time of the call + expect(arg.abortSignal!.aborted).toBe(false); + }); +}); diff --git a/src/cli/commands/logs/command.tsx b/src/cli/commands/logs/command.tsx index ba6d1ef21..d036fca1d 100644 --- a/src/cli/commands/logs/command.tsx +++ b/src/cli/commands/logs/command.tsx @@ -1,9 +1,11 @@ import { getErrorMessage } from '../../errors'; import { handleLogsEval } from '../../operations/eval'; import type { LogsEvalOptions } from '../../operations/eval'; +import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import { handleLogs } from './action'; +import { type LogsInterceptorOptions, handleLogsInterceptor } from './interceptor'; import type { LogsOptions } from './types'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; @@ -67,4 +69,34 @@ export const registerLogs = (program: Command) => { process.exit(1); } }); + + logsCmd + .command('interceptor') + .description('Stream or search Lambda interceptor logs (managed mode only)') + .option('--name ', 'Interceptor name (required)') + .option('--target ', 'Deployment target (defaults to first target)') + .option('--since