diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5e6c1..a225144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Add a versioned source-manifest schema, documentation, and synthetic conforming example for public-source provenance records. - Add a bounded agent-job result schema, documentation, and synthetic conforming example linked to source-manifest provenance. - Add backward-compatible cost, timeout, and freshness metadata to agent-job results. +- Add a default-deny action-control schema, deterministic evaluator, documentation, and synthetic dry-run and authorized-action examples. ### Security diff --git a/DESIGN.md b/DESIGN.md index 57667dc..d3b0914 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -84,3 +84,7 @@ The public pod catalog currently returns the full catalog even when a smaller `l ### D9. Add operational observations to v1 without breaking existing documents Cost, timeout, and freshness are optional, strictly bounded objects in the agent-job result v1 schema so documents created against the initial v1 contract remain valid. Costs use non-negative decimal strings rather than JSON numbers to avoid floating-point ambiguity. These fields expose public job-level observations only and exclude account, wallet, payment-credential, internal-budget, and provider-secret data. + +### D10. Separate authorization from execution and default to denial + +The action-control evaluator computes a stable digest over a bounded public action and emits only a dry-run, authorized, or rejected decision. It never executes an action. Execute requests require an unexpired approval whose action identifier, type, and digest match exactly; missing, malformed, expired, rejected, future-issued, or mismatched approvals deny execution. A separate adapter must re-check authorization at its execution boundary. diff --git a/README.md b/README.md index 4db70b7..acdcdf8 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Public contributions must be independently reviewable from public sources or int 1. **Public foundation** — governance, security, CI, and boundary checks. 2. **Reppo read-only inspector** — public API discovery and diagnostics with stable JSON. -3. **Provenance schemas** — source manifests and structured agent-job results. +3. **Provenance and safety schemas** — source manifests, structured agent-job results, and default-deny action controls. 4. **Virtuals ACP reference integration** — a bounded, observable example service. 5. **Community validation** — upstream feedback, external users, and a documented inference-sponsorship decision. @@ -81,6 +81,8 @@ See [docs/reppo-inspector.md](docs/reppo-inspector.md) for the JSON contract, ca For portable public-source provenance records and bounded structured job results, see [docs/provenance-schemas.md](docs/provenance-schemas.md), `schemas/source-manifest-v1.schema.json`, and `schemas/agent-job-result-v1.schema.json`. +For deterministic dry-run and default-deny approval decisions, see [docs/action-controls.md](docs/action-controls.md), `schemas/action-control-v1.schema.json`, and `agentic_commerce.action_control`. The evaluator authorizes or rejects bounded actions but never executes them. + For silent compatibility drift detection and bounded weekly project evidence, see [docs/automation.md](docs/automation.md). These helpers are read-only and never perform GitHub mutations. At the 2026-07-11 compatibility check, the datanet and pod catalogs were live. The documented public stats route returned HTTP 404, so `status` and `snapshot` correctly returned partial result code `2` while preserving catalog data. The upstream pods route also ignored its requested page size; the client applies the requested limit after a capped download. diff --git a/ROADMAP.md b/ROADMAP.md index 84477d0..8953a94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,7 +32,7 @@ A phase is complete only when its artifacts are exercised and its verification g - [x] Versioned source-manifest schema - [x] Structured agent-job result schema - [x] Cost, timeout, and freshness fields -- [ ] Dry-run and approval-control reference patterns +- [x] Dry-run and approval-control reference patterns **Gate:** another example can consume the schemas without private project context. diff --git a/docs/README.md b/docs/README.md index d375f81..83079c1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,7 @@ - [Reppo read-only ecosystem inspector](reppo-inspector.md) - [Provenance schemas](provenance-schemas.md) - [Agent job result schema](provenance-schemas.md#agent-job-result-v1) +- [Dry-run and approval controls](action-controls.md) - [Read-only maintenance automation](automation.md) - [Release process](releasing.md) diff --git a/docs/action-controls.md b/docs/action-controls.md new file mode 100644 index 0000000..25c1849 --- /dev/null +++ b/docs/action-controls.md @@ -0,0 +1,45 @@ +# Dry-run and approval controls + +The toolkit provides a default-deny reference pattern for evaluating bounded actions before any external execution. + +## Artifacts + +- `schemas/action-control-v1.schema.json` defines the portable control-decision contract. +- `agentic_commerce.action_control` computes action digests and evaluates dry-run or execute requests without performing them. +- `examples/action-control/` contains deterministic synthetic dry-run and authorized-action records. + +## Decision model + +Every control record contains a bounded request, an optional approval, and a decision: + +| Request or approval state | Decision | `mayExecute` | +| --- | --- | --- | +| `dry-run` | `dry-run` / `DRY_RUN_ONLY` | `false` | +| `execute` without approval | `rejected` / `APPROVAL_REQUIRED` | `false` | +| rejected approval | `rejected` / `APPROVAL_REJECTED` | `false` | +| expired approval | `rejected` / `APPROVAL_EXPIRED` | `false` | +| mismatched action scope | `rejected` / `APPROVAL_SCOPE_MISMATCH` | `false` | +| valid matching approval | `authorized` / `APPROVED` | `true` | + +The evaluator never executes an action. `mayExecute: true` means only that a separate execution adapter may proceed after re-checking the decision at its own boundary. + +## Approval scope + +An approval is bound to: + +- a stable public `actionId`; +- a bounded `actionType`; +- an `actionDigest` computed over the identifier, type, summary, and ordered public parameters; +- unique parameter names, bounded scalar values, and finite numeric magnitudes; +- an issue and expiration interval; +- an explicit approved or rejected decision. + +The request mode is excluded from the digest so the exact action first evaluated in dry-run mode can later be submitted in execute mode without changing its scope. Changing the action summary or any parameter changes the digest and invalidates the approval. + +JSON Schema cannot compare timestamps or cross-check digest equality. The standard-library evaluator performs those checks deterministically and defaults to denial when an approval is absent, malformed, expired, or scoped to another action. + +## Public boundary + +Only public, bounded action summaries and parameters belong in these records. Never include credentials, signing material, wallet or account data, private billing records, internal budgets, private communications, local paths, or private runtime identifiers. + +This is a control pattern, not a signing or transaction API. Future write-capable adapters must separately document simulation, idempotency, budgets, signer assumptions, and recovery behavior. diff --git a/examples/README.md b/examples/README.md index b051a7f..3cf8d45 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,5 +3,6 @@ - [Reppo inspector](reppo-inspector/README.md) - [Source manifest](source-manifest/README.md) - [Agent job result](agent-job-result/README.md) +- [Dry-run and approval controls](action-control/README.md) Tested, public-source-only examples live here. diff --git a/examples/action-control/README.md b/examples/action-control/README.md new file mode 100644 index 0000000..3215fe1 --- /dev/null +++ b/examples/action-control/README.md @@ -0,0 +1,10 @@ +# Action-control examples + +These synthetic examples exercise `schemas/action-control-v1.schema.json` and the deterministic evaluator in `agentic_commerce.action_control`. + +- `dry-run-v1.example.json` demonstrates that a dry-run never consumes an approval and always returns `mayExecute: false`. +- `authorized-action-v1.example.json` demonstrates an unexpired human approval scoped to the exact action identifier, type, summary, and bounded parameters through a SHA-256 action digest. + +An `authorized` decision is not proof of execution and does not perform an action. A separate adapter would need to re-check the decision at its execution boundary. This repository does not provide a transaction writer, signer, wallet integration, or live mutation API. + +The examples contain public synthetic identifiers and parameters only. Do not place credentials, wallet or account data, private runtime state, internal budgets, private communications, or local paths in action requests or approvals. diff --git a/examples/action-control/authorized-action-v1.example.json b/examples/action-control/authorized-action-v1.example.json new file mode 100644 index 0000000..e2dd841 --- /dev/null +++ b/examples/action-control/authorized-action-v1.example.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": "1.0", + "controlId": "example:control:authorized:2026-07-14", + "evaluatedAt": "2026-07-14T13:00:00Z", + "request": { + "actionId": "example:catalog-update:2026-07-14", + "actionType": "example.catalog-update", + "actionDigest": "e17e5f2954ef59e20a5cbad2c0ef7825f8aad5937c37bddfc4acc1b44cad3223", + "mode": "execute", + "summary": "Prepare a synthetic update to a public example catalog.", + "parameters": [ + { + "name": "catalog", + "value": "synthetic-public-examples" + }, + { + "name": "itemCount", + "value": 2 + } + ] + }, + "approval": { + "approvalId": "example:approval:catalog-update:2026-07-14", + "actionId": "example:catalog-update:2026-07-14", + "actionType": "example.catalog-update", + "actionDigest": "e17e5f2954ef59e20a5cbad2c0ef7825f8aad5937c37bddfc4acc1b44cad3223", + "decision": "approved", + "issuedAt": "2026-07-14T12:55:00Z", + "expiresAt": "2026-07-14T13:30:00Z", + "issuerType": "human", + "note": "Synthetic approval for the public reference example only." + }, + "decision": { + "status": "authorized", + "mayExecute": true, + "reasonCode": "APPROVED", + "message": "The action is authorized for a separate execution adapter." + }, + "limitations": [ + "Authorization is not execution; a separate adapter must perform any approved action.", + "This control record must contain public inputs only and must not contain credentials, wallet data, account identifiers, or private runtime state." + ] +} diff --git a/examples/action-control/dry-run-v1.example.json b/examples/action-control/dry-run-v1.example.json new file mode 100644 index 0000000..328b9d1 --- /dev/null +++ b/examples/action-control/dry-run-v1.example.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "1.0", + "controlId": "example:control:dry-run:2026-07-14", + "evaluatedAt": "2026-07-14T13:00:00Z", + "request": { + "actionId": "example:catalog-update:2026-07-14", + "actionType": "example.catalog-update", + "actionDigest": "e17e5f2954ef59e20a5cbad2c0ef7825f8aad5937c37bddfc4acc1b44cad3223", + "mode": "dry-run", + "summary": "Prepare a synthetic update to a public example catalog.", + "parameters": [ + { + "name": "catalog", + "value": "synthetic-public-examples" + }, + { + "name": "itemCount", + "value": 2 + } + ] + }, + "approval": null, + "decision": { + "status": "dry-run", + "mayExecute": false, + "reasonCode": "DRY_RUN_ONLY", + "message": "The action was evaluated only; execution is not authorized." + }, + "limitations": [ + "Authorization is not execution; a separate adapter must perform any approved action.", + "This control record must contain public inputs only and must not contain credentials, wallet data, account identifiers, or private runtime state." + ] +} diff --git a/schemas/README.md b/schemas/README.md index 631620f..e5ae2b7 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -6,5 +6,7 @@ - `../examples/source-manifest/reppo-public-api-manifest-v1.example.json` — synthetic conforming source-manifest example validated in CI. - `agent-job-result-v1.schema.json` — bounded public result envelope for structured agent jobs, including optional cost, timeout, and freshness metadata. - `../examples/agent-job-result/reppo-inspection-result-v1.example.json` — synthetic conforming agent-job result validated in CI. +- `action-control-v1.schema.json` — default-deny dry-run and approval-control decision contract. +- `../examples/action-control/` — synthetic dry-run and authorized-action examples validated in CI. Versioned JSON schemas for additional safety patterns will live here. diff --git a/schemas/action-control-v1.schema.json b/schemas/action-control-v1.schema.json new file mode 100644 index 0000000..c8c18ce --- /dev/null +++ b/schemas/action-control-v1.schema.json @@ -0,0 +1,314 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/nccrypto/agentic-commerce-toolkit/main/schemas/action-control-v1.schema.json", + "title": "Agentic Commerce Action Control v1", + "description": "A default-deny control decision for a bounded dry-run or approval-scoped public action.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "controlId", + "evaluatedAt", + "request", + "approval", + "decision", + "limitations" + ], + "properties": { + "schemaVersion": {"const": "1.0"}, + "controlId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{2,127}$" + }, + "evaluatedAt": {"type": "string", "format": "date-time"}, + "request": {"$ref": "#/$defs/request"}, + "approval": { + "oneOf": [ + {"$ref": "#/$defs/approval"}, + {"type": "null"} + ] + }, + "decision": {"$ref": "#/$defs/decision"}, + "limitations": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "request": { + "properties": {"mode": {"const": "dry-run"}}, + "required": ["mode"] + } + }, + "required": ["request"] + }, + "then": { + "properties": { + "approval": {"type": "null"}, + "decision": { + "properties": { + "status": {"const": "dry-run"}, + "mayExecute": {"const": false}, + "reasonCode": {"const": "DRY_RUN_ONLY"} + } + } + } + } + }, + { + "if": { + "properties": { + "decision": { + "properties": {"status": {"const": "dry-run"}}, + "required": ["status"] + } + }, + "required": ["decision"] + }, + "then": { + "properties": { + "request": { + "properties": {"mode": {"const": "dry-run"}} + }, + "approval": {"type": "null"}, + "decision": { + "properties": { + "mayExecute": {"const": false}, + "reasonCode": {"const": "DRY_RUN_ONLY"} + } + } + } + } + }, + { + "if": { + "properties": { + "decision": { + "properties": {"status": {"const": "authorized"}}, + "required": ["status"] + } + }, + "required": ["decision"] + }, + "then": { + "properties": { + "request": { + "properties": {"mode": {"const": "execute"}} + }, + "approval": { + "allOf": [ + {"$ref": "#/$defs/approval"}, + {"properties": {"decision": {"const": "approved"}}} + ] + }, + "decision": { + "properties": { + "mayExecute": {"const": true}, + "reasonCode": {"const": "APPROVED"} + } + } + } + } + }, + { + "if": { + "properties": { + "decision": { + "properties": {"status": {"const": "rejected"}}, + "required": ["status"] + } + }, + "required": ["decision"] + }, + "then": { + "properties": { + "request": { + "properties": {"mode": {"const": "execute"}} + }, + "decision": { + "properties": { + "mayExecute": {"const": false}, + "reasonCode": { + "enum": [ + "APPROVAL_REQUIRED", + "APPROVAL_REJECTED", + "APPROVAL_EXPIRED", + "APPROVAL_SCOPE_MISMATCH", + "INVALID_APPROVAL" + ] + } + } + } + } + } + }, + { + "if": { + "properties": { + "request": { + "properties": {"mode": {"const": "execute"}}, + "required": ["mode"] + }, + "approval": {"type": "null"} + }, + "required": ["request", "approval"] + }, + "then": { + "properties": { + "decision": { + "properties": { + "status": {"const": "rejected"}, + "mayExecute": {"const": false}, + "reasonCode": { + "enum": ["APPROVAL_REQUIRED", "INVALID_APPROVAL"] + } + } + } + } + } + } + ], + "$defs": { + "request": { + "type": "object", + "additionalProperties": false, + "required": [ + "actionId", + "actionType", + "actionDigest", + "mode", + "summary", + "parameters" + ], + "properties": { + "actionId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{2,127}$" + }, + "actionType": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{1,63}$" + }, + "actionDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": {"enum": ["dry-run", "execute"]}, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "parameters": { + "type": "array", + "maxItems": 50, + "items": {"$ref": "#/$defs/parameter"} + } + } + }, + "parameter": { + "type": "object", + "additionalProperties": false, + "required": ["name", "value"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9._-]{0,63}$" + }, + "value": {"$ref": "#/$defs/parameterValue"} + } + }, + "parameterValue": { + "anyOf": [ + {"$ref": "#/$defs/publicScalar"}, + { + "type": "array", + "maxItems": 100, + "items": {"$ref": "#/$defs/publicScalar"} + } + ] + }, + "approval": { + "type": "object", + "additionalProperties": false, + "required": [ + "approvalId", + "actionId", + "actionType", + "actionDigest", + "decision", + "issuedAt", + "expiresAt", + "issuerType" + ], + "properties": { + "approvalId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{2,127}$" + }, + "actionId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{2,127}$" + }, + "actionType": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{1,63}$" + }, + "actionDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "decision": {"enum": ["approved", "rejected"]}, + "issuedAt": {"type": "string", "format": "date-time"}, + "expiresAt": {"type": "string", "format": "date-time"}, + "issuerType": {"enum": ["human", "policy"]}, + "note": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "decision": { + "type": "object", + "additionalProperties": false, + "required": ["status", "mayExecute", "reasonCode", "message"], + "properties": { + "status": {"enum": ["dry-run", "authorized", "rejected"]}, + "mayExecute": {"type": "boolean"}, + "reasonCode": { + "enum": [ + "DRY_RUN_ONLY", + "APPROVED", + "APPROVAL_REQUIRED", + "APPROVAL_REJECTED", + "APPROVAL_EXPIRED", + "APPROVAL_SCOPE_MISMATCH", + "INVALID_APPROVAL" + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "publicScalar": { + "anyOf": [ + {"type": "string", "maxLength": 1000}, + {"type": "number", "minimum": -1000000000000000000, "maximum": 1000000000000000000}, + {"type": "boolean"}, + {"type": "null"} + ] + } + } +} diff --git a/src/agentic_commerce/action_control.py b/src/agentic_commerce/action_control.py new file mode 100644 index 0000000..adee2a0 --- /dev/null +++ b/src/agentic_commerce/action_control.py @@ -0,0 +1,269 @@ +"""Deterministic, default-deny action-control reference evaluator.""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from datetime import datetime +from typing import Any, Mapping + + +_PUBLIC_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{2,127}$") +_ACTION_TYPE = re.compile(r"^[a-z][a-z0-9._-]{1,63}$") +_PARAMETER_NAME = re.compile(r"^[a-z][a-zA-Z0-9._-]{0,63}$") +_DIGEST = re.compile(r"^[a-f0-9]{64}$") +_DATE_TIME = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$" +) +_MAX_NUMERIC_MAGNITUDE = 10**18 +_APPROVAL_FIELDS = { + "approvalId", + "actionId", + "actionType", + "actionDigest", + "decision", + "issuedAt", + "expiresAt", + "issuerType", + "note", +} +_REQUIRED_APPROVAL_FIELDS = _APPROVAL_FIELDS - {"note"} +_LIMITATIONS = [ + "Authorization is not execution; a separate adapter must perform any approved action.", + "This control record must contain public inputs only and must not contain credentials, wallet data, account identifiers, or private runtime state.", +] + + +def _parse_datetime(value: str, field: str) -> datetime: + if not isinstance(value, str) or not _DATE_TIME.fullmatch(value): + raise ValueError(f"{field} must be an RFC 3339 date-time string") + candidate = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(candidate) + except ValueError as error: + raise ValueError(f"{field} must be an RFC 3339 date-time string") from error + if parsed.tzinfo is None: + raise ValueError(f"{field} must include a timezone") + return parsed + + +def _validate_scalar(value: Any) -> None: + if value is None or isinstance(value, bool): + return + if isinstance(value, int) and abs(value) <= _MAX_NUMERIC_MAGNITUDE: + return + if ( + isinstance(value, float) + and math.isfinite(value) + and abs(value) <= _MAX_NUMERIC_MAGNITUDE + ): + return + if isinstance(value, str) and len(value) <= 1000: + return + raise ValueError("parameter values must be bounded public scalars") + + +def _validate_request(request: Mapping[str, Any]) -> dict[str, Any]: + required = {"actionId", "actionType", "mode", "summary", "parameters"} + if not isinstance(request, Mapping) or set(request) != required: + raise ValueError("request must contain only actionId, actionType, mode, summary, and parameters") + + action_id = request["actionId"] + action_type = request["actionType"] + mode = request["mode"] + summary = request["summary"] + parameters = request["parameters"] + + if not isinstance(action_id, str) or not _PUBLIC_ID.fullmatch(action_id): + raise ValueError("actionId must be a stable public identifier") + if not isinstance(action_type, str) or not _ACTION_TYPE.fullmatch(action_type): + raise ValueError("actionType must be a bounded public action type") + if mode not in {"dry-run", "execute"}: + raise ValueError("mode must be dry-run or execute") + if not isinstance(summary, str) or not 1 <= len(summary) <= 500: + raise ValueError("summary must contain between 1 and 500 characters") + if not isinstance(parameters, list) or len(parameters) > 50: + raise ValueError("parameters must be a list with at most 50 entries") + + normalized_parameters = [] + parameter_names = set() + for parameter in parameters: + if not isinstance(parameter, Mapping) or set(parameter) != {"name", "value"}: + raise ValueError("each parameter must contain only name and value") + name = parameter["name"] + value = parameter["value"] + if not isinstance(name, str) or not _PARAMETER_NAME.fullmatch(name): + raise ValueError("parameter names must be bounded public identifiers") + if name in parameter_names: + raise ValueError("parameter names must be unique") + parameter_names.add(name) + if isinstance(value, list): + if len(value) > 100: + raise ValueError("parameter arrays must contain at most 100 values") + for item in value: + _validate_scalar(item) + else: + _validate_scalar(value) + normalized_parameters.append({"name": name, "value": value}) + + return { + "actionId": action_id, + "actionType": action_type, + "mode": mode, + "summary": summary, + "parameters": normalized_parameters, + } + + +def action_digest(request: Mapping[str, Any]) -> str: + """Return a stable SHA-256 digest for the action scope, excluding its mode.""" + + normalized = _validate_request(request) + payload = { + "actionId": normalized["actionId"], + "actionType": normalized["actionType"], + "summary": normalized["summary"], + "parameters": normalized["parameters"], + } + encoded = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _validate_approval(approval: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(approval, Mapping): + raise ValueError("approval must be an object") + fields = set(approval) + if not _REQUIRED_APPROVAL_FIELDS.issubset(fields) or not fields.issubset(_APPROVAL_FIELDS): + raise ValueError("approval contains missing or undeclared fields") + if not isinstance(approval["approvalId"], str) or not _PUBLIC_ID.fullmatch(approval["approvalId"]): + raise ValueError("approvalId must be a stable public identifier") + if not isinstance(approval["actionId"], str) or not _PUBLIC_ID.fullmatch(approval["actionId"]): + raise ValueError("approval actionId must be a stable public identifier") + if not isinstance(approval["actionType"], str) or not _ACTION_TYPE.fullmatch(approval["actionType"]): + raise ValueError("approval actionType must be bounded") + if not isinstance(approval["actionDigest"], str) or not _DIGEST.fullmatch(approval["actionDigest"]): + raise ValueError("approval actionDigest must be a SHA-256 digest") + if approval["decision"] not in {"approved", "rejected"}: + raise ValueError("approval decision must be approved or rejected") + if approval["issuerType"] not in {"human", "policy"}: + raise ValueError("issuerType must be human or policy") + _parse_datetime(approval["issuedAt"], "approval.issuedAt") + _parse_datetime(approval["expiresAt"], "approval.expiresAt") + if "note" in approval and ( + not isinstance(approval["note"], str) or not 1 <= len(approval["note"]) <= 500 + ): + raise ValueError("approval note must contain between 1 and 500 characters") + return dict(approval) + + +def evaluate_action_control( + control_id: str, + request: Mapping[str, Any], + evaluated_at: str, + approval: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Evaluate a dry-run or execute request without performing the action.""" + + if not isinstance(control_id, str) or not _PUBLIC_ID.fullmatch(control_id): + raise ValueError("controlId must be a stable public identifier") + normalized = _validate_request(request) + evaluated_time = _parse_datetime(evaluated_at, "evaluatedAt") + digest = action_digest(normalized) + request_record = { + "actionId": normalized["actionId"], + "actionType": normalized["actionType"], + "actionDigest": digest, + "mode": normalized["mode"], + "summary": normalized["summary"], + "parameters": normalized["parameters"], + } + + if normalized["mode"] == "dry-run": + if approval is not None: + raise ValueError("dry-run requests must not include an approval") + approval_record = None + decision = { + "status": "dry-run", + "mayExecute": False, + "reasonCode": "DRY_RUN_ONLY", + "message": "The action was evaluated only; execution is not authorized.", + } + elif approval is None: + approval_record = None + decision = { + "status": "rejected", + "mayExecute": False, + "reasonCode": "APPROVAL_REQUIRED", + "message": "Execution is denied because no approval was supplied.", + } + else: + try: + approval_record = _validate_approval(approval) + except ValueError: + approval_record = None + decision = { + "status": "rejected", + "mayExecute": False, + "reasonCode": "INVALID_APPROVAL", + "message": "Execution is denied because the approval record is invalid.", + } + else: + issued_at = _parse_datetime(approval_record["issuedAt"], "approval.issuedAt") + expires_at = _parse_datetime(approval_record["expiresAt"], "approval.expiresAt") + scoped = ( + approval_record["actionId"] == normalized["actionId"] + and approval_record["actionType"] == normalized["actionType"] + and approval_record["actionDigest"] == digest + ) + if not scoped: + decision = { + "status": "rejected", + "mayExecute": False, + "reasonCode": "APPROVAL_SCOPE_MISMATCH", + "message": "Execution is denied because the approval does not match the action scope.", + } + elif issued_at > evaluated_time or expires_at <= issued_at: + decision = { + "status": "rejected", + "mayExecute": False, + "reasonCode": "INVALID_APPROVAL", + "message": "Execution is denied because the approval validity interval is invalid.", + } + elif expires_at <= evaluated_time: + decision = { + "status": "rejected", + "mayExecute": False, + "reasonCode": "APPROVAL_EXPIRED", + "message": "Execution is denied because the approval has expired.", + } + elif approval_record["decision"] == "rejected": + decision = { + "status": "rejected", + "mayExecute": False, + "reasonCode": "APPROVAL_REJECTED", + "message": "Execution is denied by the supplied approval decision.", + } + else: + decision = { + "status": "authorized", + "mayExecute": True, + "reasonCode": "APPROVED", + "message": "The action is authorized for a separate execution adapter.", + } + + return { + "schemaVersion": "1.0", + "controlId": control_id, + "evaluatedAt": evaluated_at, + "request": request_record, + "approval": approval_record, + "decision": decision, + "limitations": list(_LIMITATIONS), + } diff --git a/tests/test_action_control.py b/tests/test_action_control.py new file mode 100644 index 0000000..b7a895c --- /dev/null +++ b/tests/test_action_control.py @@ -0,0 +1,197 @@ +import unittest + +from agentic_commerce.action_control import action_digest, evaluate_action_control + + +EVALUATED_AT = "2026-07-14T13:00:00Z" + + +def action_request(mode="execute"): + return { + "actionId": "example:catalog-update:2026-07-14", + "actionType": "example.catalog-update", + "mode": mode, + "summary": "Prepare a synthetic update to a public example catalog.", + "parameters": [ + {"name": "catalog", "value": "synthetic-public-examples"}, + {"name": "itemCount", "value": 2}, + ], + } + + +def matching_approval(request=None, **overrides): + request = request or action_request() + approval = { + "approvalId": "example:approval:catalog-update:2026-07-14", + "actionId": request["actionId"], + "actionType": request["actionType"], + "actionDigest": action_digest(request), + "decision": "approved", + "issuedAt": "2026-07-14T12:55:00Z", + "expiresAt": "2026-07-14T13:30:00Z", + "issuerType": "human", + } + approval.update(overrides) + return approval + + +class ActionControlTests(unittest.TestCase): + def test_digest_is_stable_across_mode_but_changes_with_scope(self): + dry_run = action_request("dry-run") + execute = action_request("execute") + changed = action_request("execute") + changed["parameters"][1]["value"] = 3 + + self.assertEqual(action_digest(dry_run), action_digest(execute)) + self.assertNotEqual(action_digest(execute), action_digest(changed)) + + def test_dry_run_never_authorizes_execution(self): + record = evaluate_action_control( + "example:control:dry-run:2026-07-14", + action_request("dry-run"), + EVALUATED_AT, + ) + + self.assertIsNone(record["approval"]) + self.assertEqual("dry-run", record["decision"]["status"]) + self.assertEqual("DRY_RUN_ONLY", record["decision"]["reasonCode"]) + self.assertFalse(record["decision"]["mayExecute"]) + + def test_dry_run_refuses_to_consume_approval(self): + request = action_request("dry-run") + + with self.assertRaisesRegex(ValueError, "must not include an approval"): + evaluate_action_control( + "example:control:dry-run:2026-07-14", + request, + EVALUATED_AT, + matching_approval(request), + ) + + def test_execute_without_approval_is_default_deny(self): + record = evaluate_action_control( + "example:control:missing-approval:2026-07-14", + action_request(), + EVALUATED_AT, + ) + + self.assertEqual("rejected", record["decision"]["status"]) + self.assertEqual("APPROVAL_REQUIRED", record["decision"]["reasonCode"]) + self.assertFalse(record["decision"]["mayExecute"]) + + def test_matching_unexpired_approval_authorizes_but_does_not_execute(self): + request = action_request() + record = evaluate_action_control( + "example:control:authorized:2026-07-14", + request, + EVALUATED_AT, + matching_approval(request), + ) + + self.assertEqual("authorized", record["decision"]["status"]) + self.assertEqual("APPROVED", record["decision"]["reasonCode"]) + self.assertTrue(record["decision"]["mayExecute"]) + self.assertNotIn("executed", record["decision"]) + + def test_expired_approval_is_rejected(self): + request = action_request() + record = evaluate_action_control( + "example:control:expired:2026-07-14", + request, + EVALUATED_AT, + matching_approval(request, expiresAt="2026-07-14T12:59:59Z"), + ) + + self.assertEqual("APPROVAL_EXPIRED", record["decision"]["reasonCode"]) + self.assertFalse(record["decision"]["mayExecute"]) + + def test_mismatched_approval_scope_is_rejected(self): + request = action_request() + record = evaluate_action_control( + "example:control:mismatch:2026-07-14", + request, + EVALUATED_AT, + matching_approval(request, actionDigest="0" * 64), + ) + + self.assertEqual("APPROVAL_SCOPE_MISMATCH", record["decision"]["reasonCode"]) + self.assertFalse(record["decision"]["mayExecute"]) + + def test_explicit_rejection_is_enforced(self): + request = action_request() + record = evaluate_action_control( + "example:control:rejected:2026-07-14", + request, + EVALUATED_AT, + matching_approval(request, decision="rejected"), + ) + + self.assertEqual("APPROVAL_REJECTED", record["decision"]["reasonCode"]) + self.assertFalse(record["decision"]["mayExecute"]) + + def test_invalid_approval_is_sanitized_and_denied(self): + request = action_request() + approval = matching_approval(request) + approval["accountId"] = "not allowed" + record = evaluate_action_control( + "example:control:invalid:2026-07-14", + request, + EVALUATED_AT, + approval, + ) + + self.assertIsNone(record["approval"]) + self.assertEqual("INVALID_APPROVAL", record["decision"]["reasonCode"]) + self.assertFalse(record["decision"]["mayExecute"]) + + def test_future_or_reversed_approval_interval_is_invalid(self): + request = action_request() + future = evaluate_action_control( + "example:control:future:2026-07-14", + request, + EVALUATED_AT, + matching_approval(request, issuedAt="2026-07-14T13:01:00Z"), + ) + reversed_interval = evaluate_action_control( + "example:control:reversed:2026-07-14", + request, + EVALUATED_AT, + matching_approval(request, expiresAt="2026-07-14T12:54:00Z"), + ) + + self.assertEqual("INVALID_APPROVAL", future["decision"]["reasonCode"]) + self.assertEqual("INVALID_APPROVAL", reversed_interval["decision"]["reasonCode"]) + + def test_private_shaped_unbounded_or_ambiguous_parameters_are_rejected(self): + private_request = action_request() + private_request["localPath"] = "not allowed" + non_finite_request = action_request() + non_finite_request["parameters"][1]["value"] = float("nan") + huge_number_request = action_request() + huge_number_request["parameters"][1]["value"] = 10**19 + duplicate_request = action_request() + duplicate_request["parameters"].append( + {"name": "itemCount", "value": 3} + ) + + for invalid_request in ( + private_request, + non_finite_request, + huge_number_request, + duplicate_request, + ): + with self.subTest(invalid_request=invalid_request): + with self.assertRaises(ValueError): + action_digest(invalid_request) + + def test_evaluation_timestamp_must_be_strict_rfc3339(self): + with self.assertRaisesRegex(ValueError, "RFC 3339"): + evaluate_action_control( + "example:control:bad-time:2026-07-14", + action_request("dry-run"), + "2026-07-14 13:00:00+00:00", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_schema_contract.py b/tests/test_schema_contract.py index e425b54..f058e08 100644 --- a/tests/test_schema_contract.py +++ b/tests/test_schema_contract.py @@ -4,6 +4,8 @@ from jsonschema import Draft202012Validator, FormatChecker +from agentic_commerce.action_control import action_digest, evaluate_action_control + ROOT = Path(__file__).parents[1] INSPECTOR_SCHEMA = ROOT / "schemas" / "inspector-envelope-v1.schema.json" @@ -12,6 +14,9 @@ SOURCE_MANIFEST_EXAMPLE = ROOT / "examples" / "source-manifest" / "reppo-public-api-manifest-v1.example.json" AGENT_JOB_RESULT_SCHEMA = ROOT / "schemas" / "agent-job-result-v1.schema.json" AGENT_JOB_RESULT_EXAMPLE = ROOT / "examples" / "agent-job-result" / "reppo-inspection-result-v1.example.json" +ACTION_CONTROL_SCHEMA = ROOT / "schemas" / "action-control-v1.schema.json" +ACTION_CONTROL_DRY_RUN_EXAMPLE = ROOT / "examples" / "action-control" / "dry-run-v1.example.json" +ACTION_CONTROL_AUTHORIZED_EXAMPLE = ROOT / "examples" / "action-control" / "authorized-action-v1.example.json" def load_json(path): @@ -38,6 +43,65 @@ def test_source_manifest_example_conforms_to_source_manifest_v1(self): def test_agent_job_result_example_conforms_to_agent_job_result_v1(self): self.assert_conforms(AGENT_JOB_RESULT_SCHEMA, AGENT_JOB_RESULT_EXAMPLE) + def test_action_control_examples_conform_to_action_control_v1(self): + self.assert_conforms(ACTION_CONTROL_SCHEMA, ACTION_CONTROL_DRY_RUN_EXAMPLE) + self.assert_conforms(ACTION_CONTROL_SCHEMA, ACTION_CONTROL_AUTHORIZED_EXAMPLE) + + def test_action_control_schema_enforces_default_deny(self): + schema = load_json(ACTION_CONTROL_SCHEMA) + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + + dry_run = load_json(ACTION_CONTROL_DRY_RUN_EXAMPLE) + dry_run["decision"]["mayExecute"] = True + self.assertFalse(validator.is_valid(dry_run)) + + authorized = load_json(ACTION_CONTROL_AUTHORIZED_EXAMPLE) + authorized["approval"] = None + self.assertFalse(validator.is_valid(authorized)) + + private_shaped = load_json(ACTION_CONTROL_DRY_RUN_EXAMPLE) + private_shaped["request"]["localPath"] = "not allowed" + self.assertFalse(validator.is_valid(private_shaped)) + + def test_action_control_evaluator_denials_conform_to_schema(self): + schema = load_json(ACTION_CONTROL_SCHEMA) + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + request = { + "actionId": "example:catalog-update:2026-07-14", + "actionType": "example.catalog-update", + "mode": "execute", + "summary": "Prepare a synthetic update to a public example catalog.", + "parameters": [{"name": "itemCount", "value": 2}], + } + approval = { + "approvalId": "example:approval:catalog-update:2026-07-14", + "actionId": request["actionId"], + "actionType": request["actionType"], + "actionDigest": action_digest(request), + "decision": "approved", + "issuedAt": "2026-07-14T12:00:00Z", + "expiresAt": "2026-07-14T12:30:00Z", + "issuerType": "human", + } + expired = evaluate_action_control( + "example:control:expired:2026-07-14", + request, + "2026-07-14T13:00:00Z", + approval, + ) + invalid_approval = dict(approval) + invalid_approval["accountId"] = "not allowed" + invalid = evaluate_action_control( + "example:control:invalid:2026-07-14", + request, + "2026-07-14T13:00:00Z", + invalid_approval, + ) + + self.assertTrue(validator.is_valid(expired)) + self.assertTrue(validator.is_valid(invalid)) + + def test_agent_job_operational_fields_are_backward_compatible_and_optional(self): schema = load_json(AGENT_JOB_RESULT_SCHEMA) validator = Draft202012Validator(schema, format_checker=FormatChecker())