Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion agent/src/workflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -62,6 +62,7 @@
"WorkflowValidationError",
"assert_valid",
"gate_status",
"is_registry_ref",
"load_workflow",
"load_workflow_file",
"policy_principal_for",
Expand Down
27 changes: 26 additions & 1 deletion agent/src/workflow/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://<kind>/<namespace>/<name>@<constraint>
# The kind segment is snake_case (mcp_server, cedar_policy_module) and the
# @<constraint> 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).
Expand Down Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions agent/tests/test_registry_grammar_corpus.py
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 12 additions & 2 deletions agent/tests/test_workflow_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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),
],
Expand Down
2 changes: 2 additions & 0 deletions cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions cdk/src/constructs/registry-artifacts-bucket.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
113 changes: 113 additions & 0 deletions cdk/src/constructs/registry-assets-table.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Loading
Loading