From 773764f982c377ec1fa8553529b0425bbac370c9 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 20 Jul 2026 11:50:48 -0400 Subject: [PATCH] feat(registry): design + storage + publish/resolve API (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of 3 for the central agent asset registry (ADR-018). Purely additive infrastructure — nothing in the orchestrator or agent reads from the registry yet; that follows in PR 2 (orchestrator + agent MCP E2E) and PR 3 (Cedar modules + skills). Substrate: DynamoDB + S3 (ADR-018 fallback option 2). The four fallback conditions already select it at authoring time — AgentCore Registry is in public preview and hard-migrates namespaces on 2026-08-06. The RegistryClient seam keeps the AgentCore path open as a later swap. - docs/design/REGISTRY.md: MVP asset kinds, DDB/S3 schema, API contract, resolution semantics, upstream-registry/federation stance (out of MVP scope). - Grammar: extend _REGISTRY_REF to registry://kind/namespace/name@constraint (snake_case kinds, mandatory semver pin). New contracts/registry-resolution parity corpus proves the Python regex and TS parseRef agree byte-for-byte. - Shared types (canonical status='approved'; 'active' is not a code value), mirrored CDK<->CLI. - registry-resolver.ts: parseRef/resolveRef/resolveAll, fail-closed, semver ranked in code (DDB sorts versions lexicographically). - RegistryAssetsTable (pk/sk + kind-index GSI) and RegistryArtifactsBucket (versioned, TLS-only, SSE) constructs, wired into AgentStack. - Publish/resolve/list/show handlers: immutability 409, descriptor validation, Cognito RegistryPublisher/Approver gating; /registry routes on TaskApi with least-privilege IAM grants. Depends on #548 (ADR-018) — the REGISTRY.md link to the ADR resolves once that PR merges to main; docs link-check stays red until then by design. --- agent/src/workflow/__init__.py | 3 +- agent/src/workflow/validator.py | 27 +- agent/tests/test_registry_grammar_corpus.py | 66 ++++ agent/tests/test_workflow_validator.py | 14 +- cdk/package.json | 2 + .../constructs/registry-artifacts-bucket.ts | 102 ++++++ cdk/src/constructs/registry-assets-table.ts | 113 +++++++ cdk/src/constructs/task-api.ts | 89 +++++ cdk/src/handlers/registry-list.ts | 98 ++++++ cdk/src/handlers/registry-publish.ts | 192 +++++++++++ cdk/src/handlers/registry-resolve.ts | 103 ++++++ cdk/src/handlers/registry-show.ts | 91 +++++ cdk/src/handlers/shared/gateway.ts | 24 ++ .../handlers/shared/registry-descriptor.ts | 169 ++++++++++ cdk/src/handlers/shared/registry-resolver.ts | 305 +++++++++++++++++ cdk/src/handlers/shared/response.ts | 4 + cdk/src/handlers/shared/types.ts | 152 +++++++++ cdk/src/stacks/agent.ts | 18 + .../registry-artifacts-bucket.test.ts | 150 +++++++++ .../constructs/registry-assets-table.test.ts | 123 +++++++ cdk/test/constructs/task-api.test.ts | 67 ++++ cdk/test/handlers/registry-list-show.test.ts | 132 ++++++++ cdk/test/handlers/registry-publish.test.ts | 133 ++++++++ cdk/test/handlers/registry-resolve.test.ts | 111 ++++++ .../shared/registry-descriptor.test.ts | 117 +++++++ .../handlers/shared/registry-resolver.test.ts | 316 ++++++++++++++++++ cdk/test/stacks/agent.test.ts | 5 +- cli/src/types.ts | 88 +++++ contracts/registry-resolution/README.md | 82 +++++ .../grammar-invalid-floating-latest.json | 6 + .../grammar-invalid-floating-range.json | 6 + .../grammar-invalid-legacy-2segment.json | 6 + .../grammar-invalid-no-constraint.json | 6 + .../grammar-invalid-scheme.json | 6 + ...rammar-invalid-underscore-kind-legacy.json | 6 + .../grammar-invalid-uppercase-kind.json | 6 + .../grammar-invalid-wildcard.json | 6 + .../grammar-valid-exact.json | 6 + .../grammar-valid-mcp-caret.json | 6 + .../grammar-valid-prerelease.json | 6 + .../grammar-valid-skill-tilde.json | 6 + ...rule11-comment-outcome-s3-only-target.json | 2 +- ...main-default-repo-less-has-repo-steps.json | 4 +- ...rule7-repo-less-discover-and-provider.json | 4 +- ...nowledge-domain-default-requires-repo.json | 4 +- .../valid-knowledge-web-research.json | 4 +- docs/design/REGISTRY.md | 240 +++++++++++++ .../src/content/docs/architecture/Registry.md | 244 ++++++++++++++ scripts/check-types-sync.ts | 5 + yarn.lock | 5 + 50 files changed, 3465 insertions(+), 15 deletions(-) create mode 100644 agent/tests/test_registry_grammar_corpus.py create mode 100644 cdk/src/constructs/registry-artifacts-bucket.ts create mode 100644 cdk/src/constructs/registry-assets-table.ts create mode 100644 cdk/src/handlers/registry-list.ts create mode 100644 cdk/src/handlers/registry-publish.ts create mode 100644 cdk/src/handlers/registry-resolve.ts create mode 100644 cdk/src/handlers/registry-show.ts create mode 100644 cdk/src/handlers/shared/registry-descriptor.ts create mode 100644 cdk/src/handlers/shared/registry-resolver.ts create mode 100644 cdk/test/constructs/registry-artifacts-bucket.test.ts create mode 100644 cdk/test/constructs/registry-assets-table.test.ts create mode 100644 cdk/test/handlers/registry-list-show.test.ts create mode 100644 cdk/test/handlers/registry-publish.test.ts create mode 100644 cdk/test/handlers/registry-resolve.test.ts create mode 100644 cdk/test/handlers/shared/registry-descriptor.test.ts create mode 100644 cdk/test/handlers/shared/registry-resolver.test.ts create mode 100644 contracts/registry-resolution/README.md create mode 100644 contracts/registry-resolution/grammar-invalid-floating-latest.json create mode 100644 contracts/registry-resolution/grammar-invalid-floating-range.json create mode 100644 contracts/registry-resolution/grammar-invalid-legacy-2segment.json create mode 100644 contracts/registry-resolution/grammar-invalid-no-constraint.json create mode 100644 contracts/registry-resolution/grammar-invalid-scheme.json create mode 100644 contracts/registry-resolution/grammar-invalid-underscore-kind-legacy.json create mode 100644 contracts/registry-resolution/grammar-invalid-uppercase-kind.json create mode 100644 contracts/registry-resolution/grammar-invalid-wildcard.json create mode 100644 contracts/registry-resolution/grammar-valid-exact.json create mode 100644 contracts/registry-resolution/grammar-valid-mcp-caret.json create mode 100644 contracts/registry-resolution/grammar-valid-prerelease.json create mode 100644 contracts/registry-resolution/grammar-valid-skill-tilde.json create mode 100644 docs/design/REGISTRY.md create mode 100644 docs/src/content/docs/architecture/Registry.md diff --git a/agent/src/workflow/__init__.py b/agent/src/workflow/__init__.py index 9ffcef99..a1ba8f94 100644 --- a/agent/src/workflow/__init__.py +++ b/agent/src/workflow/__init__.py @@ -42,7 +42,7 @@ gate_status, run_workflow, ) -from .validator import assert_valid, validate_workflow +from .validator import assert_valid, is_registry_ref, validate_workflow __all__ = [ "STEP_HANDLERS", @@ -62,6 +62,7 @@ "WorkflowValidationError", "assert_valid", "gate_status", + "is_registry_ref", "load_workflow", "load_workflow_file", "policy_principal_for", diff --git a/agent/src/workflow/validator.py b/agent/src/workflow/validator.py index 25f2a6ee..ede3aeed 100644 --- a/agent/src/workflow/validator.py +++ b/agent/src/workflow/validator.py @@ -46,8 +46,23 @@ # Built-in (Phase 1-3) policy modules / MCP servers. Registry refs (registry://) # are accepted syntactically now and resolved against #246 in Phase 4 (rule 8). +# +# _REGISTRY_REF is the #246 asset grammar (docs/design/REGISTRY.md §6, ADR-018): +# registry:////@ +# The kind segment is snake_case (mcp_server, cedar_policy_module) and the +# @ pin is MANDATORY (fail-closed, ADR-018 sub-decision 6) — exact, +# caret, or tilde semver only; floating (*, latest, >=) is rejected. This regex +# MUST stay byte-for-byte identical to the TypeScript ``parseRef`` +# (cdk/src/handlers/shared/registry-resolver.ts); the ``contracts/registry-resolution/`` +# corpus is the agreement both sides reproduce. _BUILTIN_REF = re.compile(r"^builtin/[a-z][a-z0-9_]*$") -_REGISTRY_REF = re.compile(r"^registry://[a-z][a-z0-9-]*/[a-z0-9][a-z0-9./-]*$") +_REGISTRY_REF = re.compile( + r"^registry://" + r"[a-z][a-z0-9_]*/" # kind (snake_case) + r"[a-z][a-z0-9-]*/" # namespace + r"[a-z0-9][a-z0-9._-]*" # name + r"@[\^~]?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$" # @constraint (exact/caret/tilde) +) # Mutating built-in tools — forbidden under the read-only tier (rule 6) and when # read_only:true (rule 4, shape half is in the schema). @@ -113,6 +128,16 @@ def _ref_resolves(ref: str) -> bool: return bool(_BUILTIN_REF.match(ref) or _REGISTRY_REF.match(ref)) +def is_registry_ref(ref: str) -> bool: + """True iff ``ref`` matches the #246 registry URI grammar (REGISTRY.md §6). + + Public entry point for the ``contracts/registry-resolution/`` parity corpus: + this MUST agree byte-for-byte with the TypeScript ``parseRef``. Does not + match ``builtin/*`` — grammar only, not resolvability. + """ + return bool(_REGISTRY_REF.match(ref)) + + # --- the rules --------------------------------------------------------------- # Each returns a list of human-readable messages (empty == passed). Rule numbers # match WORKFLOWS.md §"Validation rules". Rules 3/4/7 also have a schema half; diff --git a/agent/tests/test_registry_grammar_corpus.py b/agent/tests/test_registry_grammar_corpus.py new file mode 100644 index 00000000..5f60648f --- /dev/null +++ b/agent/tests/test_registry_grammar_corpus.py @@ -0,0 +1,66 @@ +"""Registry URI grammar parity — agent side (#246). + +Loads every ``contracts/registry-resolution/grammar-*.json`` fixture and asserts +that ``workflow.is_registry_ref(ref)`` agrees with the fixture's ``expected.valid``. +This is the golden contract that the TypeScript ``parseRef`` +(cdk/src/handlers/shared/registry-resolver.ts) must also reproduce — mirroring +the cross-engine pattern in ``test_cedar_parity.py`` and the workflow-validation +corpus. + +If the regex and a fixture disagree, CI fails before the change ships: either +the grammar regressed, or the fixture must be updated as a recorded decision. +See ``contracts/registry-resolution/README.md`` and REGISTRY.md §6. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from workflow import is_registry_ref + +_FIXTURE_DIR = ( + Path(os.path.dirname(__file__)) / ".." / ".." / "contracts" / "registry-resolution" +).resolve() + + +def _load_grammar_fixtures() -> list[dict]: + assert _FIXTURE_DIR.is_dir(), ( + f"expected fixture dir at {_FIXTURE_DIR}; " + "see contracts/registry-resolution/README.md" + ) + fixtures = [] + for path in sorted(_FIXTURE_DIR.glob("grammar-*.json")): + fixture = json.loads(path.read_text(encoding="utf-8")) + for required in ("name", "ref", "expected"): + if required not in fixture: + raise AssertionError(f"{path.name}: missing required field {required!r}") + if "valid" not in fixture["expected"]: + raise AssertionError(f"{path.name}: expected missing 'valid'") + fixtures.append(fixture) + assert fixtures, f"no grammar-*.json fixtures under {_FIXTURE_DIR}" + return fixtures + + +_FIXTURES = _load_grammar_fixtures() + + +@pytest.mark.parametrize("fixture", _FIXTURES, ids=[f["name"] for f in _FIXTURES]) +def test_grammar_matches_fixture(fixture: dict) -> None: + observed = is_registry_ref(fixture["ref"]) + expected = fixture["expected"]["valid"] + assert observed == expected, ( + f"fixture {fixture['name']!r}: grammar drift — is_registry_ref({fixture['ref']!r}) " + f"returned {observed}, fixture expects {expected}" + ) + + +def test_corpus_covers_valid_and_invalid() -> None: + """The corpus must exercise both accept and reject paths.""" + valids = [f for f in _FIXTURES if f["expected"]["valid"]] + invalids = [f for f in _FIXTURES if not f["expected"]["valid"]] + assert valids, "grammar corpus must include at least one valid ref" + assert invalids, "grammar corpus must include at least one invalid ref" diff --git a/agent/tests/test_workflow_validator.py b/agent/tests/test_workflow_validator.py index 85ffa786..bf24adb8 100644 --- a/agent/tests/test_workflow_validator.py +++ b/agent/tests/test_workflow_validator.py @@ -150,7 +150,7 @@ def test_rule6_read_only_tier_rejects_elevated_fields(self): w["agent_config"]["tier"] = "read-only" w["agent_config"]["allowed_tools"] = ["Bash", "Read"] w["agent_config"]["cedar_policy_modules"] = ["builtin/hard_deny"] - w["agent_config"]["skills"] = ["registry://skill/x-v1"] + w["agent_config"]["skills"] = ["registry://skill/acme/x@^1.0.0"] w["steps"] = [ {"kind": "clone_repo"}, {"kind": "hydrate_context"}, @@ -165,7 +165,17 @@ def test_rule6_read_only_tier_rejects_elevated_fields(self): "ref,bad", [ ("builtin/soft_deny", False), - ("registry://cedar/custom-v1", False), + # #246 grammar (REGISTRY.md §6): registry://kind/namespace/name@constraint + ("registry://cedar_policy_module/acme/custom@^1.0.0", False), + ("registry://cedar_policy_module/acme/custom@1.0.0", False), + ("registry://cedar_policy_module/acme/custom@~1.2.3", False), + # legacy 2-segment form is no longer valid grammar (no namespace, no pin) + ("registry://cedar/custom-v1", True), + # unpinned 3-segment is rejected — pins are mandatory (fail-closed) + ("registry://cedar_policy_module/acme/custom", True), + # floating constraints rejected + ("registry://cedar_policy_module/acme/custom@latest", True), + ("registry://cedar_policy_module/acme/custom@>=1.0.0", True), ("http://evil", True), ("soft_deny", True), ], diff --git a/cdk/package.json b/cdk/package.json index be16744a..cb851dd8 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -36,6 +36,7 @@ "constructs": "^10.6.0", "js-yaml": "^4.1.1", "pdf-parse": "^2.4.5", + "semver": "^7.8.5", "ulid": "^3.0.2", "ws": "^8.21.0" }, @@ -49,6 +50,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^26", "@types/pdf-parse": "^1.1.5", + "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", diff --git a/cdk/src/constructs/registry-artifacts-bucket.ts b/cdk/src/constructs/registry-artifacts-bucket.ts new file mode 100644 index 00000000..c96e4975 --- /dev/null +++ b/cdk/src/constructs/registry-artifacts-bucket.ts @@ -0,0 +1,102 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; + +/** + * How long a noncurrent (superseded) object version is retained before expiry. + * The registry is immutable-per-version, so overwrites at a live key should not + * happen; this rule reaps versions left behind by a ``removed`` tombstone or an + * operational re-put, keeping storage bounded without touching current objects. + */ +export const REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS = 90; + +/** + * Properties for the RegistryArtifactsBucket construct. + */ +export interface RegistryArtifactsBucketProps { + /** + * Removal policy for the bucket. Registry artifacts are referenced by pinned + * task records for audit/reproducibility, so production should RETAIN; the + * construct default stays DESTROY for the sample stack's dev-first posture. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed (so ``cdk + * destroy`` does not need a manual bucket-empty first). Mirrors the other + * sample-stack buckets. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * S3 bucket for agent asset registry artifact bytes (#246; see + * docs/design/REGISTRY.md §3.4 and ADR-018). + * + * Stores the artifact for each published asset version — the MCP server config + * JSON, Cedar policy text, or skill prompt fragment — under the key + * ``{kind}/{namespace}/{name}/{version}/artifact``. Metadata lives in + * ``RegistryAssetsTable``; only the bytes live here. + * + * Differs from the ephemeral ``EcsPayloadBucket``: artifacts are durable + * (referenced by pinned task records for audit and reproducibility), so + * **versioning is ON** and there is no whole-object TTL — only a noncurrent- + * version expiry to bound storage. Immutability of a published + * ``(kind, namespace, name, version)`` is enforced at the DynamoDB write (409 + * on collision), not by S3 object-lock, so a ``removed`` status can tombstone a + * record without a compliance-grade byte delete. + * + * Security / hygiene (parity with the other sample buckets): + * - ``blockPublicAccess: BLOCK_ALL`` + ``enforceSSL: true`` — no public read, + * TLS-only transport. + * - ``encryption: S3_MANAGED`` — server-side encryption at rest. + * - ``versioned: true`` — retain superseded bytes for audit. + */ +export class RegistryArtifactsBucket extends Construct { + /** The underlying S3 bucket. */ + public readonly bucket: s3.Bucket; + + constructor(scope: Construct, id: string, props: RegistryArtifactsBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + versioned: true, + lifecycleRules: [ + { + id: 'registry-artifact-noncurrent-expiry', + enabled: true, + noncurrentVersionExpiration: Duration.days(REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS), + // Reap incomplete multipart uploads after 1 day so stale upload parts + // (which object/version expiry does not cover) do not linger. + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + autoDeleteObjects: props.autoDeleteObjects ?? true, + }); + } +} diff --git a/cdk/src/constructs/registry-assets-table.ts b/cdk/src/constructs/registry-assets-table.ts new file mode 100644 index 00000000..2310026f --- /dev/null +++ b/cdk/src/constructs/registry-assets-table.ts @@ -0,0 +1,113 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** GSI name for listing every asset of a given kind (REGISTRY.md §3.1). */ +export const REGISTRY_KIND_INDEX = 'kind-index'; + +/** + * Properties for the RegistryAssetsTable construct. + */ +export interface RegistryAssetsTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. Registry records are the source of truth for + * what a task may load and are immutable-per-version, so production + * deployments should RETAIN; the construct default stays DESTROY to match the + * rest of the sample stack's dev-first posture. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table backing the agent asset registry (#246; see + * docs/design/REGISTRY.md §3.1 and ADR-018). + * + * Schema: + * - ``pk`` (partition) = ``{kind}#{namespace}/{name}`` — groups every version + * of one asset under a single partition. + * - ``sk`` (sort) = the semver ``version`` string. + * + * A single ``Query`` on ``pk`` returns all versions of an asset (show + resolve; + * resolution ranks by parsed semver in code, since DynamoDB sorts the version + * sort key lexicographically). The ``kind-index`` GSI (partition ``kind``, sort + * ``pk``) powers ``GET /registry/assets?kind=...`` without a table scan. + * + * There is no TTL: registry records are immutable-per-version and audited; a + * ``removed`` status tombstones a record rather than deleting the row (a task + * pinned to it fails closed with ``REMOVED``). + */ +export class RegistryAssetsTable extends Construct { + /** + * The underlying DynamoDB table. Use this to grant access or read the table name. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: RegistryAssetsTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'pk', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'sk', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: REGISTRY_KIND_INDEX, + partitionKey: { + name: 'kind', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'pk', + type: dynamodb.AttributeType.STRING, + }, + // The list endpoint needs kind, namespace, name, version, and status to + // render a summary — a KEYS_ONLY projection would force a second read per + // row, so project everything. + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index d2f67993..e4284bd2 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -186,6 +186,20 @@ export interface TaskApiProps { * Required when attachmentsBucket is provided. */ readonly userConcurrencyTable?: dynamodb.ITable; + + /** + * Agent asset registry table (#246). When provided together with + * {@link registryArtifactsBucket}, the four ``/registry`` routes + * (publish/resolve/list/show) are created. See docs/design/REGISTRY.md. + */ + readonly registryAssetsTable?: dynamodb.ITable; + + /** + * Agent asset registry artifact bucket (#246). Publish writes artifact bytes + * here; resolve issues short-lived presigned GETs. Paired with + * {@link registryAssetsTable}. + */ + readonly registryArtifactsBucket?: s3.IBucket; } /** @@ -1143,6 +1157,81 @@ export class TaskApi extends Construct { allFunctions.push(createWebhookFn, listWebhooksFn, deleteWebhookFn, webhookAuthorizerFn, webhookCreateTaskFn); } + // --- Agent asset registry endpoints (#246) --- + // Created only when both storage handles are provided. Publish/resolve/ + // list/show over the shared Cognito authorizer; publish additionally gates + // on the RegistryPublisher/RegistryApprover groups inside the handler. + if (props.registryAssetsTable && props.registryArtifactsBucket) { + const registryEnv = { + REGISTRY_ASSETS_TABLE_NAME: props.registryAssetsTable.tableName, + REGISTRY_ARTIFACTS_BUCKET_NAME: props.registryArtifactsBucket.bucketName, + }; + + const registryPublishFn = new lambda.NodejsFunction(this, 'RegistryPublishFn', { + entry: path.join(handlersDir, 'registry-publish.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + }); + + const registryResolveFn = new lambda.NodejsFunction(this, 'RegistryResolveFn', { + entry: path.join(handlersDir, 'registry-resolve.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + }); + + const registryListFn = new lambda.NodejsFunction(this, 'RegistryListFn', { + entry: path.join(handlersDir, 'registry-list.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + }); + + const registryShowFn = new lambda.NodejsFunction(this, 'RegistryShowFn', { + entry: path.join(handlersDir, 'registry-show.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + }); + + // IAM: publish read/writes the table + writes artifacts; resolve/list/ + // show are read-only (least privilege, REGISTRY.md §11 grants table). + props.registryAssetsTable.grantReadWriteData(registryPublishFn); + props.registryArtifactsBucket.grantPut(registryPublishFn); + props.registryAssetsTable.grantReadData(registryResolveFn); + props.registryArtifactsBucket.grantRead(registryResolveFn); + props.registryAssetsTable.grantReadData(registryListFn); + props.registryAssetsTable.grantReadData(registryShowFn); + + // --- API resource tree: /registry --- + const registry = this.api.root.addResource('registry'); + + const registryAssets = registry.addResource('assets'); + registryAssets.addMethod('POST', new apigw.LambdaIntegration(registryPublishFn), cognitoAuthOptions); + registryAssets.addMethod('GET', new apigw.LambdaIntegration(registryListFn), cognitoAuthOptions); + + // /registry/assets/{kind}/{namespace}/{name} — show all versions + const registryShowResource = registryAssets + .addResource('{kind}') + .addResource('{namespace}') + .addResource('{name}'); + registryShowResource.addMethod('GET', new apigw.LambdaIntegration(registryShowFn), cognitoAuthOptions); + + const registryResolve = registry.addResource('resolve'); + registryResolve.addMethod('GET', new apigw.LambdaIntegration(registryResolveFn), cognitoAuthOptions); + + allFunctions.push(registryPublishFn, registryResolveFn, registryListFn, registryShowFn); + } + // --- cdk-nag suppressions for CDK-generated IAM policies --- for (const fn of allFunctions) { NagSuppressions.addResourceSuppressions(fn, [ diff --git a/cdk/src/handlers/registry-list.ts b/cdk/src/handlers/registry-list.ts new file mode 100644 index 00000000..c0c158a5 --- /dev/null +++ b/cdk/src/handlers/registry-list.ts @@ -0,0 +1,98 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import * as semver from 'semver'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { REGISTRY_KIND_INDEX } from '../constructs/registry-assets-table'; +import { PUBLISHABLE_KINDS, RESERVED_KINDS } from './shared/registry-descriptor'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryAssetKind, RegistryAssetRecord } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.REGISTRY_ASSETS_TABLE_NAME!; + +/** + * GET /v1/registry/assets?kind=mcp_server — list assets of a kind + * (REGISTRY.md §4.3). Queries the ``kind-index`` GSI, collapses versions to + * one row per asset (highest approved/deprecated version), and excludes + * ``removed`` unless ``?status=removed`` is requested. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + if (!extractUserId(event)) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const kind = event.queryStringParameters?.kind; + if (!kind || (!PUBLISHABLE_KINDS.has(kind as RegistryAssetKind) && !RESERVED_KINDS.has(kind as RegistryAssetKind))) { + return errorResponse( + 400, + ErrorCode.VALIDATION_ERROR, + 'Query parameter "kind" is required and must be a known asset kind.', + requestId, + ); + } + const namespaceFilter = event.queryStringParameters?.namespace; + + const result = await ddb.send( + new QueryCommand({ + TableName: TABLE_NAME, + IndexName: REGISTRY_KIND_INDEX, + KeyConditionExpression: 'kind = :kind', + ExpressionAttributeValues: { ':kind': kind }, + }), + ); + const rows = (result.Items ?? []) as unknown as RegistryAssetRecord[]; + + // Collapse to one summary per (namespace/name), keeping the highest semver + // version and its status. Exclude removed rows unless explicitly requested. + const includeRemoved = event.queryStringParameters?.status === 'removed'; + const byAsset = new Map(); + for (const row of rows) { + if (namespaceFilter && row.namespace !== namespaceFilter) continue; + if (row.status === 'removed' && !includeRemoved) continue; + const key = row.pk; + const current = byAsset.get(key); + if (!current || semver.gt(row.version, current.version)) { + byAsset.set(key, row); + } + } + + const assets = [...byAsset.values()].map((r) => ({ + kind: r.kind, + namespace: r.namespace, + name: r.name, + latest_version: r.version, + status: r.status, + })); + + logger.info('registry list', { kind, count: assets.length, request_id: requestId }); + return successResponse(200, { assets }, requestId); + } catch (err) { + logger.error('registry list failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/registry-publish.ts b/cdk/src/handlers/registry-publish.ts new file mode 100644 index 00000000..708c7c23 --- /dev/null +++ b/cdk/src/handlers/registry-publish.ts @@ -0,0 +1,192 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractGroups, extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { + artifactKey, + asDescriptor, + KINDS_REQUIRING_ARTIFACT, + publishPk, + validatePublish, + type PublishInput, +} from './shared/registry-descriptor'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryAssetKind, RegistryAssetRecord, RegistryAssetStatus } from './shared/types'; +import { parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const s3 = new S3Client({}); +const TABLE_NAME = process.env.REGISTRY_ASSETS_TABLE_NAME!; +const BUCKET_NAME = process.env.REGISTRY_ARTIFACTS_BUCKET_NAME!; + +/** Cognito group that may create records (lands in ``submitted``). */ +export const PUBLISHER_GROUP = 'RegistryPublisher'; +/** Cognito group that may approve/reject/deprecate and auto-approve on publish. */ +export const APPROVER_GROUP = 'RegistryApprover'; + +/** + * POST /v1/registry/assets — publish a new asset version (REGISTRY.md §4.1). + * + * Auth: caller must be in ``RegistryPublisher`` (or ``RegistryApprover``). + * Record lands ``submitted`` unless the caller is an approver and passes + * ``?auto_approve=true`` (dev), in which case it lands ``approved``. + * Immutability: 409 on ``(kind, namespace, name, version)`` collision. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const groups = extractGroups(event); + const isPublisher = groups.includes(PUBLISHER_GROUP); + const isApprover = groups.includes(APPROVER_GROUP); + if (!isPublisher && !isApprover) { + return errorResponse( + 403, + ErrorCode.FORBIDDEN, + `Publishing requires membership in the ${PUBLISHER_GROUP} group.`, + requestId, + ); + } + + const body = parseBody(event.body); + if (!body) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must be valid JSON.', requestId); + } + + const violations = validatePublish(body); + if (violations.length > 0) { + return errorResponse( + 400, + ErrorCode.VALIDATION_ERROR, + `Invalid publish request: ${violations.map((x) => `${x.field}: ${x.message}`).join('; ')}`, + requestId, + ); + } + + // Post-validation, the identity fields are known-good strings. + const kind = body.kind as RegistryAssetKind; + const namespace = body.namespace as string; + const name = body.name as string; + const version = body.version as string; + const pk = publishPk(kind, namespace, name); + + // Auto-approve is a dev convenience gated on approver rights (REGISTRY.md §10). + const autoApprove = event.queryStringParameters?.auto_approve === 'true' && isApprover; + const status: RegistryAssetStatus = autoApprove ? 'approved' : 'submitted'; + const now = new Date().toISOString(); + + // 1. Upload artifact (if the kind carries one). Keyed by the immutable + // (kind, ns, name, version) tuple; a 409 below prevents overwrite races + // for the DDB row, and the bucket is versioned so an artifact re-put is + // recoverable. + let artifactRef: string | undefined; + if (KINDS_REQUIRING_ARTIFACT.has(kind)) { + artifactRef = artifactKey(kind, namespace, name, version); + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: artifactRef, + Body: Buffer.from(body.artifact_b64 as string, 'base64'), + }), + ); + } + + // 2. Write the record with an immutability guard. + const record: RegistryAssetRecord = { + pk, + sk: version, + kind, + namespace, + name, + version, + descriptor: asDescriptor(body.descriptor), + artifact_ref: artifactRef, + status, + publisher: userId, + created_at: now, + status_history: [{ status, actor: userId, at: now }], + }; + + try { + await ddb.send( + new PutCommand({ + TableName: TABLE_NAME, + Item: record, + ConditionExpression: 'attribute_not_exists(pk) AND attribute_not_exists(sk)', + }), + ); + } catch (err) { + if (isConditionalCheckFailed(err)) { + return errorResponse( + 409, + ErrorCode.REGISTRY_VERSION_EXISTS, + `${kind}/${namespace}/${name}@${version} already exists; publish a new version instead.`, + requestId, + ); + } + throw err; + } + + logger.info('registry asset published', { + pk, + version, + status, + publisher: userId, + request_id: requestId, + }); + + return successResponse( + 201, + { + kind, + namespace, + name, + version, + status, + artifact_ref: artifactRef, + created_at: now, + }, + requestId, + ); + } catch (err) { + logger.error('registry publish failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} + +/** True when a DDB error is a ConditionalCheckFailedException (immutability hit). */ +function isConditionalCheckFailed(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'ConditionalCheckFailedException' + ); +} diff --git a/cdk/src/handlers/registry-resolve.ts b/cdk/src/handlers/registry-resolve.ts new file mode 100644 index 00000000..a068dae0 --- /dev/null +++ b/cdk/src/handlers/registry-resolve.ts @@ -0,0 +1,103 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { artifactKey, KINDS_REQUIRING_ARTIFACT } from './shared/registry-descriptor'; +import { + parseRef, + RegistryResolutionError, + resolveRef, +} from './shared/registry-resolver'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; + +const s3 = new S3Client({}); +const BUCKET_NAME = process.env.REGISTRY_ARTIFACTS_BUCKET_NAME!; + +/** Presigned artifact URL lifetime (seconds). */ +const ARTIFACT_URL_TTL_SECONDS = 300; + +/** + * GET /v1/registry/resolve?ref=registry://... — resolve a ref to a pinned + * asset (REGISTRY.md §4.2). Returns the descriptor, the concrete version, a + * short-lived presigned artifact URL (for kinds with an artifact), and any + * warnings. Fails 422 with a specific reason on any unresolved ref. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + // Resolve/read is available to any authenticated caller (REGISTRY.md §10). + if (!extractUserId(event)) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const ref = event.queryStringParameters?.ref; + if (!ref) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Query parameter "ref" is required.', requestId); + } + + let resolved; + try { + resolved = await resolveRef(ref); + } catch (err) { + if (err instanceof RegistryResolutionError) { + return errorResponse( + 422, + ErrorCode.REGISTRY_RESOLUTION_FAILED, + `Could not resolve ${ref}: ${err.reason}.`, + requestId, + ); + } + throw err; + } + + // Presign the artifact for kinds that have one, so a caller can fetch bytes + // directly without a second round-trip. + let artifactUrl: string | undefined; + if (KINDS_REQUIRING_ARTIFACT.has(resolved.kind)) { + const key = artifactKey(resolved.kind, resolved.namespace, resolved.name, resolved.version); + artifactUrl = await getSignedUrl( + s3, + new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }), + { expiresIn: ARTIFACT_URL_TTL_SECONDS }, + ); + } + + logger.info('registry ref resolved via API', { + ref, + version: resolved.version, + warnings: resolved.warnings, + request_id: requestId, + }); + + return successResponse(200, { ...resolved, artifact_url: artifactUrl }, requestId); + } catch (err) { + logger.error('registry resolve failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} + +// parseRef is imported to keep the grammar entry point discoverable alongside +// the handler; the resolver uses it internally. Re-export for callers/tests. +export { parseRef }; diff --git a/cdk/src/handlers/registry-show.ts b/cdk/src/handlers/registry-show.ts new file mode 100644 index 00000000..21abcc72 --- /dev/null +++ b/cdk/src/handlers/registry-show.ts @@ -0,0 +1,91 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import * as semver from 'semver'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { publishPk, PUBLISHABLE_KINDS, RESERVED_KINDS } from './shared/registry-descriptor'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryAssetKind, RegistryAssetRecord } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.REGISTRY_ASSETS_TABLE_NAME!; + +/** + * GET /v1/registry/assets/{kind}/{namespace}/{name} — show every version of a + * single asset (REGISTRY.md §4.4). Single Query on the partition; versions are + * returned newest-semver-first. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + if (!extractUserId(event)) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const kind = event.pathParameters?.kind; + const namespace = event.pathParameters?.namespace; + const name = event.pathParameters?.name; + if (!kind || !namespace || !name) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Path must be /registry/assets/{kind}/{namespace}/{name}.', requestId); + } + if (!PUBLISHABLE_KINDS.has(kind as RegistryAssetKind) && !RESERVED_KINDS.has(kind as RegistryAssetKind)) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, `Unknown asset kind ${kind}.`, requestId); + } + + const result = await ddb.send( + new QueryCommand({ + TableName: TABLE_NAME, + KeyConditionExpression: 'pk = :pk', + ExpressionAttributeValues: { ':pk': publishPk(kind, namespace, name) }, + }), + ); + const rows = (result.Items ?? []) as unknown as RegistryAssetRecord[]; + + if (rows.length === 0) { + return errorResponse( + 404, + ErrorCode.REGISTRY_ASSET_NOT_FOUND, + `No asset ${kind}/${namespace}/${name}.`, + requestId, + ); + } + + const versions = rows + .slice() + .sort((a, b) => semver.rcompare(a.version, b.version)) + .map((r) => ({ + version: r.version, + status: r.status, + created_at: r.created_at, + publisher: r.publisher, + })); + + logger.info('registry show', { kind, namespace, name, versions: versions.length, request_id: requestId }); + return successResponse(200, { kind, namespace, name, versions }, requestId); + } catch (err) { + logger.error('registry show failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/shared/gateway.ts b/cdk/src/handlers/shared/gateway.ts index efcfb4e3..0a51e26d 100644 --- a/cdk/src/handlers/shared/gateway.ts +++ b/cdk/src/handlers/shared/gateway.ts @@ -31,6 +31,30 @@ export function extractUserId(event: APIGatewayProxyEvent): string | null { return claims.sub; } +/** + * Extract the caller's Cognito group memberships from the authorizer claims. + * Cognito places groups in the ``cognito:groups`` claim, which API Gateway may + * surface either as a JSON-ish array or a comma/space-separated string + * depending on the integration — this normalizes both to a string array. + * + * Used by the registry (#246) publish/promote endpoints to gate on + * ``RegistryPublisher`` / ``RegistryApprover`` (REGISTRY.md §10). + * @param event - the API Gateway proxy event. + * @returns the caller's group names (empty array when none / unauthenticated). + */ +export function extractGroups(event: APIGatewayProxyEvent): string[] { + const raw = event.requestContext.authorizer?.claims?.['cognito:groups']; + if (!raw) return []; + if (Array.isArray(raw)) return raw.map(String); + // API Gateway commonly stringifies the list, e.g. "[Publisher Approver]" or + // "Publisher,Approver". Strip brackets, then split on comma or whitespace. + return String(raw) + .replace(/^\[|\]$/g, '') + .split(/[,\s]+/) + .map((g) => g.trim()) + .filter((g) => g.length > 0); +} + /** * Generate a branch name from task ID and description. * Pattern: `bgagent/{taskId}/{slug}` diff --git a/cdk/src/handlers/shared/registry-descriptor.ts b/cdk/src/handlers/shared/registry-descriptor.ts new file mode 100644 index 00000000..19b459e5 --- /dev/null +++ b/cdk/src/handlers/shared/registry-descriptor.ts @@ -0,0 +1,169 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Publish-time validation for registry assets (#246; REGISTRY.md §2/§3.3/§5/§6). + * Pure functions with no AWS dependency so they are unit-testable in isolation + * and reused by the publish handler. + */ + +import * as semver from 'semver'; +import type { RegistryAssetKind, RegistryDescriptor } from './types'; + +/** Kinds that have a loader in MVP and may therefore be published. */ +export const PUBLISHABLE_KINDS: ReadonlySet = new Set([ + 'mcp_server', + 'cedar_policy_module', + 'skill', +]); + +/** Kinds carried in the grammar but not yet loadable (publish is rejected). */ +export const RESERVED_KINDS: ReadonlySet = new Set([ + 'plugin', + 'subagent', + 'prompt_fragment', + 'capability', +]); + +const NAMESPACE_RE = /^[a-z][a-z0-9-]*$/; +const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/; + +/** Kinds whose artifact bytes are required at publish (REGISTRY.md §3.3). */ +export const KINDS_REQUIRING_ARTIFACT: ReadonlySet = new Set([ + 'mcp_server', + 'cedar_policy_module', + 'skill', +]); + +/** A single validation failure: a machine field key + human message. */ +export interface DescriptorViolation { + readonly field: string; + readonly message: string; +} + +export interface PublishInput { + readonly kind?: unknown; + readonly namespace?: unknown; + readonly name?: unknown; + readonly version?: unknown; + readonly descriptor?: unknown; + readonly artifact_b64?: unknown; +} + +/** + * Validate a publish request's identity fields, version, and per-kind + * descriptor. Returns all violations found (empty == valid) so the caller can + * surface them together rather than one at a time. + */ +export function validatePublish(input: PublishInput): DescriptorViolation[] { + const v: DescriptorViolation[] = []; + + // --- kind --- + const kind = input.kind; + if (typeof kind !== 'string' || (!PUBLISHABLE_KINDS.has(kind as RegistryAssetKind) && !RESERVED_KINDS.has(kind as RegistryAssetKind))) { + v.push({ field: 'kind', message: `unknown kind ${String(kind)}` }); + } else if (RESERVED_KINDS.has(kind as RegistryAssetKind)) { + v.push({ field: 'kind', message: `kind ${kind} is reserved and has no loader in MVP; cannot be published` }); + } + + // --- namespace / name --- + if (typeof input.namespace !== 'string' || !NAMESPACE_RE.test(input.namespace)) { + v.push({ field: 'namespace', message: 'namespace must match ^[a-z][a-z0-9-]*$' }); + } + if (typeof input.name !== 'string' || !NAME_RE.test(input.name)) { + v.push({ field: 'name', message: 'name must match ^[a-z0-9][a-z0-9._-]*$' }); + } + + // --- version: must be an EXACT semver (not a range) --- + if (typeof input.version !== 'string' || semver.valid(input.version) === null) { + v.push({ field: 'version', message: 'version must be an exact semver (e.g. 1.4.1)' }); + } + + // --- descriptor: shared + per-kind required fields --- + const d = input.descriptor; + if (typeof d !== 'object' || d === null) { + v.push({ field: 'descriptor', message: 'descriptor is required and must be an object' }); + } else { + const desc = d as Record; + if (typeof desc.summary !== 'string' || desc.summary.length === 0) { + v.push({ field: 'descriptor.summary', message: 'summary is required' }); + } + if (!Array.isArray(desc.permissions)) { + v.push({ field: 'descriptor.permissions', message: 'permissions must be an array' }); + } + if (typeof kind === 'string' && PUBLISHABLE_KINDS.has(kind as RegistryAssetKind)) { + v.push(...validateKindDescriptor(kind as RegistryAssetKind, desc)); + } + } + + // --- artifact required for loadable kinds --- + if (typeof kind === 'string' && KINDS_REQUIRING_ARTIFACT.has(kind as RegistryAssetKind)) { + if (typeof input.artifact_b64 !== 'string' || input.artifact_b64.length === 0) { + v.push({ field: 'artifact_b64', message: `artifact_b64 is required for kind ${kind}` }); + } + } + + return v; +} + +/** Per-kind descriptor required-field checks (REGISTRY.md §3.3). */ +function validateKindDescriptor( + kind: RegistryAssetKind, + desc: Record, +): DescriptorViolation[] { + const v: DescriptorViolation[] = []; + switch (kind) { + case 'mcp_server': + if (desc.transport !== 'http' && desc.transport !== 'stdio') { + v.push({ field: 'descriptor.transport', message: "mcp_server transport must be 'http' or 'stdio'" }); + } + if (typeof desc.tool_prefix !== 'string' || desc.tool_prefix.length === 0) { + v.push({ field: 'descriptor.tool_prefix', message: 'mcp_server descriptor requires tool_prefix' }); + } + break; + case 'cedar_policy_module': + if (!Array.isArray(desc.cedar_actions)) { + v.push({ field: 'descriptor.cedar_actions', message: 'cedar_policy_module descriptor requires cedar_actions array' }); + } + break; + case 'skill': + if (!Array.isArray(desc.tool_hints)) { + v.push({ field: 'descriptor.tool_hints', message: 'skill descriptor requires tool_hints array' }); + } + break; + default: + break; + } + return v; +} + +/** Build the ``{kind}#{namespace}/{name}`` partition key. */ +export function publishPk(kind: string, namespace: string, name: string): string { + return `${kind}#${namespace}/${name}`; +} + +/** Build the S3 artifact key ``{kind}/{namespace}/{name}/{version}/artifact``. */ +export function artifactKey(kind: string, namespace: string, name: string, version: string): string { + return `${kind}/${namespace}/${name}/${version}/artifact`; +} + +/** Narrow a validated descriptor to the typed shape. */ +export function asDescriptor(d: unknown): RegistryDescriptor { + return d as RegistryDescriptor; +} diff --git a/cdk/src/handlers/shared/registry-resolver.ts b/cdk/src/handlers/shared/registry-resolver.ts new file mode 100644 index 00000000..743482d9 --- /dev/null +++ b/cdk/src/handlers/shared/registry-resolver.ts @@ -0,0 +1,305 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Registry asset resolver (#246). Turns a ``registry://kind/namespace/name@constraint`` + * reference into a concrete pinned {@link ResolvedAsset} by querying the + * ``RegistryAssetsTable`` and applying semver + status rules. + * + * This is the TypeScript half of the two-language ``RegistryClient`` seam + * (ADR-018 sub-decision 8): {@link parseRef}'s grammar MUST agree byte-for-byte + * with the Python ``_REGISTRY_REF`` (agent/src/workflow/validator.py), and the + * ``contracts/registry-resolution/`` corpus is the agreement both reproduce. + * + * See docs/design/REGISTRY.md §5 (resolution semantics) and §6 (grammar). + */ + +import { + DynamoDBClient, +} from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + QueryCommand, +} from '@aws-sdk/lib-dynamodb'; +import * as semver from 'semver'; +import { logger } from './logger'; +import type { + RegistryAssetKind, + RegistryAssetRecord, + RegistryAssetStatus, + RegistryRef, + ResolvedAsset, + ResolvedAssetBundle, +} from './types'; + +/** + * Grammar for a ``registry://`` reference — the single source of truth on the + * TypeScript side. MUST stay byte-for-byte equivalent to the Python + * ``_REGISTRY_REF`` regex; the parity corpus guards against drift. + * + * registry:////@ + * kind snake_case: [a-z][a-z0-9_]* + * namespace [a-z][a-z0-9-]* + * name [a-z0-9][a-z0-9._-]* + * constraint MANDATORY: exact / caret / tilde semver only + */ +const REGISTRY_REF_RE = + /^registry:\/\/([a-z][a-z0-9_]*)\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9._-]*)@([\^~]?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; + +/** Kinds that are valid targets of a registry ref. Mirrors REGISTRY.md §2. */ +const KNOWN_KINDS: ReadonlySet = new Set([ + 'mcp_server', + 'cedar_policy_module', + 'skill', + 'plugin', + 'subagent', + 'prompt_fragment', + 'capability', +]); + +/** Specific, machine-readable reasons a resolution can fail (REGISTRY.md §5). */ +export type RegistryResolutionReason = + | 'INVALID_REGISTRY_REF' + | 'INVALID_CONSTRAINT' + | 'NO_MATCHING_VERSION' + | 'REMOVED'; + +/** + * Thrown when a ref cannot be resolved. Carries the specific {@link RegistryResolutionReason} + * and the offending ref so the create-task boundary can fail admission with + * ``REGISTRY_RESOLUTION_FAILED`` + reason (fail-closed; ADR-018 sub-decision 6). + */ +export class RegistryResolutionError extends Error { + readonly reason: RegistryResolutionReason; + readonly ref: string; + + constructor(reason: RegistryResolutionReason, ref: string, detail?: string) { + super(`registry resolution failed [${reason}] for ${ref}${detail ? `: ${detail}` : ''}`); + this.name = 'RegistryResolutionError'; + this.reason = reason; + this.ref = ref; + } +} + +/** + * Parse a ``registry://`` reference into its components. Grammar-only — does not + * touch the catalog. Throws {@link RegistryResolutionError} with + * ``INVALID_REGISTRY_REF`` when the ref does not match the grammar (which + * includes a missing/floating constraint — pins are mandatory). + */ +export function parseRef(ref: string): RegistryRef { + const m = REGISTRY_REF_RE.exec(ref); + if (!m) { + throw new RegistryResolutionError('INVALID_REGISTRY_REF', ref); + } + const [, kind, namespace, name, constraint] = m; + if (!KNOWN_KINDS.has(kind as RegistryAssetKind)) { + throw new RegistryResolutionError('INVALID_REGISTRY_REF', ref, `unknown kind ${kind}`); + } + return { kind: kind as RegistryAssetKind, namespace, name, constraint }; +} + +/** True iff ``ref`` matches the grammar. Never throws — the non-throwing peer of + * {@link parseRef}, mirroring Python ``is_registry_ref``. */ +export function isRegistryRef(ref: string): boolean { + try { + parseRef(ref); + return true; + } catch { + return false; + } +} + +/** Partition key for a ref: ``{kind}#{namespace}/{name}`` (REGISTRY.md §3.1). */ +export function assetPk(kind: RegistryAssetKind, namespace: string, name: string): string { + return `${kind}#${namespace}/${name}`; +} + +/** + * Translate an allowed constraint (exact / caret / tilde) into a + * ``semver``-compatible range string. Anything the grammar somehow let through + * that ``semver`` cannot parse is surfaced as ``INVALID_CONSTRAINT`` rather than + * silently matching nothing. + */ +function toSemverRange(constraint: string, ref: string): string { + // The grammar already restricts to exact / ^ / ~; semver accepts all three + // as-is. validRange guards against a future grammar loosening slipping a + // form through that would resolve to an unexpected range. + if (semver.validRange(constraint, { loose: false }) === null) { + throw new RegistryResolutionError('INVALID_CONSTRAINT', ref, constraint); + } + return constraint; +} + +/** Injectable DDB client so tests can supply a mock (default: real client). */ +export interface RegistryQueryClient { + send(command: QueryCommand): Promise<{ Items?: Record[] }>; +} + +let defaultClient: RegistryQueryClient | undefined; +function getDefaultClient(): RegistryQueryClient { + if (!defaultClient) { + defaultClient = DynamoDBDocumentClient.from(new DynamoDBClient({})) as unknown as RegistryQueryClient; + } + return defaultClient; +} + +/** + * Resolve a single ``registry://`` ref against the catalog. + * + * 1. Parse the ref (grammar; may throw ``INVALID_REGISTRY_REF``). + * 2. Query every version under the asset's partition. + * 3. Rank candidates by parsed semver (descending); pick the highest whose + * version satisfies the constraint AND whose status is resolvable. + * 4. Apply status rules (REGISTRY.md §5): ``approved`` resolves silently, + * ``deprecated`` resolves with a ``DEPRECATED`` warning, others are not + * candidates; if the single highest match is ``removed`` → ``REMOVED``. + * + * @throws {@link RegistryResolutionError} on any unresolved ref (fail-closed). + */ +export async function resolveRef( + ref: string, + opts?: { client?: RegistryQueryClient; tableName?: string }, +): Promise { + const parsed = parseRef(ref); + const range = toSemverRange(parsed.constraint, ref); + const client = opts?.client ?? getDefaultClient(); + const tableName = opts?.tableName ?? process.env.REGISTRY_ASSETS_TABLE_NAME; + if (!tableName) { + throw new RegistryResolutionError( + 'NO_MATCHING_VERSION', + ref, + 'REGISTRY_ASSETS_TABLE_NAME not configured', + ); + } + + const pk = assetPk(parsed.kind, parsed.namespace, parsed.name); + const result = await client.send( + new QueryCommand({ + TableName: tableName, + KeyConditionExpression: 'pk = :pk', + ExpressionAttributeValues: { ':pk': pk }, + }), + ); + const rows = (result.Items ?? []) as unknown as RegistryAssetRecord[]; + + // Candidates whose version satisfies the constraint AND is a valid semver. + // DynamoDB returns rows sorted lexicographically by the version sort key, + // which is WRONG for semver (1.10.0 < 1.9.0 as strings) — so we always rank + // in code (REGISTRY.md §3.2). + const matching = rows + .filter((r) => semver.valid(r.version) !== null && semver.satisfies(r.version, range, { includePrerelease: false })) + .sort((a, b) => semver.rcompare(a.version, b.version)); + + if (matching.length === 0) { + throw new RegistryResolutionError('NO_MATCHING_VERSION', ref); + } + + // Highest matching version — but its status decides whether it resolves. + const top = matching[0]; + const warnings = statusWarnings(top.status, ref, top.version); + + logger.info('registry ref resolved', { + ref, + resolved_version: top.version, + status: top.status, + warnings, + }); + + return { + kind: top.kind, + namespace: top.namespace, + name: top.name, + version: top.version, + descriptor: top.descriptor, + // artifact_url is attached by the resolve handler (presign); the library + // returns descriptor-level data only. + warnings, + }; +} + +/** + * Apply the status rules to the winning candidate. Returns the warnings list + * for resolvable statuses; throws for non-resolvable ones. ``removed`` is a + * distinct reason so operators can tell a tombstoned pin from one that never + * existed (REGISTRY.md §5). + */ +function statusWarnings(status: RegistryAssetStatus, ref: string, version: string): string[] { + switch (status) { + case 'approved': + return []; + case 'deprecated': + return ['DEPRECATED']; + case 'removed': + throw new RegistryResolutionError('REMOVED', ref, `version ${version} is removed`); + case 'submitted': + case 'draft': + case 'rejected': + // The highest semver match is not in a resolvable state and no lower + // approved version was selected — treat as no usable match. + throw new RegistryResolutionError('NO_MATCHING_VERSION', ref, `highest match ${version} is ${status}`); + } +} + +/** + * Resolve many refs in parallel into a {@link ResolvedAssetBundle} grouped by + * kind. Any single unresolved ref rejects the whole call (fail-closed) — a task + * with a bad pin must not partially resolve. + */ +export async function resolveAll( + refs: readonly string[], + opts?: { client?: RegistryQueryClient; tableName?: string }, +): Promise { + // A task declares a handful of asset refs (bounded by blueprint authoring), + // so unbounded parallelism here is acceptable — this is not driven by + // unbounded external input. + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + const resolved = await Promise.all(refs.map((ref) => resolveRef(ref, opts))); + + const bundle: { + mcp_servers: ResolvedAsset[]; + cedar_policy_modules: ResolvedAsset[]; + skills: ResolvedAsset[]; + } = { mcp_servers: [], cedar_policy_modules: [], skills: [] }; + + for (const asset of resolved) { + switch (asset.kind) { + case 'mcp_server': + bundle.mcp_servers.push(asset); + break; + case 'cedar_policy_module': + bundle.cedar_policy_modules.push(asset); + break; + case 'skill': + bundle.skills.push(asset); + break; + default: + // Reserved kinds are declared in the grammar but have no loader yet + // (REGISTRY.md §2); refusing here keeps the fail-closed guarantee + // rather than silently dropping a resolved-but-unloadable asset. + throw new RegistryResolutionError( + 'INVALID_REGISTRY_REF', + `registry://${asset.kind}/${asset.namespace}/${asset.name}`, + `kind ${asset.kind} has no loader in MVP`, + ); + } + } + + return bundle; +} diff --git a/cdk/src/handlers/shared/response.ts b/cdk/src/handlers/shared/response.ts index f4d37087..c1c04538 100644 --- a/cdk/src/handlers/shared/response.ts +++ b/cdk/src/handlers/shared/response.ts @@ -54,6 +54,10 @@ export const ErrorCode = { REQUEST_NOT_FOUND: 'REQUEST_NOT_FOUND', REQUEST_ALREADY_DECIDED: 'REQUEST_ALREADY_DECIDED', TASK_NOT_AWAITING_APPROVAL: 'TASK_NOT_AWAITING_APPROVAL', + // Agent asset registry (#246; REGISTRY.md §4/§5). + REGISTRY_VERSION_EXISTS: 'REGISTRY_VERSION_EXISTS', + REGISTRY_ASSET_NOT_FOUND: 'REGISTRY_ASSET_NOT_FOUND', + REGISTRY_RESOLUTION_FAILED: 'REGISTRY_RESOLUTION_FAILED', } as const; const COMMON_HEADERS = { diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index dc4d9c84..524454df 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -47,6 +47,158 @@ export type ResolvedWorkflow = { readonly version: string; }; +// --- Agent asset registry (#246) -------------------------------------------- +// See docs/design/REGISTRY.md and ADR-018. The registry is a versioned, +// immutable-per-version catalog of runtime artifacts referenced from Blueprints +// via ``registry://kind/namespace/name@constraint`` and resolved at the +// create-task boundary. Types below are the shared contract between the CDK +// handlers/resolver and (for the wire-facing ones) the CLI. + +/** + * Registry asset kinds. MVP loads ``mcp_server`` end-to-end; ``cedar_policy_module`` + * and ``skill`` are resolved and staged. The remaining kinds are declared so the + * grammar is stable but are rejected at publish until a loader ships + * (ADR-018 sub-decision 1; REGISTRY.md §2). + * + * Keep in sync with ``cli/src/types.ts::RegistryAssetKind``. + */ +export type RegistryAssetKind = + | 'mcp_server' + | 'cedar_policy_module' + | 'skill' + | 'plugin' + | 'subagent' + | 'prompt_fragment' + | 'capability'; + +/** + * Lifecycle status of a registry asset version (ADR-018 sub-decision 4/10; + * REGISTRY.md §5). ``approved`` is the single canonical resolvable state — the + * ADR prose word "active" is NOT a value in code (one token, byte-for-byte, + * across the TS resolver and the Python loader). + * + * Resolution: ``approved`` resolves silently; ``deprecated`` resolves with a + * warning; ``submitted``/``draft``/``rejected`` are not candidates; ``removed`` + * fails with a distinct ``REMOVED`` reason. + * + * Keep in sync with ``cli/src/types.ts::RegistryAssetStatus``. + */ +export type RegistryAssetStatus = + | 'draft' + | 'submitted' + | 'approved' + | 'rejected' + | 'deprecated' + | 'removed'; + +/** + * A parsed ``registry://kind/namespace/name@constraint`` reference. The + * ``constraint`` is mandatory (fail-closed; ADR-018 sub-decision 6) and is one + * of exact (``1.4.1``), caret (``^1.4.1``), or tilde (``~1.4.1``). Produced by + * ``registry-resolver.ts::parseRef``; its grammar must agree byte-for-byte with + * the Python ``_REGISTRY_REF`` (contracts/registry-resolution/). + * + * Keep in sync with ``cli/src/types.ts::RegistryRef``. + */ +export type RegistryRef = { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly constraint: string; +}; + +/** + * One audit entry appended to a record on every lifecycle transition + * (REGISTRY.md §3.1/§10). Append-only; rich actor+timestamp+rationale is a MUST. + */ +export interface RegistryStatusEvent { + readonly status: RegistryAssetStatus; + /** Cognito ``sub`` of the actor who caused the transition. */ + readonly actor: string; + readonly at: string; + readonly rationale?: string; +} + +/** + * Typed per-kind descriptor validated at publish (REGISTRY.md §3.3). Shared + * required fields plus kind-specific keys carried as an open map (each handler + * validates its kind's required keys). ``artifact`` marks fields whose bytes + * live in S3 rather than inline. + */ +export interface RegistryDescriptor { + readonly summary: string; + readonly permissions: readonly string[]; + readonly [key: string]: unknown; +} + +/** + * Full registry asset record as stored in ``RegistryAssetsTable`` (REGISTRY.md + * §3.1). One row per published ``(kind, namespace, name, version)``. Immutable + * once written except for ``status`` and ``status_history``. + */ +export interface RegistryAssetRecord { + /** Partition key: ``{kind}#{namespace}/{name}``. */ + readonly pk: string; + /** Sort key: the semver ``version`` string. */ + readonly sk: string; + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly descriptor: RegistryDescriptor; + /** S3 key of the artifact bytes; empty for descriptor-only kinds. */ + readonly artifact_ref?: string; + readonly status: RegistryAssetStatus; + /** Cognito ``sub`` of the publishing principal. */ + readonly publisher: string; + readonly created_at: string; + readonly status_history: readonly RegistryStatusEvent[]; +} + +/** + * The result of resolving a single ``registry://`` ref against the catalog + * (REGISTRY.md §4.2). Carries the descriptor, the pinned version chosen, and + * any resolution warnings (e.g. ``DEPRECATED``). The artifact bytes are fetched + * separately (presigned URL) so this stays small on the task payload. + * + * Keep in sync with ``cli/src/types.ts::ResolvedAsset``. + */ +export interface ResolvedAsset { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly descriptor: RegistryDescriptor; + readonly artifact_url?: string; + readonly warnings: readonly string[]; +} + +/** + * The bundle attached to the agent payload after resolving all of a task's + * registry refs (REGISTRY.md §7/§8). Grouped by kind so the agent-side loader + * applies each with the right mechanism. + * + * Keep in sync with ``cli/src/types.ts::ResolvedAssetBundle``. + */ +export interface ResolvedAssetBundle { + readonly mcp_servers: readonly ResolvedAsset[]; + readonly cedar_policy_modules: readonly ResolvedAsset[]; + readonly skills: readonly ResolvedAsset[]; +} + +/** + * Compact audit triple stamped on the TaskRecord — ``{kind, id, version}`` — + * answering "what asset versions did this task run" from a single field + * (ADR-018 sub-decision 5; REGISTRY.md §7). ``id`` is ``namespace/name``. + * + * Keep in sync with ``cli/src/types.ts::ResolvedAssetSummary``. + */ +export interface ResolvedAssetSummary { + readonly kind: RegistryAssetKind; + readonly id: string; + readonly version: string; +} + /** Shared across all attachment interfaces. Add new types here (e.g., 'audio'). */ export type AttachmentType = 'image' | 'file' | 'url'; diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 1382dce4..2e1c0755 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -47,6 +47,8 @@ import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-int import { JiraIntegration } from '../constructs/jira-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; +import { RegistryArtifactsBucket } from '../constructs/registry-artifacts-bucket'; +import { RegistryAssetsTable } from '../constructs/registry-assets-table'; import { RepoTable } from '../constructs/repo-table'; import { SlackIntegration } from '../constructs/slack-integration'; import { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler'; @@ -108,6 +110,20 @@ export class AgentStack extends Stack { const webhookTable = new WebhookTable(this, 'WebhookTable'); const repoTable = new RepoTable(this, 'RepoTable'); + // Agent asset registry (#246). Storage + API only in PR 1 — nothing in the + // orchestrator/agent reads from these yet (purely additive). The + // orchestrator resolve step and agent-side loaders follow in PR 2. The + // TaskApi wires the /registry routes when both handles are supplied. + const registryAssetsTable = new RegistryAssetsTable(this, 'RegistryAssetsTable'); + const registryArtifactsBucket = new RegistryArtifactsBucket(this, 'RegistryArtifactsBucket'); + + NagSuppressions.addResourceSuppressions(registryArtifactsBucket.bucket, [ + { + id: 'AwsSolutions-S1', + reason: 'Registry artifact bytes (REGISTRY.md §3.4): writes confined to the publish Lambda (RegistryPublisher/Approver-gated) via grantPut; reads only via short-lived presigned URLs from the authn\'d resolve handler. Versioned for audit; immutability enforced at the DynamoDB write. A separate access-log bucket is not justified for this admin-published catalog.', + }, + ]); + // Cedar-wasm Lambda layer (§15.2 task 10). Instantiated here so the // asset is in the synthed template; Chunk 5 handlers (Approve, // Deny, GetPolicies, CreateTask) attach the layer via @@ -299,6 +315,8 @@ export class AgentStack extends Stack { traceArtifactsBucket: traceArtifactsBucket.bucket, attachmentsBucket: attachmentsBucket.bucket, userConcurrencyTable: userConcurrencyTable.table, + registryAssetsTable: registryAssetsTable.table, + registryArtifactsBucket: registryArtifactsBucket.bucket, }); // --- AgentCore Runtime (IAM-authed orchestrator path) --- diff --git a/cdk/test/constructs/registry-artifacts-bucket.test.ts b/cdk/test/constructs/registry-artifacts-bucket.test.ts new file mode 100644 index 00000000..8a6fce31 --- /dev/null +++ b/cdk/test/constructs/registry-artifacts-bucket.test.ts @@ -0,0 +1,150 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { + REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS, + RegistryArtifactsBucket, +} from '../../src/constructs/registry-artifacts-bucket'; + +describe('RegistryArtifactsBucket', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryArtifactsBucket(stack, 'RegistryArtifactsBucket'); + template = Template.fromStack(stack); + }); + + test('blocks all public access', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, + }); + }); + + test('enables S3-managed server-side encryption', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { ServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }, + ], + }, + }); + }); + + test('enables versioning (artifacts are durable, referenced by pinned tasks)', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + VersioningConfiguration: { Status: 'Enabled' }, + }); + }); + + test('enforces TLS-only access via bucket policy', () => { + template.hasResourceProperties('AWS::S3::BucketPolicy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Effect: 'Deny', + Action: 's3:*', + Condition: { Bool: { 'aws:SecureTransport': 'false' } }, + }), + ]), + }, + }); + }); + + test('expires noncurrent versions rather than current objects', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: Match.arrayWith([ + Match.objectLike({ + Id: 'registry-artifact-noncurrent-expiry', + Status: 'Enabled', + NoncurrentVersionExpiration: { + NoncurrentDays: REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS, + }, + }), + ]), + }, + }); + }); + + test('does NOT set a whole-object expiration (artifacts are durable)', () => { + const buckets = template.findResources('AWS::S3::Bucket'); + const [bucket] = Object.values(buckets); + const rules = bucket.Properties.LifecycleConfiguration.Rules as Array>; + for (const rule of rules) { + expect(rule.ExpirationInDays).toBeUndefined(); + } + }); + + test('aborts incomplete multipart uploads within a day', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: Match.arrayWith([ + Match.objectLike({ + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 }, + }), + ]), + }, + }); + }); + + test('sets DESTROY removal policy + autoDelete by default', () => { + template.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + template.hasResourceProperties('Custom::S3AutoDeleteObjects', { + BucketName: Match.anyValue(), + }); + }); + + test('exposes a bucket handle via the `bucket` property', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const b = new RegistryArtifactsBucket(stack, 'RegistryArtifactsBucket'); + expect(b.bucket).toBeDefined(); + expect(b.bucket.bucketName).toBeDefined(); + }); +}); + +describe('RegistryArtifactsBucket with custom props', () => { + test('accepts a RETAIN removal policy and disables autoDelete', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryArtifactsBucket(stack, 'RegistryArtifactsBucket', { + removalPolicy: RemovalPolicy.RETAIN, + autoDeleteObjects: false, + }); + const template = Template.fromStack(stack); + + template.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + expect(Object.keys(template.findResources('Custom::S3AutoDeleteObjects'))).toHaveLength(0); + }); +}); diff --git a/cdk/test/constructs/registry-assets-table.test.ts b/cdk/test/constructs/registry-assets-table.test.ts new file mode 100644 index 00000000..46df56e3 --- /dev/null +++ b/cdk/test/constructs/registry-assets-table.test.ts @@ -0,0 +1,123 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { + REGISTRY_KIND_INDEX, + RegistryAssetsTable, +} from '../../src/constructs/registry-assets-table'; + +describe('RegistryAssetsTable', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryAssetsTable(stack, 'RegistryAssetsTable'); + template = Template.fromStack(stack); + }); + + test('creates a table with a composite pk/sk key schema', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'pk', KeyType: 'HASH' }, + { AttributeName: 'sk', KeyType: 'RANGE' }, + ], + }); + }); + + test('uses on-demand billing', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('defines the kind-index GSI (kind HASH, pk RANGE) with ALL projection', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: REGISTRY_KIND_INDEX, + KeySchema: [ + { AttributeName: 'kind', KeyType: 'HASH' }, + { AttributeName: 'pk', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + }), + ]), + }); + }); + + test('declares the attribute definitions the base key and GSI need', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + AttributeDefinitions: Match.arrayWith([ + { AttributeName: 'pk', AttributeType: 'S' }, + { AttributeName: 'sk', AttributeType: 'S' }, + { AttributeName: 'kind', AttributeType: 'S' }, + ]), + }); + }); + + test('enables point-in-time recovery by default', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true }, + }); + }); + + test('does NOT configure a TTL (records are immutable + audited, not expired)', () => { + const tables = template.findResources('AWS::DynamoDB::Table'); + const [table] = Object.values(tables); + expect(table.Properties.TimeToLiveSpecification).toBeUndefined(); + }); + + test('sets DESTROY removal policy by default', () => { + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + }); + + test('exposes a table handle via the `table` property', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const t = new RegistryAssetsTable(stack, 'RegistryAssetsTable'); + expect(t.table).toBeDefined(); + expect(t.table.tableName).toBeDefined(); + }); +}); + +describe('RegistryAssetsTable with custom props', () => { + test('accepts a RETAIN removal policy and disables PITR', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryAssetsTable(stack, 'RegistryAssetsTable', { + removalPolicy: RemovalPolicy.RETAIN, + pointInTimeRecovery: false, + }); + const template = Template.fromStack(stack); + + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: false }, + }); + }); +}); diff --git a/cdk/test/constructs/task-api.test.ts b/cdk/test/constructs/task-api.test.ts index 7c20de3e..cbf80f28 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -419,6 +419,73 @@ describe('TaskApi construct with webhooks', () => { }); }); +describe('TaskApi construct with registry (#246)', () => { + function createStackWithRegistry(): Template { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + const registryAssetsTable = new dynamodb.Table(stack, 'RegistryAssetsTable', { + partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING }, + }); + const registryArtifactsBucket = new s3.Bucket(stack, 'RegistryArtifactsBucket'); + + new TaskApi(stack, 'TaskApi', { + taskTable, + taskEventsTable, + registryAssetsTable, + registryArtifactsBucket, + }); + return Template.fromStack(stack); + } + + let template: Template; + beforeAll(() => { + template = createStackWithRegistry(); + }); + + test('creates the /registry resource tree', () => { + for (const pathPart of ['registry', 'assets', 'resolve', '{kind}', '{namespace}', '{name}']) { + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: pathPart }); + } + }); + + test('adds 4 registry methods (POST+GET /assets, GET show, GET resolve)', () => { + const methods = template.findResources('AWS::ApiGateway::Method'); + const nonOptions = Object.values(methods).filter( + (r) => (r as { Properties: { HttpMethod: string } }).Properties.HttpMethod !== 'OPTIONS', + ); + // 6 base (incl. GET replay) + 4 registry + expect(nonOptions.length).toBe(10); + }); + + test('registry Lambdas receive the table + bucket env vars', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + REGISTRY_ASSETS_TABLE_NAME: Match.anyValue(), + REGISTRY_ARTIFACTS_BUCKET_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('is not created when the registry props are absent', () => { + const bare = createStack().template; + const resources = bare.findResources('AWS::ApiGateway::Resource'); + const hasRegistry = Object.values(resources).some( + (r) => (r as { Properties?: { PathPart?: string } }).Properties?.PathPart === 'registry', + ); + expect(hasRegistry).toBe(false); + }); +}); + describe('TaskApi construct — nudge endpoint (Phase 2)', () => { let nudgeTemplate: Template; diff --git a/cdk/test/handlers/registry-list-show.test.ts b/cdk/test/handlers/registry-list-show.test.ts new file mode 100644 index 00000000..9159b582 --- /dev/null +++ b/cdk/test/handlers/registry-list-show.test.ts @@ -0,0 +1,132 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + QueryCommand: jest.fn((input: unknown) => ({ _type: 'Query', input })), +})); +jest.mock('../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +process.env.REGISTRY_ASSETS_TABLE_NAME = 'RegistryAssets'; + +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import { handler as listHandler } from '../../src/handlers/registry-list'; +import { handler as showHandler } from '../../src/handlers/registry-show'; + +function row(namespace: string, name: string, version: string, status = 'approved') { + return { + pk: `mcp_server#${namespace}/${name}`, + sk: version, + kind: 'mcp_server', + namespace, + name, + version, + status, + created_at: '2026-07-20T00:00:00Z', + publisher: 'sub-1', + descriptor: { summary: 's', permissions: [] }, + status_history: [], + }; +} + +beforeEach(() => jest.clearAllMocks()); + +describe('registry list handler', () => { + function listEvent(qs: Record | null, authed = true): APIGatewayProxyEvent { + return { + queryStringParameters: qs, + requestContext: { authorizer: { claims: authed ? { sub: 'u' } : {} } }, + } as unknown as APIGatewayProxyEvent; + } + + test('401 unauthenticated', async () => { + expect((await listHandler(listEvent({ kind: 'mcp_server' }, false))).statusCode).toBe(401); + }); + + test('400 when kind missing or unknown', async () => { + expect((await listHandler(listEvent(null))).statusCode).toBe(400); + expect((await listHandler(listEvent({ kind: 'nope' }))).statusCode).toBe(400); + }); + + test('collapses versions to the highest per asset', async () => { + mockDdbSend.mockResolvedValue({ + Items: [row('acme', 'pdf', '1.0.0'), row('acme', 'pdf', '1.10.0'), row('acme', 'other', '2.0.0')], + }); + const res = await listHandler(listEvent({ kind: 'mcp_server' })); + expect(res.statusCode).toBe(200); + const assets = JSON.parse(res.body).data.assets; + expect(assets).toHaveLength(2); + const pdf = assets.find((a: { name: string }) => a.name === 'pdf'); + expect(pdf.latest_version).toBe('1.10.0'); // semver, not lexicographic + }); + + test('excludes removed unless status=removed requested', async () => { + mockDdbSend.mockResolvedValue({ Items: [row('acme', 'gone', '1.0.0', 'removed')] }); + expect(JSON.parse((await listHandler(listEvent({ kind: 'mcp_server' }))).body).data.assets).toHaveLength(0); + expect( + JSON.parse((await listHandler(listEvent({ kind: 'mcp_server', status: 'removed' }))).body).data.assets, + ).toHaveLength(1); + }); + + test('honors the namespace filter', async () => { + mockDdbSend.mockResolvedValue({ Items: [row('acme', 'pdf', '1.0.0'), row('other', 'pdf', '1.0.0')] }); + const res = await listHandler(listEvent({ kind: 'mcp_server', namespace: 'acme' })); + expect(JSON.parse(res.body).data.assets).toHaveLength(1); + }); +}); + +describe('registry show handler', () => { + function showEvent(params: Record | null, authed = true): APIGatewayProxyEvent { + return { + pathParameters: params, + requestContext: { authorizer: { claims: authed ? { sub: 'u' } : {} } }, + } as unknown as APIGatewayProxyEvent; + } + + test('401 unauthenticated', async () => { + expect( + (await showHandler(showEvent({ kind: 'mcp_server', namespace: 'acme', name: 'pdf' }, false))).statusCode, + ).toBe(401); + }); + + test('400 on missing path params', async () => { + expect((await showHandler(showEvent(null))).statusCode).toBe(400); + }); + + test('404 when the asset has no versions', async () => { + mockDdbSend.mockResolvedValue({ Items: [] }); + const res = await showHandler(showEvent({ kind: 'mcp_server', namespace: 'acme', name: 'pdf' })); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).error.code).toBe('REGISTRY_ASSET_NOT_FOUND'); + }); + + test('returns versions newest-semver-first', async () => { + mockDdbSend.mockResolvedValue({ + Items: [row('acme', 'pdf', '1.2.0'), row('acme', 'pdf', '1.10.0'), row('acme', 'pdf', '1.9.0')], + }); + const res = await showHandler(showEvent({ kind: 'mcp_server', namespace: 'acme', name: 'pdf' })); + expect(res.statusCode).toBe(200); + const versions = JSON.parse(res.body).data.versions.map((v: { version: string }) => v.version); + expect(versions).toEqual(['1.10.0', '1.9.0', '1.2.0']); + }); +}); diff --git a/cdk/test/handlers/registry-publish.test.ts b/cdk/test/handlers/registry-publish.test.ts new file mode 100644 index 00000000..5156c74b --- /dev/null +++ b/cdk/test/handlers/registry-publish.test.ts @@ -0,0 +1,133 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const mockDdbSend = jest.fn(); +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), +})); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: mockS3Send })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'PutObject', input })), +})); +jest.mock('../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +process.env.REGISTRY_ASSETS_TABLE_NAME = 'RegistryAssets'; +process.env.REGISTRY_ARTIFACTS_BUCKET_NAME = 'registry-artifacts'; + +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import { handler } from '../../src/handlers/registry-publish'; + +function event(opts: { + groups?: string[]; + body?: unknown; + authed?: boolean; + autoApprove?: boolean; +}): APIGatewayProxyEvent { + const claims: Record = {}; + if (opts.authed !== false) claims.sub = 'user-123'; + if (opts.groups) claims['cognito:groups'] = opts.groups; + return { + body: opts.body === undefined ? null : JSON.stringify(opts.body), + queryStringParameters: opts.autoApprove ? { auto_approve: 'true' } : null, + requestContext: { authorizer: { claims } }, + } as unknown as APIGatewayProxyEvent; +} + +const validBody = { + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + descriptor: { summary: 'PDF', permissions: [], transport: 'http', tool_prefix: 'mcp__pdf__' }, + artifact_b64: Buffer.from('{}').toString('base64'), +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockS3Send.mockResolvedValue({}); + mockDdbSend.mockResolvedValue({}); +}); + +describe('registry publish handler', () => { + test('401 when unauthenticated', async () => { + const res = await handler(event({ authed: false, body: validBody })); + expect(res.statusCode).toBe(401); + }); + + test('403 when caller is in no registry group', async () => { + const res = await handler(event({ groups: ['SomeOtherGroup'], body: validBody })); + expect(res.statusCode).toBe(403); + }); + + test('publishes as submitted for a RegistryPublisher', async () => { + const res = await handler(event({ groups: ['RegistryPublisher'], body: validBody })); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).data.status).toBe('submitted'); + // artifact uploaded, then record written + expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(mockDdbSend).toHaveBeenCalledTimes(1); + }); + + test('auto_approve lands approved only for an approver', async () => { + const approver = await handler(event({ groups: ['RegistryApprover'], body: validBody, autoApprove: true })); + expect(JSON.parse(approver.body).data.status).toBe('approved'); + + // a plain publisher asking for auto_approve still lands submitted + const publisher = await handler(event({ groups: ['RegistryPublisher'], body: validBody, autoApprove: true })); + expect(JSON.parse(publisher.body).data.status).toBe('submitted'); + }); + + test('400 on descriptor validation failure', async () => { + const res = await handler( + event({ groups: ['RegistryPublisher'], body: { ...validBody, version: '^1.4.1' } }), + ); + expect(res.statusCode).toBe(400); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + + test('409 on version immutability collision', async () => { + mockDdbSend.mockRejectedValueOnce( + Object.assign(new Error('exists'), { name: 'ConditionalCheckFailedException' }), + ); + const res = await handler(event({ groups: ['RegistryPublisher'], body: validBody })); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).error.code).toBe('REGISTRY_VERSION_EXISTS'); + }); + + test('write is guarded by attribute_not_exists on pk and sk', async () => { + await handler(event({ groups: ['RegistryPublisher'], body: validBody })); + const putInput = mockDdbSend.mock.calls[0][0].input; + expect(putInput.ConditionExpression).toContain('attribute_not_exists(pk)'); + expect(putInput.ConditionExpression).toContain('attribute_not_exists(sk)'); + }); + + test('400 on non-JSON body', async () => { + const res = await handler({ + body: 'not json', + queryStringParameters: null, + requestContext: { authorizer: { claims: { 'sub': 'u', 'cognito:groups': ['RegistryPublisher'] } } }, + } as unknown as APIGatewayProxyEvent); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/cdk/test/handlers/registry-resolve.test.ts b/cdk/test/handlers/registry-resolve.test.ts new file mode 100644 index 00000000..cd32cfb6 --- /dev/null +++ b/cdk/test/handlers/registry-resolve.test.ts @@ -0,0 +1,111 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const mockResolveRef = jest.fn(); +const mockGetSignedUrl = jest.fn(); + +jest.mock('../../src/handlers/shared/registry-resolver', () => { + const actual = jest.requireActual('../../src/handlers/shared/registry-resolver'); + return { + ...actual, + resolveRef: mockResolveRef, + }; +}); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({})), + GetObjectCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), +})); +jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: mockGetSignedUrl })); +jest.mock('../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +process.env.REGISTRY_ARTIFACTS_BUCKET_NAME = 'registry-artifacts'; + +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import { handler } from '../../src/handlers/registry-resolve'; +import { RegistryResolutionError } from '../../src/handlers/shared/registry-resolver'; + +function event(ref: string | null, authed = true): APIGatewayProxyEvent { + return { + queryStringParameters: ref === null ? null : { ref }, + requestContext: { authorizer: { claims: authed ? { sub: 'u' } : {} } }, + } as unknown as APIGatewayProxyEvent; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetSignedUrl.mockResolvedValue('https://signed.example/artifact'); +}); + +describe('registry resolve handler', () => { + test('401 when unauthenticated', async () => { + const res = await handler(event('registry://mcp_server/acme/x@^1.0.0', false)); + expect(res.statusCode).toBe(401); + }); + + test('400 when ref query param is missing', async () => { + const res = await handler(event(null)); + expect(res.statusCode).toBe(400); + }); + + test('200 with a presigned artifact URL for an mcp_server', async () => { + mockResolveRef.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + descriptor: { summary: 's', permissions: [] }, + warnings: [], + }); + const res = await handler(event('registry://mcp_server/acme/pdf-tools@^1.0.0')); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body).data; + expect(data.version).toBe('1.4.1'); + expect(data.artifact_url).toBe('https://signed.example/artifact'); + expect(mockGetSignedUrl).toHaveBeenCalledTimes(1); + }); + + test('422 with the specific reason on resolution failure', async () => { + mockResolveRef.mockRejectedValue( + new RegistryResolutionError('NO_MATCHING_VERSION', 'registry://mcp_server/acme/x@^9.0.0'), + ); + const res = await handler(event('registry://mcp_server/acme/x@^9.0.0')); + expect(res.statusCode).toBe(422); + const err = JSON.parse(res.body).error; + expect(err.code).toBe('REGISTRY_RESOLUTION_FAILED'); + expect(err.message).toContain('NO_MATCHING_VERSION'); + }); + + test('does not presign for a kind without an artifact loader path', async () => { + // capability is not in KINDS_REQUIRING_ARTIFACT + mockResolveRef.mockResolvedValue({ + kind: 'capability', + namespace: 'acme', + name: 'wf', + version: '1.0.0', + descriptor: { summary: 's', permissions: [] }, + warnings: [], + }); + const res = await handler(event('registry://capability/acme/wf@^1.0.0')); + expect(res.statusCode).toBe(200); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + expect(JSON.parse(res.body).data.artifact_url).toBeUndefined(); + }); +}); diff --git a/cdk/test/handlers/shared/registry-descriptor.test.ts b/cdk/test/handlers/shared/registry-descriptor.test.ts new file mode 100644 index 00000000..e76efc64 --- /dev/null +++ b/cdk/test/handlers/shared/registry-descriptor.test.ts @@ -0,0 +1,117 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + artifactKey, + publishPk, + validatePublish, +} from '../../../src/handlers/shared/registry-descriptor'; + +const validMcp = { + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + descriptor: { summary: 'PDF tools', permissions: ['network:egress'], transport: 'http', tool_prefix: 'mcp__pdf__' }, + artifact_b64: Buffer.from('{}').toString('base64'), +}; + +describe('validatePublish', () => { + test('accepts a well-formed mcp_server publish', () => { + expect(validatePublish(validMcp)).toEqual([]); + }); + + test('accepts a cedar_policy_module with cedar_actions', () => { + expect( + validatePublish({ + kind: 'cedar_policy_module', + namespace: 'acme', + name: 'guard', + version: '1.0.0', + descriptor: { summary: 's', permissions: [], cedar_actions: ['Action::"ForcePush"'] }, + artifact_b64: Buffer.from('permit(...)').toString('base64'), + }), + ).toEqual([]); + }); + + test('accepts a skill with tool_hints', () => { + expect( + validatePublish({ + kind: 'skill', + namespace: 'acme', + name: 'refactor', + version: '2.0.0', + descriptor: { summary: 's', permissions: [], tool_hints: ['Edit'] }, + artifact_b64: Buffer.from('# skill').toString('base64'), + }), + ).toEqual([]); + }); + + test('rejects a reserved kind (no loader in MVP)', () => { + const v = validatePublish({ ...validMcp, kind: 'plugin' }); + expect(v.some((x) => x.field === 'kind')).toBe(true); + }); + + test('rejects an unknown kind', () => { + const v = validatePublish({ ...validMcp, kind: 'nonsense' }); + expect(v.some((x) => x.field === 'kind')).toBe(true); + }); + + test('rejects a non-exact version (range is not a publish version)', () => { + expect(validatePublish({ ...validMcp, version: '^1.4.1' }).some((x) => x.field === 'version')).toBe(true); + expect(validatePublish({ ...validMcp, version: 'latest' }).some((x) => x.field === 'version')).toBe(true); + }); + + test('rejects a bad namespace / name', () => { + expect(validatePublish({ ...validMcp, namespace: 'Acme' }).some((x) => x.field === 'namespace')).toBe(true); + expect(validatePublish({ ...validMcp, name: '-bad' }).some((x) => x.field === 'name')).toBe(true); + }); + + test('requires descriptor summary and permissions', () => { + const v = validatePublish({ ...validMcp, descriptor: { transport: 'http', tool_prefix: 'x' } }); + expect(v.some((x) => x.field === 'descriptor.summary')).toBe(true); + expect(v.some((x) => x.field === 'descriptor.permissions')).toBe(true); + }); + + test('mcp_server requires transport and tool_prefix', () => { + const v = validatePublish({ + ...validMcp, + descriptor: { summary: 's', permissions: [] }, + }); + expect(v.some((x) => x.field === 'descriptor.transport')).toBe(true); + expect(v.some((x) => x.field === 'descriptor.tool_prefix')).toBe(true); + }); + + test('requires an artifact for loadable kinds', () => { + const { artifact_b64: _omit, ...noArtifact } = validMcp; + expect(validatePublish(noArtifact).some((x) => x.field === 'artifact_b64')).toBe(true); + }); +}); + +describe('key helpers', () => { + test('publishPk builds {kind}#{namespace}/{name}', () => { + expect(publishPk('mcp_server', 'acme', 'pdf-tools')).toBe('mcp_server#acme/pdf-tools'); + }); + + test('artifactKey builds the versioned S3 key', () => { + expect(artifactKey('mcp_server', 'acme', 'pdf-tools', '1.4.1')).toBe( + 'mcp_server/acme/pdf-tools/1.4.1/artifact', + ); + }); +}); diff --git a/cdk/test/handlers/shared/registry-resolver.test.ts b/cdk/test/handlers/shared/registry-resolver.test.ts new file mode 100644 index 00000000..d2d88f44 --- /dev/null +++ b/cdk/test/handlers/shared/registry-resolver.test.ts @@ -0,0 +1,316 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + isRegistryRef, + parseRef, + RegistryResolutionError, + resolveAll, + resolveRef, + type RegistryQueryClient, +} from '../../../src/handlers/shared/registry-resolver'; +import type { RegistryAssetRecord, RegistryAssetStatus } from '../../../src/handlers/shared/types'; + +// --- test helpers ------------------------------------------------------------ + +/** Build a minimal catalog row for the given version/status. */ +function row( + version: string, + status: RegistryAssetStatus, + overrides: Partial = {}, +): RegistryAssetRecord { + return { + pk: 'mcp_server#acme/pdf-tools', + sk: version, + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version, + descriptor: { summary: 's', permissions: [] }, + artifact_ref: `mcp_server/acme/pdf-tools/${version}/artifact`, + status, + publisher: 'sub-1', + created_at: '2026-07-20T00:00:00Z', + status_history: [], + ...overrides, + }; +} + +/** A query client that returns the given rows for every query. */ +function clientReturning(rows: RegistryAssetRecord[]): RegistryQueryClient { + return { send: jest.fn().mockResolvedValue({ Items: rows }) }; +} + +const TABLE = 'RegistryAssets'; + +// --- parseRef / grammar ------------------------------------------------------ + +describe('parseRef', () => { + test('parses a caret-pinned snake_case kind', () => { + expect(parseRef('registry://mcp_server/acme/pdf-tools@^1.4.1')).toEqual({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + constraint: '^1.4.1', + }); + }); + + test.each([ + 'registry://mcp_server/acme/pdf-tools@1.4.1', + 'registry://cedar_policy_module/acme/guard@~2.0.0', + 'registry://skill/acme/refactor@^1.0.0', + 'registry://mcp_server/acme/pdf-tools@1.4.1-rc.1', + ])('accepts valid ref %s', (ref) => { + expect(() => parseRef(ref)).not.toThrow(); + expect(isRegistryRef(ref)).toBe(true); + }); + + test.each([ + ['legacy 2-segment', 'registry://skill/x-v1'], + ['hyphen kind (shipped-regex form)', 'registry://mcp-server/acme/pdf-tools'], + ['unpinned 3-segment', 'registry://mcp_server/acme/pdf-tools'], + ['floating latest', 'registry://mcp_server/acme/pdf-tools@latest'], + ['range operator', 'registry://mcp_server/acme/pdf-tools@>=1.0.0'], + ['wildcard', 'registry://mcp_server/acme/pdf-tools@1.x'], + ['wrong scheme', 'http://evil/acme/x@1.0.0'], + ['uppercase kind', 'registry://MCP_Server/acme/x@1.0.0'], + ])('rejects %s', (_label, ref) => { + expect(() => parseRef(ref)).toThrow(RegistryResolutionError); + expect(isRegistryRef(ref)).toBe(false); + }); + + test('rejects a syntactically-fine but unknown kind', () => { + expect(() => parseRef('registry://not_a_kind/acme/x@1.0.0')).toThrow(/unknown kind/); + }); + + test('parse failure carries INVALID_REGISTRY_REF', () => { + try { + parseRef('registry://skill/x-v1'); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(RegistryResolutionError); + expect((e as RegistryResolutionError).reason).toBe('INVALID_REGISTRY_REF'); + } + }); +}); + +// --- resolveRef: semver + status -------------------------------------------- + +describe('resolveRef', () => { + test('selects the highest version satisfying a caret constraint', async () => { + const client = clientReturning([ + row('1.0.0', 'approved'), + row('1.2.0', 'approved'), + row('1.9.0', 'approved'), + row('2.0.0', 'approved'), // outside ^1.0.0 + ]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.9.0'); + expect(resolved.warnings).toEqual([]); + }); + + test('ranks by semver, not lexicographically (1.10.0 > 1.9.0)', async () => { + const client = clientReturning([row('1.9.0', 'approved'), row('1.10.0', 'approved')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.10.0'); + }); + + test('tilde constraint stays within the minor', async () => { + const client = clientReturning([ + row('1.2.3', 'approved'), + row('1.2.9', 'approved'), + row('1.3.0', 'approved'), // outside ~1.2.3 + ]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@~1.2.3', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.2.9'); + }); + + test('exact constraint matches only that version', async () => { + const client = clientReturning([row('1.4.0', 'approved'), row('1.4.1', 'approved')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@1.4.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.4.0'); + }); + + test('prereleases are excluded from a plain range', async () => { + const client = clientReturning([row('1.4.1-rc.1', 'approved'), row('1.4.0', 'approved')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.4.0'); + }); + + test('deprecated highest match resolves with a DEPRECATED warning', async () => { + const client = clientReturning([row('1.0.0', 'approved'), row('1.2.0', 'deprecated')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.2.0'); + expect(resolved.warnings).toEqual(['DEPRECATED']); + }); + + test('removed highest match fails with REMOVED', async () => { + const client = clientReturning([row('1.2.0', 'removed')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'REMOVED' }); + }); + + test('submitted-only highest match fails with NO_MATCHING_VERSION', async () => { + const client = clientReturning([row('1.2.0', 'submitted')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'NO_MATCHING_VERSION' }); + }); + + test('no version in range fails with NO_MATCHING_VERSION', async () => { + const client = clientReturning([row('2.0.0', 'approved')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'NO_MATCHING_VERSION' }); + }); + + test('empty catalog fails with NO_MATCHING_VERSION', async () => { + const client = clientReturning([]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'NO_MATCHING_VERSION' }); + }); + + test('invalid ref never touches the catalog', async () => { + const send = jest.fn().mockResolvedValue({ Items: [] }); + const client: RegistryQueryClient = { send }; + await expect( + resolveRef('registry://skill/x-v1', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'INVALID_REGISTRY_REF' }); + expect(send).not.toHaveBeenCalled(); + }); + + test('missing table name fails closed', async () => { + const client = clientReturning([row('1.0.0', 'approved')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: '' }), + ).rejects.toBeInstanceOf(RegistryResolutionError); + }); +}); + +// --- resolveAll -------------------------------------------------------------- + +describe('resolveAll', () => { + test('groups resolved assets by kind', async () => { + const client: RegistryQueryClient = { + send: jest.fn((command: { input: { ExpressionAttributeValues: { ':pk': string } } }) => { + const pk = command.input.ExpressionAttributeValues[':pk']; + if (pk === 'mcp_server#acme/pdf-tools') { + return Promise.resolve({ Items: [row('1.0.0', 'approved')] }); + } + return Promise.resolve({ + Items: [ + { + ...row('2.0.0', 'approved'), + pk: 'skill#acme/refactor', + kind: 'skill', + namespace: 'acme', + name: 'refactor', + version: '2.0.0', + }, + ], + }); + }), + }; + const bundle = await resolveAll( + ['registry://mcp_server/acme/pdf-tools@^1.0.0', 'registry://skill/acme/refactor@^2.0.0'], + { client, tableName: TABLE }, + ); + expect(bundle.mcp_servers).toHaveLength(1); + expect(bundle.skills).toHaveLength(1); + expect(bundle.cedar_policy_modules).toHaveLength(0); + }); + + test('one bad ref rejects the whole bundle (fail-closed)', async () => { + const client = clientReturning([]); + await expect( + resolveAll( + ['registry://mcp_server/acme/pdf-tools@^1.0.0', 'registry://skill/x-v1'], + { client, tableName: TABLE }, + ), + ).rejects.toBeInstanceOf(RegistryResolutionError); + }); + + test('empty ref list yields an empty bundle', async () => { + const bundle = await resolveAll([], { tableName: TABLE }); + expect(bundle).toEqual({ mcp_servers: [], cedar_policy_modules: [], skills: [] }); + }); + + test('a resolvable but loader-less reserved kind is refused (fail-closed)', async () => { + // capability is a declared grammar kind with no MVP loader (REGISTRY.md §2). + // Even if the catalog somehow holds an approved row, resolveAll must refuse + // rather than drop it silently. + const client: RegistryQueryClient = { + send: jest.fn().mockResolvedValue({ + Items: [ + { + ...row('1.0.0', 'approved'), + pk: 'capability#acme/wf', + kind: 'capability', + namespace: 'acme', + name: 'wf', + }, + ], + }), + }; + await expect( + resolveAll(['registry://capability/acme/wf@^1.0.0'], { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'INVALID_REGISTRY_REF' }); + }); +}); + +// --- parity corpus (grammar side) ------------------------------------------- + +describe('registry-resolution grammar parity corpus', () => { + const corpusDir = path.resolve(__dirname, '../../../../contracts/registry-resolution'); + const files = fs.readdirSync(corpusDir).filter((f) => f.startsWith('grammar-') && f.endsWith('.json')); + + test('corpus dir is present and non-empty', () => { + expect(files.length).toBeGreaterThan(0); + }); + + test.each(files)('%s — parseRef agrees with fixture verdict', (file) => { + const fixture = JSON.parse(fs.readFileSync(path.join(corpusDir, file), 'utf-8')); + expect(isRegistryRef(fixture.ref)).toBe(fixture.expected.valid); + if (fixture.expected.valid && fixture.expected.parsed) { + expect(parseRef(fixture.ref)).toEqual(fixture.expected.parsed); + } + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 3b8e6249..6eff269d 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -44,8 +44,9 @@ describe('AgentStack', () => { // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping), // jira-project-mapping, jira-user-mapping, jira-workspace-registry, // jira-webhook-dedup (added for the Jira Cloud integration), - // github-webhook-dedup (added by GitHubScreenshotIntegration on main) - template.resourceCountIs('AWS::DynamoDB::Table', 18); + // github-webhook-dedup (added by GitHubScreenshotIntegration on main), + // registry-assets (agent asset registry, #246) + template.resourceCountIs('AWS::DynamoDB::Table', 19); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { diff --git a/cli/src/types.ts b/cli/src/types.ts index b0a9f344..47f99d5d 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -30,6 +30,94 @@ export type ResolvedWorkflow = { readonly version: string; }; +// --- Agent asset registry (#246) -------------------------------------------- +// Wire-facing types consumed by the ``bgagent registry`` commands. Mirror +// ``cdk/src/handlers/shared/types.ts`` per the CLI types-sync contract. The DDB +// record shape (``RegistryAssetRecord``) and audit-event shape are server-only +// and intentionally not mirrored here. See docs/design/REGISTRY.md / ADR-018. + +/** + * Registry asset kinds. Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryAssetKind``. + */ +export type RegistryAssetKind = + | 'mcp_server' + | 'cedar_policy_module' + | 'skill' + | 'plugin' + | 'subagent' + | 'prompt_fragment' + | 'capability'; + +/** + * Lifecycle status of a registry asset version. ``approved`` is the single + * canonical resolvable state ("active" is not a code value). Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryAssetStatus``. + */ +export type RegistryAssetStatus = + | 'draft' + | 'submitted' + | 'approved' + | 'rejected' + | 'deprecated' + | 'removed'; + +/** + * A parsed ``registry://kind/namespace/name@constraint`` reference; the + * constraint pin is mandatory. Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryRef``. + */ +export type RegistryRef = { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly constraint: string; +}; + +/** + * Typed per-kind descriptor (validated at publish). Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryDescriptor``. + */ +export interface RegistryDescriptor { + readonly summary: string; + readonly permissions: readonly string[]; + readonly [key: string]: unknown; +} + +/** + * Result of resolving one ``registry://`` ref. Mirrors + * ``cdk/src/handlers/shared/types.ts::ResolvedAsset``. + */ +export interface ResolvedAsset { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly descriptor: RegistryDescriptor; + readonly artifact_url?: string; + readonly warnings: readonly string[]; +} + +/** + * All of a task's resolved assets grouped by kind. Mirrors + * ``cdk/src/handlers/shared/types.ts::ResolvedAssetBundle``. + */ +export interface ResolvedAssetBundle { + readonly mcp_servers: readonly ResolvedAsset[]; + readonly cedar_policy_modules: readonly ResolvedAsset[]; + readonly skills: readonly ResolvedAsset[]; +} + +/** + * Compact ``{kind, id, version}`` audit triple stamped on a task. Mirrors + * ``cdk/src/handlers/shared/types.ts::ResolvedAssetSummary``. + */ +export interface ResolvedAssetSummary { + readonly kind: RegistryAssetKind; + readonly id: string; + readonly version: string; +} + /** Shared across all attachment interfaces. Add new types here (e.g., 'audio'). */ export type AttachmentType = 'image' | 'file' | 'url'; diff --git a/contracts/registry-resolution/README.md b/contracts/registry-resolution/README.md new file mode 100644 index 00000000..0b212f81 --- /dev/null +++ b/contracts/registry-resolution/README.md @@ -0,0 +1,82 @@ +# Registry resolution parity fixtures + +Golden-file test vectors for the #246 registry URI grammar and resolution +semantics. Each fixture is a `registry://` ref (and, for resolution fixtures, a +catalog of candidate versions) paired with its **expected verdict**. + +**Design reference:** [`docs/design/REGISTRY.md`](../../docs/design/REGISTRY.md) +§6 (grammar) and §5 (resolution semantics); +[ADR-018](../../docs/decisions/ADR-018-agent-asset-registry.md). + +## Why this lives in `contracts/` + +A `registry://` ref is parsed on **two** sides of the platform in different +languages: the Python agent validator (`agent/src/workflow/validator.py` +`_REGISTRY_REF`, run at author/CI time) and the TypeScript resolver +(`cdk/src/handlers/shared/registry-resolver.ts` `parseRef`, run at the +create-task boundary). This is exactly the two-language drift hazard the repo +learned from twice — Cedar bindings and workflow validation. This corpus is the +neutral agreement both implementations must reproduce; neither `agent/` nor the +registry service owns it. + +Mirrors [`contracts/cedar-parity/`](../cedar-parity/README.md) and +[`contracts/workflow-validation/`](../workflow-validation/README.md). + +## Fixture shape + +Two fixture families share one directory, distinguished by their top-level keys. + +### Grammar fixtures — `grammar-*.json` + +Test the URI regex only (no catalog). Both `_REGISTRY_REF.match()` (Python) and +`parseRef()` (TypeScript) must agree on `valid`. + +```json +{ + "name": "short-identifier", + "description": "One-sentence purpose", + "ref": "registry://mcp_server/acme/pdf-tools@^1.4.1", + "expected": { "valid": true } +} +``` + +- `expected.valid` — `true` iff the ref matches the grammar (REGISTRY.md §6). +- When `valid` is `true`, `expected.parsed` MAY give the decomposed + `{ kind, namespace, name, constraint }` the TS parser must return. + +### Resolution fixtures — `resolve-*.json` + +Test the full resolve (grammar + semver match + status rules, REGISTRY.md §5) +against an in-memory catalog. + +```json +{ + "name": "short-identifier", + "description": "One-sentence purpose", + "ref": "registry://mcp_server/acme/pdf-tools@^1.0.0", + "catalog": [ + { "version": "1.0.0", "status": "approved" }, + { "version": "1.2.0", "status": "approved" }, + { "version": "2.0.0", "status": "approved" } + ], + "expected": { "version": "1.2.0", "warnings": [] } +} +``` + +- `expected.version` — the version that must win, or `null` on failure. +- `expected.reason` — required when `version` is `null`: one of + `NO_MATCHING_VERSION`, `REMOVED`, `INVALID_CONSTRAINT`, `INVALID_REGISTRY_REF`. +- `expected.warnings` — e.g. `["DEPRECATED"]` when the winning version is + `deprecated`. + +## Consumers + +- **Agent (Python):** loads every `grammar-*.json` and asserts + `bool(_REGISTRY_REF.match(ref)) == expected.valid`. +- **Registry resolver (TypeScript):** loads every fixture; `parseRef` must agree + on grammar, and `resolveRef` against the fixture catalog must reproduce the + resolution verdict. + +If a fixture's `expected` no longer matches an implementation, CI fails before +the change ships — either an implementation regressed or the fixture must be +updated as a recorded decision. diff --git a/contracts/registry-resolution/grammar-invalid-floating-latest.json b/contracts/registry-resolution/grammar-invalid-floating-latest.json new file mode 100644 index 00000000..d8036f60 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-floating-latest.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-floating-latest", + "description": "Floating 'latest' constraint is rejected (REGISTRY.md §5).", + "ref": "registry://mcp_server/acme/pdf-tools@latest", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-floating-range.json b/contracts/registry-resolution/grammar-invalid-floating-range.json new file mode 100644 index 00000000..ea23392c --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-floating-range.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-floating-range", + "description": "Range operator '>=' is rejected — only exact/caret/tilde allowed.", + "ref": "registry://mcp_server/acme/pdf-tools@>=1.0.0", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-legacy-2segment.json b/contracts/registry-resolution/grammar-invalid-legacy-2segment.json new file mode 100644 index 00000000..0e9e088b --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-legacy-2segment.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-legacy-2segment", + "description": "Legacy WORKFLOWS.md 2-segment form (registry://kind/name) — no namespace, no pin. Superseded by the 3-segment ADR-018 grammar.", + "ref": "registry://skill/x-v1", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-no-constraint.json b/contracts/registry-resolution/grammar-invalid-no-constraint.json new file mode 100644 index 00000000..9fda5377 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-no-constraint.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-no-constraint", + "description": "3-segment but unpinned — pins are MANDATORY (ADR-018 sub-decision 6, fail-closed). Invalid grammar.", + "ref": "registry://mcp_server/acme/pdf-tools", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-scheme.json b/contracts/registry-resolution/grammar-invalid-scheme.json new file mode 100644 index 00000000..c66211d1 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-scheme.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-scheme", + "description": "Non-registry scheme is rejected (rule-8 http://evil case).", + "ref": "http://evil/acme/x@1.0.0", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-underscore-kind-legacy.json b/contracts/registry-resolution/grammar-invalid-underscore-kind-legacy.json new file mode 100644 index 00000000..2319d6ca --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-underscore-kind-legacy.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-underscore-kind-legacy", + "description": "PR #548 review: the SHIPPED regex only matched hyphenated 'mcp-server'. That hyphen form is NOT the #246 grammar (kinds are snake_case) and carries no pin — invalid.", + "ref": "registry://mcp-server/acme/pdf-tools", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-uppercase-kind.json b/contracts/registry-resolution/grammar-invalid-uppercase-kind.json new file mode 100644 index 00000000..847d2472 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-uppercase-kind.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-uppercase-kind", + "description": "Kind must be lowercase snake_case; uppercase is rejected.", + "ref": "registry://MCP_Server/acme/pdf-tools@1.0.0", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-wildcard.json b/contracts/registry-resolution/grammar-invalid-wildcard.json new file mode 100644 index 00000000..ca3b242d --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-wildcard.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-wildcard", + "description": "Wildcard x-range is rejected.", + "ref": "registry://mcp_server/acme/pdf-tools@1.x", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-valid-exact.json b/contracts/registry-resolution/grammar-valid-exact.json new file mode 100644 index 00000000..5aac7246 --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-exact.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-exact", + "description": "Exact-version pin on a multi-word snake_case kind.", + "ref": "registry://cedar_policy_module/acme/force-push-guard@1.0.0", + "expected": { "valid": true, "parsed": { "kind": "cedar_policy_module", "namespace": "acme", "name": "force-push-guard", "constraint": "1.0.0" } } +} diff --git a/contracts/registry-resolution/grammar-valid-mcp-caret.json b/contracts/registry-resolution/grammar-valid-mcp-caret.json new file mode 100644 index 00000000..175da8f1 --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-mcp-caret.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-mcp-caret", + "description": "Canonical snake_case kind with caret constraint — the ADR-018 example that the SHIPPED regex rejected (PR #548 review). Must be valid.", + "ref": "registry://mcp_server/acme/pdf-tools@^1.4.1", + "expected": { "valid": true, "parsed": { "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "constraint": "^1.4.1" } } +} diff --git a/contracts/registry-resolution/grammar-valid-prerelease.json b/contracts/registry-resolution/grammar-valid-prerelease.json new file mode 100644 index 00000000..1a7ec306 --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-prerelease.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-prerelease", + "description": "Exact pin to a prerelease version (REGISTRY.md §5 — prereleases rank below their base).", + "ref": "registry://mcp_server/acme/pdf-tools@1.4.1-rc.1", + "expected": { "valid": true, "parsed": { "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "constraint": "1.4.1-rc.1" } } +} diff --git a/contracts/registry-resolution/grammar-valid-skill-tilde.json b/contracts/registry-resolution/grammar-valid-skill-tilde.json new file mode 100644 index 00000000..d22073aa --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-skill-tilde.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-skill-tilde", + "description": "skill kind with tilde constraint — PR #548 review case 'registry://skill/acme/refactor@~2.0.0'.", + "ref": "registry://skill/acme/refactor@~2.0.0", + "expected": { "valid": true, "parsed": { "kind": "skill", "namespace": "acme", "name": "refactor", "constraint": "~2.0.0" } } +} diff --git a/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json b/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json index 6183053f..378436c0 100644 --- a/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json +++ b/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json @@ -22,7 +22,7 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", diff --git a/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json b/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json index 2cc23097..cb23b92f 100644 --- a/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json +++ b/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json @@ -23,14 +23,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json b/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json index d413321a..6b5d506a 100644 --- a/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json +++ b/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json @@ -22,14 +22,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json b/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json index 9fa35f28..41b562ae 100644 --- a/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json +++ b/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json @@ -25,14 +25,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/contracts/workflow-validation/valid-knowledge-web-research.json b/contracts/workflow-validation/valid-knowledge-web-research.json index f040527c..ed31a092 100644 --- a/contracts/workflow-validation/valid-knowledge-web-research.json +++ b/contracts/workflow-validation/valid-knowledge-web-research.json @@ -26,14 +26,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/docs/design/REGISTRY.md b/docs/design/REGISTRY.md new file mode 100644 index 00000000..baa4138b --- /dev/null +++ b/docs/design/REGISTRY.md @@ -0,0 +1,240 @@ +# Agent asset registry + +A **registry asset** is a versioned, immutable-per-version runtime artifact that a task can load — an MCP server, a Cedar policy module, or a skill. Today those artifacts are vendored into the container image (`agent/src/channel_mcp.py`), inlined on the Blueprint construct (Cedar policies), or committed to a repo (`.mcp.json`). None of them are versioned, none carry an audit trail, and adding one means a **core-code change plus a CDK deploy**. The registry replaces that with a catalog: publishers push typed, versioned records via an API; blueprints pin them by `registry://kind/namespace/name@constraint`; the orchestrator resolves the pins at task start; and the agent receives a resolved bundle. + +- **Use this doc for:** the asset-kind catalog, the storage schema (DynamoDB + S3), the publish/resolve/list/show API contract, resolution semantics (semver, immutability, status), and how a resolved bundle flows from orchestrator to agent. +- **Related docs:** [WORKFLOWS.md](./WORKFLOWS.md) for the `registry://` grammar and asset-kind vocabulary this generalizes, [REPO_ONBOARDING.md](./REPO_ONBOARDING.md) for the per-repo **Blueprint** that references assets, [CEDAR_HITL_GATES.md](./CEDAR_HITL_GATES.md) for the policy engine that consumes `cedar_policy_module` assets, [SECURITY.md](./SECURITY.md) for tool tiers, and [IDENTITY_AND_AUTH.md](./IDENTITY_AND_AUTH.md) for the Cognito groups that gate publish. +- **Decision record:** [ADR-018](../decisions/ADR-018-agent-asset-registry.md). +- **Tracking issue:** [#246](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/246). Child issues: [#478](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/478) (lifecycle), [#479](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/479) (versioning + immutability), [#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480) (Blueprint integration + ACLs), [#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481) (capability descriptors). + +> **Substrate.** ADR-018 prefers AWS Agent Registry (Bedrock AgentCore) but requires a design-time prototype and defines four fall-back conditions. This document specifies the **DynamoDB + S3 fallback** (ADR-018 substrate option 2), which the fall-back conditions already select at authoring time (AgentCore Registry is in public preview and hard-migrates namespaces on 2026-08-06). The `RegistryClient` seam (§8) keeps the AgentCore path open as a later swap without changing the contract. + +## 1. Goals and non-goals + +**Goals (MVP, closes #246):** + +- A versioned, immutable-per-version catalog of typed runtime artifacts. +- Publish, resolve, list, and show over a REST API — no CDK deploy to add an asset. +- Semver-pinned references (`registry://kind/namespace/name@constraint`) resolved at the create-task boundary. +- One end-to-end asset kind proven (`mcp_server`); two more wired but staged (`cedar_policy_module`, `skill`). +- Descriptor validation at publish; resolved `{kind, id, version}` triples stamped on the task record for audit. +- Fail-closed resolution — a task never silently downgrades or substitutes an asset. + +**Non-goals (deferred to child issues / Phase 3):** + +- Transitive dependencies between registry assets (explicitly disallowed in MVP). +- Plugin, subagent, prompt_fragment, and capability (= workflow) loaders — declared in the schema, not loaded. +- Federation to / mirroring from upstream registries (official MCP registry, CNCF catalogs) — see §11. +- Cedar-governed publish ACLs and per-namespace granularity — MVP uses two Cognito groups (§10). +- EventBridge as a primary bus for asset events; migrating first-party workflows into the registry. + +## 2. Asset kinds for MVP + +| Kind | MVP status | Artifact | Loaded by | +|------|-----------|----------|-----------| +| `mcp_server` | **implemented E2E** | MCP server config JSON (`mcpServers` entry) | agent → merged into `.mcp.json` | +| `cedar_policy_module` | resolved + staged | Cedar policy text | agent → appended to `PolicyEngine` policies | +| `skill` | resolved + staged | prompt fragment + tool hints | agent → SDK `setting_sources` | +| `plugin`, `subagent`, `prompt_fragment`, `capability` | **declared, not loaded** | — | — (reserved kinds; §12 of ADR-018) | + +`capability` is reserved for workflows (ADR-014 vocabulary); workflows do **not** migrate into the registry in this work (ADR-018 sub-decision 12). Reserved kinds are accepted by the schema so the grammar is stable, but publish rejects them until a loader ships. + +## 3. Schema + +### 3.1 `RegistryAssetsTable` (DynamoDB) + +Metadata records. One row per published `(kind, namespace, name, version)`. + +| Attribute | Type | Key | Notes | +|-----------|------|-----|-------| +| `pk` | S | **partition** | `{kind}#{namespace}/{name}` — e.g. `mcp_server#acme/pdf-tools` | +| `sk` | S | **sort** | `version` — e.g. `1.4.1` (semver string) | +| `kind` | S | | denormalized for the list GSI | +| `namespace` | S | | | +| `name` | S | | | +| `version` | S | | semver, immutable once written | +| `descriptor` | M | | typed per-kind descriptor (§3.3) — validated at publish | +| `artifact_ref` | S | | S3 key (§3.4); empty for descriptor-only kinds | +| `status` | S | | `draft` \| `submitted` \| `approved` \| `rejected` \| `deprecated` \| `removed` (§5) | +| `publisher` | S | | Cognito `sub` of the publishing principal | +| `created_at` | S | | ISO-8601, set at publish | +| `status_history` | L | | append-only audit: `[{status, actor, at, rationale}]` | + +**GSI `kind-index`** — partition `kind`, sort `pk` — powers `GET /registry/assets?kind=mcp_server` (list) without a scan. + +> **Canonical status token.** The approved-and-resolvable state is spelled **`approved`** everywhere — in DynamoDB, the TypeScript resolver, and the Python loader. ADR-018 prose mentions "`active`" as a synonym; that word is **not** a value in code. One token, byte-for-byte, across both languages (the parity hazard ADR-018's `(−)` bullet flags). + +### 3.2 Partition/sort rationale + +`pk = {kind}#{namespace}/{name}` groups every version of one asset under a single partition; `sk = version` orders them. `GET /registry/assets/{id}` (show all versions) is a single `Query` on `pk`. Resolution (`resolveRef`) is a `Query` on `pk` that returns all versions, then ranks client-side by parsed semver (§5) — DynamoDB cannot sort semver lexicographically (`1.10.0` < `1.9.0` as strings), so ranking is always in code. + +### 3.3 Per-kind descriptor shapes + +Every record carries a typed `descriptor`, validated at publish (§4). Shared required fields: `summary` (string), `permissions` (list of strings). Per kind: + +```jsonc +// mcp_server +{ "summary": "...", "permissions": ["network:egress"], + "transport": "http" | "stdio", + "egress_domains": ["mcp.example.com"], // feeds Blueprint egress review + "tool_prefix": "mcp__example__", // tools surface under this prefix + "server_config": { /* mcpServers entry, or in artifact for large configs */ } } + +// cedar_policy_module +{ "summary": "...", "permissions": [], + "cedar_actions": ["Action::\"ForcePush\""], // actions the module introduces + "policy_text_ref": "artifact" } // Cedar text lives in the artifact + +// skill +{ "summary": "...", "permissions": [], + "prompt_fragment_ref": "artifact", + "tool_hints": ["Bash", "Edit"] } +``` + +### 3.4 `RegistryArtifactsBucket` (S3) + +Artifact bytes (MCP config JSON, Cedar text, skill prompt fragment). Key structure: + +``` +{kind}/{namespace}/{name}/{version}/artifact +``` + +e.g. `mcp_server/acme/pdf-tools/1.4.1/artifact`. Versioning **on**, SSE (S3-managed or KMS), public access blocked, TLS-only bucket policy, lifecycle rule to expire noncurrent versions. Mirrors `ecs-payload-bucket.ts` / `attachments-bucket.ts`. Immutability (§5) is enforced at the DynamoDB write, not by S3 object-lock, so `removed` can tombstone a record without a compliance-grade delete (ADR-018 risk bullet). + +## 4. API contract + +All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case (matching the rest of the API). + +### 4.1 `POST /registry/assets` — publish + +Request: + +```jsonc +{ "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", + "version": "1.4.1", "descriptor": { /* §3.3 */ }, + "artifact_b64": "..." } // optional; required for kinds with an artifact +``` + +- Validates kind ∈ MVP kinds, semver shape (§5), descriptor required fields (§3.3). +- **Immutability:** if `(kind, namespace, name, version)` exists → `409 REGISTRY_VERSION_EXISTS`. +- Uploads artifact to S3 (§3.4), writes the DynamoDB row. +- Initial `status`: `submitted` for a `RegistryPublisher`; `approved` if the caller is also a `RegistryApprover` and passes `?auto_approve=true` (dev). See §10. +- Response `201`: the created record (minus artifact bytes). + +### 4.2 `GET /registry/resolve?ref=registry://...` — resolve + +- Parses the ref (§6), queries candidate versions, ranks by semver, applies the constraint and status rules (§5). +- Response `200`: `{ kind, namespace, name, version, descriptor, artifact_url, warnings[] }` where `artifact_url` is a short-lived presigned GET (callers that want the bytes directly). +- Failure: `422 REGISTRY_RESOLUTION_FAILED` with `reason ∈ { NO_MATCHING_VERSION, REMOVED, INVALID_CONSTRAINT, INVALID_REGISTRY_REF }`. + +### 4.3 `GET /registry/assets?kind=mcp_server` — list + +- Queries `kind-index`. Optional `?namespace=` filter, `?status=` filter (default: exclude `removed`). +- Response `200`: `{ assets: [{ kind, namespace, name, latest_version, status }] }`. + +### 4.4 `GET /registry/assets/{kind}/{namespace}/{name}` — show + +- `Query` on `pk`; returns every version of one asset with status. +- Response `200`: `{ kind, namespace, name, versions: [{ version, status, created_at, publisher }] }`. + +## 5. Resolution semantics + +**Allowed constraint syntaxes** (validated at publish *and* at blueprint validation): + +| Syntax | Example | Matches | +|--------|---------|---------| +| exact | `1.4.1` | only `1.4.1` | +| caret | `^1.4.1` | `>=1.4.1 <2.0.0` | +| tilde | `~1.4.1` | `>=1.4.1 <1.5.0` | +| *(none)* | `registry://.../pdf-tools` | treated as `*` → **rejected** | + +**Rejected** at validation time with `INVALID_CONSTRAINT` / `INVALID_REGISTRY_REF`: `*`, `latest`, `>=`, `<=`, `>`, `<`, `x`-ranges, and bare prerelease modifiers. A ref with **no** `@constraint` is rejected — pins are mandatory (fail-closed; no implicit "latest"). + +**Resolution rule:** highest semver-comparable version matching the constraint wins; prereleases rank below their base version (`1.4.1-rc.1` < `1.4.1`). + +**Status handling:** + +| Status | Resolves? | Behavior | +|--------|-----------|----------| +| `approved` | yes | silent | +| `deprecated` | yes | resolves + `warnings: ["DEPRECATED"]` on response and a warning event on the task record | +| `submitted`, `draft`, `rejected` | no | not a candidate; if it's the only match → `NO_MATCHING_VERSION` | +| `removed` | no | if it's the highest match → `REMOVED` (distinct reason, so operators see a tombstoned pin vs. a never-existed one) | + +**Fail-closed:** any unresolved ref fails task admission with `REGISTRY_RESOLUTION_FAILED` and a specific reason. A running task never re-resolves or substitutes. + +## 6. URI grammar + +ADR-018 grammar: `registry:////@`. + +The shipped `_REGISTRY_REF` regex (`agent/src/workflow/validator.py`) predates this contract and does **not** match it — it has no `@` and allows only hyphens in the kind segment (so `mcp_server` and `@^1.4.1` both fail). This work **extends** the shipped grammar (ADR-018, corrected per review): the kind segment gains `_` (all MVP kinds are snake_case) and an optional `@` group is added. The extension ships in this PR on **both** sides — Python (`validator.py`) and TypeScript (`registry-resolver.ts`) — and is covered by the parity corpus (§12). + +Extended grammar (both languages must agree byte-for-byte): + +``` +registry:////[@] + kind = [a-z][a-z0-9_]* # snake_case: mcp_server, cedar_policy_module + namespace = [a-z][a-z0-9-]* + name = [a-z0-9][a-z0-9._-]* + constraint = [\^~]?MAJOR.MINOR.PATCH[-prerelease] # exact / caret / tilde only +``` + +> **Note — two grammars in the tree today.** WORKFLOWS.md examples use a 2-segment `registry://prompt/name` form. The 3-segment ADR-018 grammar above is authoritative for #246; the WORKFLOWS.md examples are illustrative and pre-date this contract. Reconciling those examples is a docs-only follow-up (tracked in the WORKFLOWS.md registry section), not a code change here. + +## 7. Orchestrator integration (preview — full impl in PR 2) + +At the create-task boundary (where `workflow_ref` already resolves, `cdk/src/handlers/shared/`), after loading the Blueprint config and before hydration: + +1. Collect `registry://` refs from the Blueprint's asset fields. +2. `resolveAll(refs)` → `ResolvedAssetBundle` (§8), failing admission on any unresolved ref. +3. Stamp `resolved_assets: [{kind, id, version}]` on the `TaskRecord` (audit). +4. Thread the full bundle into the agent invocation payload. + +PR 1 ships the resolver library and API only; nothing in the orchestrator calls it yet (purely additive). + +## 8. Agent integration (preview — full impl in PR 2/3) + +The agent receives `resolved_assets` in its payload and a per-kind loader applies each: + +- `mcp_server` → merge `server_config` into `.mcp.json` alongside `channel_mcp.py` output (PR 2). +- `cedar_policy_module` → append Cedar text to the `PolicyEngine` policy set (PR 3). +- `skill` → write the prompt fragment into the SDK `setting_sources` extension points (PR 3). + +**`RegistryClient` seam:** both sides talk to a `RegistryClient` abstraction, never to a raw AWS SDK client. The DDB+S3 implementation lives in one file per language; swapping to AgentCore Registry later (or absorbing the 2026-08-06 namespace rename) is confined there. + +## 9. Blueprint construct extension (preview — full impl in PR 2/3) + +`BlueprintProps.assets?: { mcpServers?: string[]; cedarPolicyModules?: string[]; skills?: string[] }`. Each entry is a `registry://` ref, validated at synth (reject floating constraints early). The refs flatten into `RepoConfig` columns (`mcp_servers`, `cedar_policy_modules`, `skills`). Existing inline `security.cedarPolicies` keep working alongside `cedarPolicyModules` refs (mixed case tested in PR 3). + +## 10. Access control (MVP) + +Two Cognito groups (ADR-018 sub-decision 11): + +- **`RegistryPublisher`** — may `POST /registry/assets`; records land in `submitted`. +- **`RegistryApprover`** — may transition `submitted → approved | rejected` and `approved → deprecated`, and may `?auto_approve=true` on publish (dev). + +Resolve / list / show are available to any authenticated caller. No per-namespace ACL in MVP. Every status transition appends to `status_history` with actor + timestamp + rationale (audit is a MUST). Cedar-governed publish ACLs are Phase 3 ([#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480)/[#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481)). + +## 11. Relationship to upstream registries + +The registry is a **self-contained catalog** in MVP: assets are published to ABCA's own store, not mirrored from or federated to external registries. This is deliberate — the resolution invariants (semver, immutability, fail-closed, descriptor validation) are ABCA-side guarantees, and an upstream source would have to satisfy them before ABCA could depend on it. + +Ecosystem catalogs exist and are relevant as **future discovery sources**, not MVP substrates: + +- The official **MCP registry** (`registry.modelcontextprotocol.io`, [modelcontextprotocol/registry](https://github.com/modelcontextprotocol/registry)) — the closest analogue for the `mcp_server` kind. +- **AWS Agent Registry (Bedrock AgentCore)** — the ADR-018 *preferred* substrate; the `RegistryClient` seam (§8) is the swap point. +- Language/package precedents (PyPI, npm, container registries) — the "registry of registries" model. + +**MVP answer to "how is the lookup maintained":** by publishers, via the publish API, into ABCA's own DynamoDB catalog — ABCA is the single source of truth for what a task can load, so the resolution contract is enforceable. **Federation** (ingesting/mirroring an upstream registry behind the same `RegistryClient` interface, with a trust/verification gate) is a Phase 3 option the seam permits without re-opening the contract. It is out of scope for #246 and should not be added by scope-creep, because an unverified upstream would undermine the fail-closed guarantee. + +## 12. Test plan + +- **Resolver unit tests** (`cdk/test/handlers/shared/registry-resolver.test.ts`): URI parse valid/invalid; semver match for exact/`^`/`~`; highest-version selection; prerelease ranking; no-match → `NO_MATCHING_VERSION`; `deprecated` → warning; `removed` → `REMOVED`; floating constraint → `INVALID_CONSTRAINT`. +- **Grammar parity corpus** (`contracts/registry-resolution/`): annotated `(ref) → verdict` fixtures run against **both** the Python `_REGISTRY_REF` and the TS `parseRef`, mirroring `contracts/cedar-parity/` and `contracts/workflow-validation/`. Includes the exact cases from the PR #548 review (snake_case kinds, `@constraint`). +- **Handler tests**: publish happy path, `409` immutability, descriptor validation errors, auth refusal (non-publisher), resolve/list/show. +- **Construct tests**: `registry-assets-table.test.ts`, `registry-artifacts-bucket.test.ts` (encryption, versioning, public-access-block, TLS policy). +- **E2E (PR 2)**: publish an MCP server → reference from a Blueprint → run a task → assert the agent payload carries the bundle and the `TaskRecord` has `resolved_assets`. + +## 13. Out of scope (explicit) + +Transitive registry-asset dependencies; plugin/subagent/prompt_fragment/capability loaders; upstream federation; per-namespace ACL; EventBridge as primary event bus; migrating first-party workflows into the registry; compliance-grade (GDPR) deletion of artifact bytes (`removed` tombstones the record only). diff --git a/docs/src/content/docs/architecture/Registry.md b/docs/src/content/docs/architecture/Registry.md new file mode 100644 index 00000000..45a05a09 --- /dev/null +++ b/docs/src/content/docs/architecture/Registry.md @@ -0,0 +1,244 @@ +--- +title: Registry +--- + +# Agent asset registry + +A **registry asset** is a versioned, immutable-per-version runtime artifact that a task can load — an MCP server, a Cedar policy module, or a skill. Today those artifacts are vendored into the container image (`agent/src/channel_mcp.py`), inlined on the Blueprint construct (Cedar policies), or committed to a repo (`.mcp.json`). None of them are versioned, none carry an audit trail, and adding one means a **core-code change plus a CDK deploy**. The registry replaces that with a catalog: publishers push typed, versioned records via an API; blueprints pin them by `registry://kind/namespace/name@constraint`; the orchestrator resolves the pins at task start; and the agent receives a resolved bundle. + +- **Use this doc for:** the asset-kind catalog, the storage schema (DynamoDB + S3), the publish/resolve/list/show API contract, resolution semantics (semver, immutability, status), and how a resolved bundle flows from orchestrator to agent. +- **Related docs:** [WORKFLOWS.md](/sample-autonomous-cloud-coding-agents/architecture/workflows) for the `registry://` grammar and asset-kind vocabulary this generalizes, [REPO_ONBOARDING.md](/sample-autonomous-cloud-coding-agents/architecture/repo-onboarding) for the per-repo **Blueprint** that references assets, [CEDAR_HITL_GATES.md](/sample-autonomous-cloud-coding-agents/architecture/cedar-hitl-gates) for the policy engine that consumes `cedar_policy_module` assets, [SECURITY.md](/sample-autonomous-cloud-coding-agents/architecture/security) for tool tiers, and [IDENTITY_AND_AUTH.md](/sample-autonomous-cloud-coding-agents/architecture/identity-and-auth) for the Cognito groups that gate publish. +- **Decision record:** [ADR-018](/sample-autonomous-cloud-coding-agents/architecture/adr-018-agent-asset-registry). +- **Tracking issue:** [#246](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/246). Child issues: [#478](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/478) (lifecycle), [#479](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/479) (versioning + immutability), [#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480) (Blueprint integration + ACLs), [#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481) (capability descriptors). + +> **Substrate.** ADR-018 prefers AWS Agent Registry (Bedrock AgentCore) but requires a design-time prototype and defines four fall-back conditions. This document specifies the **DynamoDB + S3 fallback** (ADR-018 substrate option 2), which the fall-back conditions already select at authoring time (AgentCore Registry is in public preview and hard-migrates namespaces on 2026-08-06). The `RegistryClient` seam (§8) keeps the AgentCore path open as a later swap without changing the contract. + +## 1. Goals and non-goals + +**Goals (MVP, closes #246):** + +- A versioned, immutable-per-version catalog of typed runtime artifacts. +- Publish, resolve, list, and show over a REST API — no CDK deploy to add an asset. +- Semver-pinned references (`registry://kind/namespace/name@constraint`) resolved at the create-task boundary. +- One end-to-end asset kind proven (`mcp_server`); two more wired but staged (`cedar_policy_module`, `skill`). +- Descriptor validation at publish; resolved `{kind, id, version}` triples stamped on the task record for audit. +- Fail-closed resolution — a task never silently downgrades or substitutes an asset. + +**Non-goals (deferred to child issues / Phase 3):** + +- Transitive dependencies between registry assets (explicitly disallowed in MVP). +- Plugin, subagent, prompt_fragment, and capability (= workflow) loaders — declared in the schema, not loaded. +- Federation to / mirroring from upstream registries (official MCP registry, CNCF catalogs) — see §11. +- Cedar-governed publish ACLs and per-namespace granularity — MVP uses two Cognito groups (§10). +- EventBridge as a primary bus for asset events; migrating first-party workflows into the registry. + +## 2. Asset kinds for MVP + +| Kind | MVP status | Artifact | Loaded by | +|------|-----------|----------|-----------| +| `mcp_server` | **implemented E2E** | MCP server config JSON (`mcpServers` entry) | agent → merged into `.mcp.json` | +| `cedar_policy_module` | resolved + staged | Cedar policy text | agent → appended to `PolicyEngine` policies | +| `skill` | resolved + staged | prompt fragment + tool hints | agent → SDK `setting_sources` | +| `plugin`, `subagent`, `prompt_fragment`, `capability` | **declared, not loaded** | — | — (reserved kinds; §12 of ADR-018) | + +`capability` is reserved for workflows (ADR-014 vocabulary); workflows do **not** migrate into the registry in this work (ADR-018 sub-decision 12). Reserved kinds are accepted by the schema so the grammar is stable, but publish rejects them until a loader ships. + +## 3. Schema + +### 3.1 `RegistryAssetsTable` (DynamoDB) + +Metadata records. One row per published `(kind, namespace, name, version)`. + +| Attribute | Type | Key | Notes | +|-----------|------|-----|-------| +| `pk` | S | **partition** | `{kind}#{namespace}/{name}` — e.g. `mcp_server#acme/pdf-tools` | +| `sk` | S | **sort** | `version` — e.g. `1.4.1` (semver string) | +| `kind` | S | | denormalized for the list GSI | +| `namespace` | S | | | +| `name` | S | | | +| `version` | S | | semver, immutable once written | +| `descriptor` | M | | typed per-kind descriptor (§3.3) — validated at publish | +| `artifact_ref` | S | | S3 key (§3.4); empty for descriptor-only kinds | +| `status` | S | | `draft` \| `submitted` \| `approved` \| `rejected` \| `deprecated` \| `removed` (§5) | +| `publisher` | S | | Cognito `sub` of the publishing principal | +| `created_at` | S | | ISO-8601, set at publish | +| `status_history` | L | | append-only audit: `[{status, actor, at, rationale}]` | + +**GSI `kind-index`** — partition `kind`, sort `pk` — powers `GET /registry/assets?kind=mcp_server` (list) without a scan. + +> **Canonical status token.** The approved-and-resolvable state is spelled **`approved`** everywhere — in DynamoDB, the TypeScript resolver, and the Python loader. ADR-018 prose mentions "`active`" as a synonym; that word is **not** a value in code. One token, byte-for-byte, across both languages (the parity hazard ADR-018's `(−)` bullet flags). + +### 3.2 Partition/sort rationale + +`pk = {kind}#{namespace}/{name}` groups every version of one asset under a single partition; `sk = version` orders them. `GET /registry/assets/{id}` (show all versions) is a single `Query` on `pk`. Resolution (`resolveRef`) is a `Query` on `pk` that returns all versions, then ranks client-side by parsed semver (§5) — DynamoDB cannot sort semver lexicographically (`1.10.0` < `1.9.0` as strings), so ranking is always in code. + +### 3.3 Per-kind descriptor shapes + +Every record carries a typed `descriptor`, validated at publish (§4). Shared required fields: `summary` (string), `permissions` (list of strings). Per kind: + +```jsonc +// mcp_server +{ "summary": "...", "permissions": ["network:egress"], + "transport": "http" | "stdio", + "egress_domains": ["mcp.example.com"], // feeds Blueprint egress review + "tool_prefix": "mcp__example__", // tools surface under this prefix + "server_config": { /* mcpServers entry, or in artifact for large configs */ } } + +// cedar_policy_module +{ "summary": "...", "permissions": [], + "cedar_actions": ["Action::\"ForcePush\""], // actions the module introduces + "policy_text_ref": "artifact" } // Cedar text lives in the artifact + +// skill +{ "summary": "...", "permissions": [], + "prompt_fragment_ref": "artifact", + "tool_hints": ["Bash", "Edit"] } +``` + +### 3.4 `RegistryArtifactsBucket` (S3) + +Artifact bytes (MCP config JSON, Cedar text, skill prompt fragment). Key structure: + +``` +{kind}/{namespace}/{name}/{version}/artifact +``` + +e.g. `mcp_server/acme/pdf-tools/1.4.1/artifact`. Versioning **on**, SSE (S3-managed or KMS), public access blocked, TLS-only bucket policy, lifecycle rule to expire noncurrent versions. Mirrors `ecs-payload-bucket.ts` / `attachments-bucket.ts`. Immutability (§5) is enforced at the DynamoDB write, not by S3 object-lock, so `removed` can tombstone a record without a compliance-grade delete (ADR-018 risk bullet). + +## 4. API contract + +All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case (matching the rest of the API). + +### 4.1 `POST /registry/assets` — publish + +Request: + +```jsonc +{ "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", + "version": "1.4.1", "descriptor": { /* §3.3 */ }, + "artifact_b64": "..." } // optional; required for kinds with an artifact +``` + +- Validates kind ∈ MVP kinds, semver shape (§5), descriptor required fields (§3.3). +- **Immutability:** if `(kind, namespace, name, version)` exists → `409 REGISTRY_VERSION_EXISTS`. +- Uploads artifact to S3 (§3.4), writes the DynamoDB row. +- Initial `status`: `submitted` for a `RegistryPublisher`; `approved` if the caller is also a `RegistryApprover` and passes `?auto_approve=true` (dev). See §10. +- Response `201`: the created record (minus artifact bytes). + +### 4.2 `GET /registry/resolve?ref=registry://...` — resolve + +- Parses the ref (§6), queries candidate versions, ranks by semver, applies the constraint and status rules (§5). +- Response `200`: `{ kind, namespace, name, version, descriptor, artifact_url, warnings[] }` where `artifact_url` is a short-lived presigned GET (callers that want the bytes directly). +- Failure: `422 REGISTRY_RESOLUTION_FAILED` with `reason ∈ { NO_MATCHING_VERSION, REMOVED, INVALID_CONSTRAINT, INVALID_REGISTRY_REF }`. + +### 4.3 `GET /registry/assets?kind=mcp_server` — list + +- Queries `kind-index`. Optional `?namespace=` filter, `?status=` filter (default: exclude `removed`). +- Response `200`: `{ assets: [{ kind, namespace, name, latest_version, status }] }`. + +### 4.4 `GET /registry/assets/{kind}/{namespace}/{name}` — show + +- `Query` on `pk`; returns every version of one asset with status. +- Response `200`: `{ kind, namespace, name, versions: [{ version, status, created_at, publisher }] }`. + +## 5. Resolution semantics + +**Allowed constraint syntaxes** (validated at publish *and* at blueprint validation): + +| Syntax | Example | Matches | +|--------|---------|---------| +| exact | `1.4.1` | only `1.4.1` | +| caret | `^1.4.1` | `>=1.4.1 <2.0.0` | +| tilde | `~1.4.1` | `>=1.4.1 <1.5.0` | +| *(none)* | `registry://.../pdf-tools` | treated as `*` → **rejected** | + +**Rejected** at validation time with `INVALID_CONSTRAINT` / `INVALID_REGISTRY_REF`: `*`, `latest`, `>=`, `<=`, `>`, `<`, `x`-ranges, and bare prerelease modifiers. A ref with **no** `@constraint` is rejected — pins are mandatory (fail-closed; no implicit "latest"). + +**Resolution rule:** highest semver-comparable version matching the constraint wins; prereleases rank below their base version (`1.4.1-rc.1` < `1.4.1`). + +**Status handling:** + +| Status | Resolves? | Behavior | +|--------|-----------|----------| +| `approved` | yes | silent | +| `deprecated` | yes | resolves + `warnings: ["DEPRECATED"]` on response and a warning event on the task record | +| `submitted`, `draft`, `rejected` | no | not a candidate; if it's the only match → `NO_MATCHING_VERSION` | +| `removed` | no | if it's the highest match → `REMOVED` (distinct reason, so operators see a tombstoned pin vs. a never-existed one) | + +**Fail-closed:** any unresolved ref fails task admission with `REGISTRY_RESOLUTION_FAILED` and a specific reason. A running task never re-resolves or substitutes. + +## 6. URI grammar + +ADR-018 grammar: `registry:////@`. + +The shipped `_REGISTRY_REF` regex (`agent/src/workflow/validator.py`) predates this contract and does **not** match it — it has no `@` and allows only hyphens in the kind segment (so `mcp_server` and `@^1.4.1` both fail). This work **extends** the shipped grammar (ADR-018, corrected per review): the kind segment gains `_` (all MVP kinds are snake_case) and an optional `@` group is added. The extension ships in this PR on **both** sides — Python (`validator.py`) and TypeScript (`registry-resolver.ts`) — and is covered by the parity corpus (§12). + +Extended grammar (both languages must agree byte-for-byte): + +``` +registry:////[@] + kind = [a-z][a-z0-9_]* # snake_case: mcp_server, cedar_policy_module + namespace = [a-z][a-z0-9-]* + name = [a-z0-9][a-z0-9._-]* + constraint = [\^~]?MAJOR.MINOR.PATCH[-prerelease] # exact / caret / tilde only +``` + +> **Note — two grammars in the tree today.** WORKFLOWS.md examples use a 2-segment `registry://prompt/name` form. The 3-segment ADR-018 grammar above is authoritative for #246; the WORKFLOWS.md examples are illustrative and pre-date this contract. Reconciling those examples is a docs-only follow-up (tracked in the WORKFLOWS.md registry section), not a code change here. + +## 7. Orchestrator integration (preview — full impl in PR 2) + +At the create-task boundary (where `workflow_ref` already resolves, `cdk/src/handlers/shared/`), after loading the Blueprint config and before hydration: + +1. Collect `registry://` refs from the Blueprint's asset fields. +2. `resolveAll(refs)` → `ResolvedAssetBundle` (§8), failing admission on any unresolved ref. +3. Stamp `resolved_assets: [{kind, id, version}]` on the `TaskRecord` (audit). +4. Thread the full bundle into the agent invocation payload. + +PR 1 ships the resolver library and API only; nothing in the orchestrator calls it yet (purely additive). + +## 8. Agent integration (preview — full impl in PR 2/3) + +The agent receives `resolved_assets` in its payload and a per-kind loader applies each: + +- `mcp_server` → merge `server_config` into `.mcp.json` alongside `channel_mcp.py` output (PR 2). +- `cedar_policy_module` → append Cedar text to the `PolicyEngine` policy set (PR 3). +- `skill` → write the prompt fragment into the SDK `setting_sources` extension points (PR 3). + +**`RegistryClient` seam:** both sides talk to a `RegistryClient` abstraction, never to a raw AWS SDK client. The DDB+S3 implementation lives in one file per language; swapping to AgentCore Registry later (or absorbing the 2026-08-06 namespace rename) is confined there. + +## 9. Blueprint construct extension (preview — full impl in PR 2/3) + +`BlueprintProps.assets?: { mcpServers?: string[]; cedarPolicyModules?: string[]; skills?: string[] }`. Each entry is a `registry://` ref, validated at synth (reject floating constraints early). The refs flatten into `RepoConfig` columns (`mcp_servers`, `cedar_policy_modules`, `skills`). Existing inline `security.cedarPolicies` keep working alongside `cedarPolicyModules` refs (mixed case tested in PR 3). + +## 10. Access control (MVP) + +Two Cognito groups (ADR-018 sub-decision 11): + +- **`RegistryPublisher`** — may `POST /registry/assets`; records land in `submitted`. +- **`RegistryApprover`** — may transition `submitted → approved | rejected` and `approved → deprecated`, and may `?auto_approve=true` on publish (dev). + +Resolve / list / show are available to any authenticated caller. No per-namespace ACL in MVP. Every status transition appends to `status_history` with actor + timestamp + rationale (audit is a MUST). Cedar-governed publish ACLs are Phase 3 ([#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480)/[#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481)). + +## 11. Relationship to upstream registries + +The registry is a **self-contained catalog** in MVP: assets are published to ABCA's own store, not mirrored from or federated to external registries. This is deliberate — the resolution invariants (semver, immutability, fail-closed, descriptor validation) are ABCA-side guarantees, and an upstream source would have to satisfy them before ABCA could depend on it. + +Ecosystem catalogs exist and are relevant as **future discovery sources**, not MVP substrates: + +- The official **MCP registry** (`registry.modelcontextprotocol.io`, [modelcontextprotocol/registry](https://github.com/modelcontextprotocol/registry)) — the closest analogue for the `mcp_server` kind. +- **AWS Agent Registry (Bedrock AgentCore)** — the ADR-018 *preferred* substrate; the `RegistryClient` seam (§8) is the swap point. +- Language/package precedents (PyPI, npm, container registries) — the "registry of registries" model. + +**MVP answer to "how is the lookup maintained":** by publishers, via the publish API, into ABCA's own DynamoDB catalog — ABCA is the single source of truth for what a task can load, so the resolution contract is enforceable. **Federation** (ingesting/mirroring an upstream registry behind the same `RegistryClient` interface, with a trust/verification gate) is a Phase 3 option the seam permits without re-opening the contract. It is out of scope for #246 and should not be added by scope-creep, because an unverified upstream would undermine the fail-closed guarantee. + +## 12. Test plan + +- **Resolver unit tests** (`cdk/test/handlers/shared/registry-resolver.test.ts`): URI parse valid/invalid; semver match for exact/`^`/`~`; highest-version selection; prerelease ranking; no-match → `NO_MATCHING_VERSION`; `deprecated` → warning; `removed` → `REMOVED`; floating constraint → `INVALID_CONSTRAINT`. +- **Grammar parity corpus** (`contracts/registry-resolution/`): annotated `(ref) → verdict` fixtures run against **both** the Python `_REGISTRY_REF` and the TS `parseRef`, mirroring `contracts/cedar-parity/` and `contracts/workflow-validation/`. Includes the exact cases from the PR #548 review (snake_case kinds, `@constraint`). +- **Handler tests**: publish happy path, `409` immutability, descriptor validation errors, auth refusal (non-publisher), resolve/list/show. +- **Construct tests**: `registry-assets-table.test.ts`, `registry-artifacts-bucket.test.ts` (encryption, versioning, public-access-block, TLS policy). +- **E2E (PR 2)**: publish an MCP server → reference from a Blueprint → run a task → assert the agent payload carries the bundle and the `TaskRecord` has `resolved_assets`. + +## 13. Out of scope (explicit) + +Transitive registry-asset dependencies; plugin/subagent/prompt_fragment/capability loaders; upstream federation; per-namespace ACL; EventBridge as primary event bus; migrating first-party workflows into the registry; compliance-grade (GDPR) deletion of artifact bytes (`removed` tombstones the record only). diff --git a/scripts/check-types-sync.ts b/scripts/check-types-sync.ts index 68ce017a..b9f01a83 100644 --- a/scripts/check-types-sync.ts +++ b/scripts/check-types-sync.ts @@ -104,6 +104,11 @@ const CDK_ONLY_ALLOWLIST = new Set([ // Internal extension shape used by create-task-core.ts to thread // Cedar HITL fields without widening the public CreateTaskRequest: 'CreateTaskApprovalExtensions', + // Registry (#246) server-only persistence/audit shapes — the CLI consumes + // the resolved/wire types (RegistryRef, ResolvedAsset, etc.) but never the + // raw DDB row or its audit-event entries. See docs/design/REGISTRY.md §3.1. + 'RegistryAssetRecord', + 'RegistryStatusEvent', // Server-side bound constants — sourced from contracts/constants.json // (S9). Cross-language drift is enforced by scripts/check-constants-sync.ts. 'APPROVAL_GATE_CAP_MIN', diff --git a/yarn.lock b/yarn.lock index 04b6df25..4b5aa5f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3020,6 +3020,11 @@ dependencies: "@types/node" "*" +"@types/semver@^7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" + integrity sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA== + "@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"