From 0be069b67b45aeee4e8af5858b1e7bafcd779cc3 Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Fri, 31 Jul 2026 02:11:47 -0700 Subject: [PATCH] feat(policy): add deployment readiness guards --- .gitignore | 1 + apps/sint-mcp/src/downstream.ts | 2 +- apps/sint-mcp/src/server.ts | 2 +- docs/COMPETITIVE-LANDSCAPE.md | 32 +++ .../code-as-policy-robot-agent-safety.md | 143 ++++++++++ docs/guides/spatial-integrity-policy.md | 68 +++++ docs/index.md | 2 + docs/social/launch-checklist.md | 4 +- .../code-as-policy-skill-guard.v1.json | 215 +++++++++++++++ ...-as-policy-skill-guard-conformance.test.ts | 165 ++++++++++++ .../conformance-tests/src/fixture-loader.ts | 84 ++++++ .../__tests__/code-as-policy-guard.test.ts | 247 ++++++++++++++++++ .../spatial-integrity-policy.test.ts | 189 ++++++++++++++ .../src/code-as-policy-guard.ts | 229 ++++++++++++++++ packages/policy-gateway/src/gateway.ts | 76 ++++++ packages/policy-gateway/src/index.ts | 17 ++ .../src/spatial-integrity-policy.ts | 216 +++++++++++++++ 17 files changed, 1689 insertions(+), 3 deletions(-) create mode 100644 docs/guides/code-as-policy-robot-agent-safety.md create mode 100644 docs/guides/spatial-integrity-policy.md create mode 100644 packages/conformance-tests/fixtures/physical-ai/code-as-policy-skill-guard.v1.json create mode 100644 packages/conformance-tests/src/code-as-policy-skill-guard-conformance.test.ts create mode 100644 packages/policy-gateway/__tests__/code-as-policy-guard.test.ts create mode 100644 packages/policy-gateway/__tests__/spatial-integrity-policy.test.ts create mode 100644 packages/policy-gateway/src/code-as-policy-guard.ts create mode 100644 packages/policy-gateway/src/spatial-integrity-policy.ts diff --git a/.gitignore b/.gitignore index aebfe947..33829ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ docs/*.pdf .DS_Store __pycache__/ *.pyc +.codex/ diff --git a/apps/sint-mcp/src/downstream.ts b/apps/sint-mcp/src/downstream.ts index 338d3000..c028fe48 100644 --- a/apps/sint-mcp/src/downstream.ts +++ b/apps/sint-mcp/src/downstream.ts @@ -72,7 +72,7 @@ export class DownstreamManager { } const client = new Client( - { name: `sint-mcp-client-${name}`, version: "0.1.0" }, + { name: `sint-mcp-client-${name}`, version: "0.1.1" }, { capabilities: {} }, ); diff --git a/apps/sint-mcp/src/server.ts b/apps/sint-mcp/src/server.ts index 9d8171f6..2dd8d0dd 100644 --- a/apps/sint-mcp/src/server.ts +++ b/apps/sint-mcp/src/server.ts @@ -79,7 +79,7 @@ export class SintMCPServer { // Create MCP Server this.server = new Server( - { name: "sint-mcp", version: "0.1.0" }, + { name: "sint-mcp", version: "0.1.1" }, { capabilities: { tools: {}, diff --git a/docs/COMPETITIVE-LANDSCAPE.md b/docs/COMPETITIVE-LANDSCAPE.md index 1acdb8e1..10bf4fb3 100644 --- a/docs/COMPETITIVE-LANDSCAPE.md +++ b/docs/COMPETITIVE-LANDSCAPE.md @@ -66,6 +66,38 @@ **Where SINT fits:** AutoGPT agents controlling physical tools/robots need SINT's gateway between their decisions and physical actions. +### Code-As-Policy Robot Agents + +**Examples:** Waddle-style systems that connect an API to a robot, take a +natural-language task, write an editable robot control program, and grow a +shared skill library from successful attempts. + +**What they do:** Move robot learning closer to the software-agent workflow: +agents decompose goals, inspect camera feedback, write and revise code, call +specialist action models, and execute physical primitives on real hardware. + +**Security posture:** Generated robot code and reusable skills create a new +authority boundary. The agent may safely revise software, but a changed +program, changed skill body, new primitive, or new workspace should not inherit +prior approval to move hardware. + +**Where SINT fits:** SINT sits below the robot-agent platform as the runtime +authorization and evidence layer. Generated programs stage through +`engine://system2/plan`; approved execution routes through +`engine://system2/execute`; reusable skills are content-bound through +`engine://capsule/skill-library/register`; physical primitives still resolve to +bridge resources such as ROS 2 actuation topics. + +**Gap SINT fills:** Content-digest binding for generated programs and skills, +T2/T3 review before actuation, human-workspace escalation, primitive vocabulary +constraints, and hash-chained receipts for every allow, deny, and escalation. + +Executable artifact: +`packages/conformance-tests/fixtures/physical-ai/code-as-policy-skill-guard.v1.json` + +Runtime guard: +`DefaultCodeAsPolicyGuard` in `@pshkv/gate-policy-gateway` + ## SINT's Unique Position SINT is **not a competing agent protocol**. It is an **execution-governance layer** that sits between agent protocols and real execution surfaces. This positioning means: diff --git a/docs/guides/code-as-policy-robot-agent-safety.md b/docs/guides/code-as-policy-robot-agent-safety.md new file mode 100644 index 00000000..a9070f00 --- /dev/null +++ b/docs/guides/code-as-policy-robot-agent-safety.md @@ -0,0 +1,143 @@ +# Code-As-Policy Robot Agent Safety + +This guide captures the SINT integration pattern for robot agents that write, +revise, and execute robot control programs. + +The motivating shape is Waddle-style robot agents: connect an API to a robot, +prompt the agent, let it produce an editable policy program, and grow a shared +skill library over time. SINT does not need to compete with that layer. It fits +under it as the runtime authorization and evidence boundary between generated +programs and physical actuation. + +## Fit + +Code-as-policy systems need at least three control points: + +- generated program staging before execution +- reusable skill registration with content digests +- physical primitive execution through robot middleware such as ROS 2 + +SINT already has the required surfaces: + +- `engine://system2/plan` for staging and reviewing generated programs +- `engine://capsule/skill-library/register` for content-bound skill promotion +- `engine://system2/execute` for reviewed generated-program execution +- ROS 2 resources such as + `ros2:///joint_trajectory_controller/follow_joint_trajectory` for actuator + commands +- `EvidenceLedger` receipts binding the agent, program digest, skill digest, + primitive set, hardware profile, policy decision, and hash-chain pointers +- `DefaultCodeAsPolicyGuard` in `@pshkv/gate-policy-gateway` for runtime + checks against generated-program digests, approved skill digests, primitive + allowlists, robot identity shape, and autonomous trial budgets + +## Run The Check + +```bash +pnpm --filter @pshkv/conformance-tests exec vitest run src/code-as-policy-skill-guard-conformance.test.ts +pnpm --filter @pshkv/gate-policy-gateway exec vitest run __tests__/code-as-policy-guard.test.ts +``` + +The fixture is: + +```text +packages/conformance-tests/fixtures/physical-ai/code-as-policy-skill-guard.v1.json +``` + +## Boundary + +The fixture uses a vendor-neutral code-as-policy boundary: + +- generated robot program: `T1_prepare` +- reusable skill registration: `T1_prepare`, content-bound +- generated program execution: `T2_act`, reviewed +- physical primitive actuation: `T2_act`, reviewed +- human detected in the workspace: escalates to `T3_commit` +- program body changed after approval: denied +- skill or primitive vocabulary changed after approval: denied +- autonomous data-collection and auto-research loops: bounded by trial budget + +## Receipt Shape + +Each receipt binds: + +- agent identity +- robot identities +- generated program reference and digest +- reusable skill reference and digest +- approved primitive set +- hardware profile +- workspace +- resource and operation +- assigned tier and decision +- decision digest +- evidence event hash and previous hash +- timestamp + +The practical goal is to make robot-agent iteration auditable. A reviewer +should be able to answer: which generated program or skill produced this +trajectory, which physical limits applied, which robot executed it, and whether +the program or skill changed after approval. + +## Integration Pattern + +1. The robot-agent platform emits a generated program artifact with a stable + digest. +2. SINT records program staging as `engine://system2/plan`. +3. Reusable skills are registered only with their digest, primitive set, and + hardware profile. +4. Before execution, the agent requests `engine://system2/execute` with the + approved program digest and skill digests. +5. Every physical primitive routes through the relevant bridge, commonly ROS 2. +6. If a digest, primitive set, workspace, or physical constraint changes, the + previous approval no longer applies. + +This keeps generated code useful and editable while preventing silent mutation +from becoming silent physical authority. + +## Runtime Guard + +Configure the gateway with an explicit primitive contract: + +```ts +import { DefaultCodeAsPolicyGuard, PolicyGateway } from "@pshkv/gate-policy-gateway"; + +const gateway = new PolicyGateway({ + resolveToken, + codeAsPolicyGuard: new DefaultCodeAsPolicyGuard({ + allowedPrimitives: [ + "bounding_box", + "detect_in_base", + "preset", + "approach_until", + "reset_home", + ], + maxTrialBudget: 1_000, + }), +}); +``` + +Requests for generated robot programs carry `params.codeAsPolicy` metadata: + +```json +{ + "codeAsPolicy": { + "programRef": "sint://program/code-policy/fold-shirt.py", + "programDigest": "digest:sha256:...", + "approvedProgramDigest": "digest:sha256:...", + "skillRef": "sint://skill/fold-grasp/v4", + "skillDigest": "digest:sha256:...", + "approvedSkillDigest": "digest:sha256:...", + "primitiveSetRef": "sint://primitive-set/manipulation-safe-v1", + "primitives": ["bounding_box", "detect_in_base", "approach_until"], + "robotIds": ["arm-left-01", "arm-right-01"], + "trialIndex": 12, + "trialBudget": 100 + } +} +``` + +The guard denies before tier assignment when metadata is missing, a digest no +longer matches the approved artifact, a new primitive appears, duplicate robot +IDs are present, or the trial loop exceeds its budget. Violations emit +`robot.code_policy.guard_violation`. diff --git a/docs/guides/spatial-integrity-policy.md b/docs/guides/spatial-integrity-policy.md new file mode 100644 index 00000000..936a9f8e --- /dev/null +++ b/docs/guides/spatial-integrity-policy.md @@ -0,0 +1,68 @@ +# Spatial Integrity Policy + +SINT's spatial integrity policy turns degraded localization from a demo-time +assumption into a deployment-time gate. It is intended for physical AI rollouts +where GPS, connectivity, perception, or map freshness cannot be treated as +ambient guarantees. + +The policy is opt-in through `PolicyGatewayConfig.spatialIntegrityPolicy`. It +runs after token validation and before normal tier assignment, so it can stop or +escalate physical actions before a bridge reaches ROS 2, MAVLink, Open-RMF, OPC +UA, or humanoid/robot control surfaces. + +## Default Profiles + +`DefaultSpatialIntegrityPolicy` includes three deployment profiles: + +| Profile | Use case | Default behavior | +|---|---|---| +| `gps-denied-indoor` | Warehouses, facilities, public-safety interiors | Requires pose, frame, fresh localization, confidence >= 0.7; escalates below 0.9 | +| `underground-inspection` | Tunnels, mines, basements, utility corridors | Requires pose, frame, fresh localization, confidence >= 0.75; escalates below 0.92 | +| `contested-airspace` | Degraded GNSS or adversarial RF environments | Requires pose, frame, fresh localization, confidence >= 0.8; escalates below 0.95 | + +Observe-only actions such as sensor subscriptions are not blocked by these +profiles. The policy applies to physical actions such as velocity commands, +joint commands, gripper/end-effector calls, MAVLink/PX4 commands, Open-RMF +actions, and industrial control resources. + +## Example + +```ts +import { + DefaultSpatialIntegrityPolicy, + PolicyGateway, +} from "@pshkv/gate-policy-gateway"; + +const gateway = new PolicyGateway({ + resolveToken, + spatialIntegrityPolicy: new DefaultSpatialIntegrityPolicy(), +}); +``` + +A GPS-denied physical request should carry localization evidence: + +```ts +{ + executionContext: { + deploymentProfile: "gps-denied-indoor" + }, + physicalContext: { + currentPosition: { x: 12.4, y: 3.1, z: 0 }, + frameId: "map:warehouse-a:v17", + localizationConfidence: 0.94, + localizationObservedAt: "2026-07-31T09:30:00.000000Z" + } +} +``` + +## Decision Model + +- Missing required position or frame evidence: deny. +- Missing or stale `localizationObservedAt`: deny. +- Confidence below `minLocalizationConfidence`: deny. +- Confidence below `minAutonomousLocalizationConfidence`: escalate to T2 human review. +- Fresh high-confidence evidence: continue through normal SINT tiering and token constraints. + +Token `executionEnvelope` spatial proof remains the stricter per-token control. +Use this deployment policy when an entire site profile should fail closed even +if a token was issued without explicit spatial proof requirements. diff --git a/docs/index.md b/docs/index.md index 08a6d074..9d72aeba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,6 +53,7 @@ limits, and tamper-evident audit evidence before execution. - NIST submission playbook: [Guide](./guides/nist-submission-playbook.md) - Mission Authority reference gateway: [Guide](./guides/mission-authority-reference-gateway.md) - Regulated agent runtime quickstart: [Guide](./guides/regulated-agent-runtime-quickstart.md) +- Spatial integrity policy: [Guide](./guides/spatial-integrity-policy.md) - Community launch runbook: [Discord Launch](./community/discord-launch-runbook.md) - AAIF RFC-001 submission packet: [Community/AAIF Packet](./community/aaif-rfc001-submission-packet.md) - Discord launch kit: [Community/Discord Launch Kit](./community/discord-launch-kit.md) @@ -61,6 +62,7 @@ limits, and tamper-evident audit evidence before execution. - Physical AI runtime safety working group: [Community/Working Group](./community/physical-ai-runtime-safety-working-group.md) - Industrial humanoid shipyard safety pack: [Guide](./guides/industrial-humanoid-shipyard-safety-pack.md) - Industrial humanoid shipyard safety sprint: [Roadmap](./roadmaps/industrial-humanoid-shipyard-safety-sprint.md) +- Code-as-policy robot agent safety: [Guide](./guides/code-as-policy-robot-agent-safety.md) - Shipyard humanoid evidence export sample: `docs/reports/shipyard-humanoid-evidence-export.jsonl` - OWASP Agentic Landscape submission packet: [Community/OWASP Packet](./community/owasp-agentic-landscape-submission.md) - EU AI Act mapping: [Compliance/EU AI Act](./compliance/eu-ai-act-mapping.md) diff --git a/docs/social/launch-checklist.md b/docs/social/launch-checklist.md index 932ba53c..e7d24ba2 100644 --- a/docs/social/launch-checklist.md +++ b/docs/social/launch-checklist.md @@ -6,10 +6,12 @@ Day-of sequence. Work top to bottom — each step depends on the previous. ## Pre-Launch (Do First) -- [ ] `git pull --rebase` — confirm on latest master +- [ ] `git pull --rebase` — confirm on latest `main` - [ ] `pnpm run build && pnpm run test` — all 1,105 tests pass - [ ] `pnpm run demo:interceptor-quickstart` — demo transcript prints allow, escalate, and fail-closed paths - [ ] `pnpm run docs:build` — quickstart guide resolves cleanly in docs site +- [ ] `npm view sint-mcp version` — public npm version matches `.mcp/server.json` +- [ ] `npx -y sint-mcp --help` — published package prints CLI help from a clean directory --- diff --git a/packages/conformance-tests/fixtures/physical-ai/code-as-policy-skill-guard.v1.json b/packages/conformance-tests/fixtures/physical-ai/code-as-policy-skill-guard.v1.json new file mode 100644 index 00000000..893d70ec --- /dev/null +++ b/packages/conformance-tests/fixtures/physical-ai/code-as-policy-skill-guard.v1.json @@ -0,0 +1,215 @@ +{ + "fixtureId": "code-as-policy-skill-guard-v1", + "schemaVersion": "1.0.0", + "description": "Project-neutral fixture for guarding agent-generated robot programs, reusable skills, and physical primitive execution.", + "scope": { + "bridge": "ros2", + "projectContext": "code-as-policy-robot-agent", + "boundary": "agent-generated robot program to physical actuation", + "goal": "Test whether a robot agent that writes and revises control programs can stage code, register reusable skills, and execute primitives only through SINT-governed receipts.", + "nonGoal": "This fixture does not depend on a specific vendor API, model family, robot embodiment, or training stack." + }, + "requirements": { + "generatedProgramsArePrepareTier": true, + "skillLibraryMutationRequiresContentBinding": true, + "primitiveExecutionRoutesThroughGateway": true, + "concurrentRobotCoordinationRequiresReceipts": true, + "postApprovalProgramMutationDenied": true, + "negativeOutcomesCarryReceipt": true + }, + "deployment": { + "siteId": "robot-agent-lab-01", + "agentId": "robot-agent-master-01", + "robotIds": ["arm-left-01", "arm-right-01"], + "programRef": "sint://program/code-as-policy/robot-agent-lab-01/fold-shirt-7782.py", + "programDigest": "digest:sha256:1111111111111111111111111111111111111111111111111111111111111111", + "skillRef": "sint://skill/code-as-policy/fold-grasp/v4", + "skillDigest": "digest:sha256:2222222222222222222222222222222222222222222222222222222222222222", + "primitiveSetRef": "sint://primitive-set/manipulation-safe-v1", + "hardwareProfileRef": "sint://hardware/dual-arm-tabletop/v1", + "workspaceId": "bench-cell-a" + }, + "receiptSchema": { + "requiredFields": [ + "receiptId", + "actionRef", + "resource", + "operation", + "decision", + "assignedTier", + "agentId", + "robotIds", + "programRef", + "programDigest", + "skillRef", + "skillDigest", + "primitiveSetRef", + "hardwareProfileRef", + "workspaceId", + "decisionDigest", + "eventHash", + "previousHash", + "timestamp" + ], + "sample": { + "receiptId": "code-policy:robot-agent-lab-01:fold-shirt-7782", + "actionRef": "action:code-policy:execute:robot-agent-lab-01:fold-shirt-7782", + "resource": "ros2:///joint_trajectory_controller/follow_joint_trajectory", + "operation": "publish", + "decision": "escalate", + "assignedTier": "T2_act", + "agentId": "robot-agent-master-01", + "robotIds": "arm-left-01,arm-right-01", + "programRef": "sint://program/code-as-policy/robot-agent-lab-01/fold-shirt-7782.py", + "programDigest": "digest:sha256:1111111111111111111111111111111111111111111111111111111111111111", + "skillRef": "sint://skill/code-as-policy/fold-grasp/v4", + "skillDigest": "digest:sha256:2222222222222222222222222222222222222222222222222222222222222222", + "primitiveSetRef": "sint://primitive-set/manipulation-safe-v1", + "hardwareProfileRef": "sint://hardware/dual-arm-tabletop/v1", + "workspaceId": "bench-cell-a", + "decisionDigest": "digest:sha256:3333333333333333333333333333333333333333333333333333333333333333", + "eventHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "previousHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "timestamp": "2026-07-28T17:00:00.000Z" + } + }, + "mappingCases": [ + { + "id": "generated_program_routes_through_prepare_boundary", + "name": "Generated robot program is staged before execution", + "resourceSource": "engine", + "resource": "engine://system2/plan", + "operation": "generate", + "expectedResource": "engine://system2/plan", + "expectedTier": "T1_prepare", + "receiptRequired": true + }, + { + "id": "skill_registration_routes_through_capsule_boundary", + "name": "Reusable skill registration is content-bound", + "resourceSource": "capsule", + "resource": "engine://capsule/skill-library/register", + "operation": "register", + "expectedResource": "engine://capsule/skill-library/register", + "expectedTier": "T1_prepare", + "receiptRequired": true + }, + { + "id": "program_execution_routes_through_engine_boundary", + "name": "Approved generated program executes through the engine boundary", + "resourceSource": "engine", + "resource": "engine://system2/execute", + "operation": "execute", + "expectedResource": "engine://system2/execute", + "expectedTier": "T2_act", + "receiptRequired": true + }, + { + "id": "primitive_actuation_routes_through_ros2_boundary", + "name": "Trajectory primitive resolves to the ROS 2 actuation boundary", + "resourceSource": "topic", + "topicName": "/joint_trajectory_controller/follow_joint_trajectory", + "operation": "publish", + "expectedResource": "ros2:///joint_trajectory_controller/follow_joint_trajectory", + "expectedTier": "T2_act", + "receiptRequired": true + } + ], + "policyCases": [ + { + "id": "program_generation_is_prepare_tier", + "name": "Generated program staging stays below physical execution", + "resource": "engine://system2/plan", + "operation": "generate", + "expectedDecision": "allow", + "expectedTier": "T1_prepare", + "receiptRequired": true + }, + { + "id": "skill_registration_requires_matching_digest", + "name": "Reusable skill registration is accepted only with matching content digest", + "resource": "engine://capsule/skill-library/register", + "operation": "register", + "expectedDecision": "allow", + "expectedTier": "T1_prepare", + "programDigestMatches": true, + "skillDigestMatches": true, + "receiptRequired": true + }, + { + "id": "generated_program_execution_requires_review", + "name": "Executing generated code remains high consequence", + "resource": "engine://system2/execute", + "operation": "execute", + "expectedDecision": "escalate", + "expectedTier": "T2_act", + "constraints": { + "maxVelocityMps": 0.2, + "maxForceNewtons": 80, + "allowedPrimitives": ["bounding_box", "detect_in_base", "preset", "approach_until", "reset_home"] + }, + "receiptRequired": true + }, + { + "id": "concurrent_robot_primitive_execution_requires_receipts", + "name": "Coordinated dual-arm actuation carries one receipt per robot path", + "resource": "ros2:///joint_trajectory_controller/follow_joint_trajectory", + "operation": "publish", + "expectedDecision": "escalate", + "expectedTier": "T2_act", + "constraints": { + "maxVelocityMps": 0.18, + "maxForceNewtons": 70 + }, + "receiptRequired": true + }, + { + "id": "human_workspace_escalates_generated_trajectory", + "name": "Human presence raises generated-code trajectory execution consequence", + "resource": "ros2:///joint_trajectory_controller/follow_joint_trajectory", + "operation": "publish", + "expectedDecision": "escalate", + "expectedTier": "T3_commit", + "physicalContext": { + "humanDetected": true, + "currentVelocityMps": 0.06, + "currentForceNewtons": 22 + }, + "receiptRequired": true + }, + { + "id": "program_mutation_without_reapproval_denied", + "name": "Reject a generated program body change after approval", + "resource": "engine://system2/execute", + "operation": "execute", + "expectedDecision": "deny", + "expectedTier": "T2_act", + "programDigestMatches": false, + "skillDigestMatches": true, + "policyViolated": "CONSTRAINT_VIOLATION", + "receiptRequired": true + }, + { + "id": "new_primitive_after_approval_denied", + "name": "Reject a newly introduced primitive that was not approved", + "resource": "ros2:///joint_trajectory_controller/follow_joint_trajectory", + "operation": "publish", + "expectedDecision": "deny", + "expectedTier": "T2_act", + "constraints": { + "allowedPrimitives": ["bounding_box", "detect_in_base", "preset", "approach_until", "reset_home"] + }, + "skillDigestMatches": false, + "policyViolated": "FORBIDDEN_COMBINATION", + "receiptRequired": true + } + ], + "successCriteria": { + "generatedProgramStagingIsPrepareTier": true, + "generatedSkillRegistrationIsContentBound": true, + "physicalPrimitiveExecutionIsHighConsequence": true, + "concurrentRobotActuationCarriesReceipts": true, + "programMutationWithoutReapprovalDenied": true, + "allOutcomesCarryReceipts": true + } +} diff --git a/packages/conformance-tests/src/code-as-policy-skill-guard-conformance.test.ts b/packages/conformance-tests/src/code-as-policy-skill-guard-conformance.test.ts new file mode 100644 index 00000000..9e133826 --- /dev/null +++ b/packages/conformance-tests/src/code-as-policy-skill-guard-conformance.test.ts @@ -0,0 +1,165 @@ +/** + * Code-as-policy robot-agent skill guard conformance. + * + * This covers the Waddle-shaped pattern without binding SINT to a vendor API: + * an agent writes robot code, promotes reusable skills, and executes physical + * primitives only through gateway-backed receipts. + */ + +import { describe, expect, it } from "vitest"; +import { ApprovalTier } from "@pshkv/core"; +import { topicToResourceUri } from "@pshkv/bridge-ros2"; +import { loadCodeAsPolicySkillGuardFixture } from "./fixture-loader.js"; + +describe("Code-as-policy skill guard fixture v1", () => { + const fixture = loadCodeAsPolicySkillGuardFixture(); + + it("declares a vendor-neutral robot-agent scope", () => { + expect(fixture.fixtureId).toBe("code-as-policy-skill-guard-v1"); + expect(fixture.scope).toEqual({ + bridge: "ros2", + projectContext: "code-as-policy-robot-agent", + boundary: "agent-generated robot program to physical actuation", + goal: "Test whether a robot agent that writes and revises control programs can stage code, register reusable skills, and execute primitives only through SINT-governed receipts.", + nonGoal: "This fixture does not depend on a specific vendor API, model family, robot embodiment, or training stack.", + }); + expect(fixture.requirements).toEqual({ + generatedProgramsArePrepareTier: true, + skillLibraryMutationRequiresContentBinding: true, + primitiveExecutionRoutesThroughGateway: true, + concurrentRobotCoordinationRequiresReceipts: true, + postApprovalProgramMutationDenied: true, + negativeOutcomesCarryReceipt: true, + }); + }); + + it("defines receipt fields that bind generated code, skills, primitives, and hardware", () => { + const { sample } = fixture.receiptSchema; + + for (const field of fixture.receiptSchema.requiredFields) { + expect(sample[field], field).toBeTruthy(); + } + + expect(sample.agentId).toBe(fixture.deployment.agentId); + expect(sample.robotIds).toBe(fixture.deployment.robotIds.join(",")); + expect(sample.programRef).toBe(fixture.deployment.programRef); + expect(sample.programDigest).toBe(fixture.deployment.programDigest); + expect(sample.skillRef).toBe(fixture.deployment.skillRef); + expect(sample.skillDigest).toBe(fixture.deployment.skillDigest); + expect(sample.primitiveSetRef).toBe(fixture.deployment.primitiveSetRef); + expect(sample.hardwareProfileRef).toBe(fixture.deployment.hardwareProfileRef); + expect(sample.workspaceId).toBe(fixture.deployment.workspaceId); + expect(sample.decisionDigest).toMatch(/^digest:sha256:[a-f0-9]{64}$/); + expect(sample.eventHash).toMatch(/^[a-f0-9]{64}$/); + expect(sample.previousHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it("derives expected resources from engine, capsule, and ROS 2 boundaries", () => { + for (const item of fixture.mappingCases) { + if (item.resourceSource === "engine" || item.resourceSource === "capsule") { + expect(item.resource, item.id).toBe(item.expectedResource); + } + + if (item.resourceSource === "topic") { + expect(item.topicName, item.id).toBeDefined(); + expect(topicToResourceUri(item.topicName ?? ""), item.id).toBe(item.expectedResource); + } + } + }); + + it("keeps generated program and skill staging below live execution", () => { + const program = fixture.policyCases.find( + (item) => item.id === "program_generation_is_prepare_tier", + ); + const skill = fixture.policyCases.find( + (item) => item.id === "skill_registration_requires_matching_digest", + ); + const execute = fixture.policyCases.find( + (item) => item.id === "generated_program_execution_requires_review", + ); + + expect(program?.resource).toBe("engine://system2/plan"); + expect(program?.expectedDecision).toBe("allow"); + expect(program?.expectedTier).toBe(ApprovalTier.T1_PREPARE); + + expect(skill?.resource).toBe("engine://capsule/skill-library/register"); + expect(skill?.expectedDecision).toBe("allow"); + expect(skill?.expectedTier).toBe(ApprovalTier.T1_PREPARE); + expect(skill?.programDigestMatches).toBe(true); + expect(skill?.skillDigestMatches).toBe(true); + + expect(execute?.resource).toBe("engine://system2/execute"); + expect(execute?.expectedDecision).toBe("escalate"); + expect(execute?.expectedTier).toBe(ApprovalTier.T2_ACT); + expect(execute?.constraints).toEqual({ + maxVelocityMps: 0.2, + maxForceNewtons: 80, + allowedPrimitives: ["bounding_box", "detect_in_base", "preset", "approach_until", "reset_home"], + }); + }); + + it("models coordinated primitive actuation and human-workspace escalation", () => { + const coordinated = fixture.policyCases.find( + (item) => item.id === "concurrent_robot_primitive_execution_requires_receipts", + ); + const human = fixture.policyCases.find( + (item) => item.id === "human_workspace_escalates_generated_trajectory", + ); + + expect(coordinated?.resource).toBe("ros2:///joint_trajectory_controller/follow_joint_trajectory"); + expect(coordinated?.expectedDecision).toBe("escalate"); + expect(coordinated?.expectedTier).toBe(ApprovalTier.T2_ACT); + expect(coordinated?.constraints).toEqual({ + maxVelocityMps: 0.18, + maxForceNewtons: 70, + }); + + expect(human?.resource).toBe("ros2:///joint_trajectory_controller/follow_joint_trajectory"); + expect(human?.expectedDecision).toBe("escalate"); + expect(human?.expectedTier).toBe(ApprovalTier.T3_COMMIT); + expect(human?.physicalContext).toEqual({ + humanDetected: true, + currentVelocityMps: 0.06, + currentForceNewtons: 22, + }); + }); + + it("rejects generated-code or skill mutations after approval", () => { + const programMutation = fixture.policyCases.find( + (item) => item.id === "program_mutation_without_reapproval_denied", + ); + const primitiveMutation = fixture.policyCases.find( + (item) => item.id === "new_primitive_after_approval_denied", + ); + + expect(programMutation?.expectedDecision).toBe("deny"); + expect(programMutation?.expectedTier).toBe(ApprovalTier.T2_ACT); + expect(programMutation?.programDigestMatches).toBe(false); + expect(programMutation?.skillDigestMatches).toBe(true); + expect(programMutation?.policyViolated).toBe("CONSTRAINT_VIOLATION"); + + expect(primitiveMutation?.expectedDecision).toBe("deny"); + expect(primitiveMutation?.expectedTier).toBe(ApprovalTier.T2_ACT); + expect(primitiveMutation?.skillDigestMatches).toBe(false); + expect(primitiveMutation?.policyViolated).toBe("FORBIDDEN_COMBINATION"); + }); + + it("keeps all outcomes receipt-backed", () => { + expect(fixture.successCriteria).toEqual({ + generatedProgramStagingIsPrepareTier: true, + generatedSkillRegistrationIsContentBound: true, + physicalPrimitiveExecutionIsHighConsequence: true, + concurrentRobotActuationCarriesReceipts: true, + programMutationWithoutReapprovalDenied: true, + allOutcomesCarryReceipts: true, + }); + + for (const item of fixture.mappingCases) { + expect(item.receiptRequired, item.id).toBe(true); + } + + for (const item of fixture.policyCases) { + expect(item.receiptRequired, item.id).toBe(true); + } + }); +}); diff --git a/packages/conformance-tests/src/fixture-loader.ts b/packages/conformance-tests/src/fixture-loader.ts index 34fdf1da..086a6784 100644 --- a/packages/conformance-tests/src/fixture-loader.ts +++ b/packages/conformance-tests/src/fixture-loader.ts @@ -1616,6 +1616,90 @@ export function loadLeRobotPolicyActuationReceiptsFixture(): LeRobotPolicyActuat ); } +export interface CodeAsPolicySkillGuardFixture { + readonly fixtureId: string; + readonly schemaVersion: string; + readonly description: string; + readonly scope: { + readonly bridge: "ros2"; + readonly projectContext: "code-as-policy-robot-agent"; + readonly boundary: "agent-generated robot program to physical actuation"; + readonly goal: string; + readonly nonGoal: string; + }; + readonly requirements: { + readonly generatedProgramsArePrepareTier: boolean; + readonly skillLibraryMutationRequiresContentBinding: boolean; + readonly primitiveExecutionRoutesThroughGateway: boolean; + readonly concurrentRobotCoordinationRequiresReceipts: boolean; + readonly postApprovalProgramMutationDenied: boolean; + readonly negativeOutcomesCarryReceipt: boolean; + }; + readonly deployment: { + readonly siteId: string; + readonly agentId: string; + readonly robotIds: readonly string[]; + readonly programRef: string; + readonly programDigest: string; + readonly skillRef: string; + readonly skillDigest: string; + readonly primitiveSetRef: string; + readonly hardwareProfileRef: string; + readonly workspaceId: string; + }; + readonly receiptSchema: { + readonly requiredFields: readonly string[]; + readonly sample: Record; + }; + readonly mappingCases: readonly Array<{ + readonly id: string; + readonly name: string; + readonly resourceSource: "engine" | "capsule" | "topic"; + readonly resource?: string; + readonly topicName?: string; + readonly operation: "generate" | "register" | "execute" | "publish"; + readonly expectedResource: string; + readonly expectedTier: ApprovalTier; + readonly receiptRequired: boolean; + }>; + readonly policyCases: readonly Array<{ + readonly id: string; + readonly name: string; + readonly resource: string; + readonly operation: "generate" | "register" | "execute" | "publish"; + readonly expectedDecision: DecisionAction; + readonly expectedTier: ApprovalTier; + readonly constraints?: { + readonly maxVelocityMps?: number; + readonly maxForceNewtons?: number; + readonly allowedPrimitives?: readonly string[]; + }; + readonly physicalContext?: { + readonly humanDetected?: boolean; + readonly currentVelocityMps?: number; + readonly currentForceNewtons?: number; + }; + readonly programDigestMatches?: boolean; + readonly skillDigestMatches?: boolean; + readonly policyViolated?: "CONSTRAINT_VIOLATION" | "FORBIDDEN_COMBINATION"; + readonly receiptRequired: boolean; + }>; + readonly successCriteria: { + readonly generatedProgramStagingIsPrepareTier: boolean; + readonly generatedSkillRegistrationIsContentBound: boolean; + readonly physicalPrimitiveExecutionIsHighConsequence: boolean; + readonly concurrentRobotActuationCarriesReceipts: boolean; + readonly programMutationWithoutReapprovalDenied: boolean; + readonly allOutcomesCarryReceipts: boolean; + }; +} + +export function loadCodeAsPolicySkillGuardFixture(): CodeAsPolicySkillGuardFixture { + return loadFixture( + "physical-ai/code-as-policy-skill-guard.v1.json", + ); +} + export interface SolarFieldOperationsPolicyReceiptsFixture { readonly fixtureId: string; readonly schemaVersion: string; diff --git a/packages/policy-gateway/__tests__/code-as-policy-guard.test.ts b/packages/policy-gateway/__tests__/code-as-policy-guard.test.ts new file mode 100644 index 00000000..9068d9ef --- /dev/null +++ b/packages/policy-gateway/__tests__/code-as-policy-guard.test.ts @@ -0,0 +1,247 @@ +/** + * SINT code-as-policy robot-agent guard tests. + */ + +import { describe, expect, it, vi } from "vitest"; +import { ApprovalTier, type SintCapabilityToken, type SintRequest } from "@pshkv/core"; +import { + generateKeypair, + issueCapabilityToken, +} from "@pshkv/gate-capability-tokens"; +import { PolicyGateway } from "../src/gateway.js"; +import { + DefaultCodeAsPolicyGuard, + type CodeAsPolicyGuardPlugin, +} from "../src/code-as-policy-guard.js"; + +const root = generateKeypair(); +const agent = generateKeypair(); +const VALID_PROGRAM_DIGEST = `digest:sha256:${"1".repeat(64)}`; +const VALID_SKILL_DIGEST = `digest:sha256:${"2".repeat(64)}`; + +function futureISO(h = 1): string { + return new Date(Date.now() + h * 3_600_000) + .toISOString() + .replace(/\.(\d{3})Z$/, ".$1000Z"); +} + +function makeToken(overrides: Partial[0]> = {}): SintCapabilityToken { + const result = issueCapabilityToken( + { + issuer: root.publicKey, + subject: agent.publicKey, + resource: "engine://system2/execute", + actions: ["execute"], + constraints: { + maxVelocityMps: 0.2, + maxForceNewtons: 80, + }, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt: futureISO(), + revocable: false, + ...overrides, + }, + root.privateKey, + ); + if (!result.ok) throw new Error(`token issuance failed: ${result.error}`); + return result.value; +} + +let seq = 0; +function makeRequest( + token: SintCapabilityToken, + overrides: Partial = {}, +): SintRequest { + const suffix = String(++seq).padStart(4, "0"); + return { + requestId: `01905f7c-4e8a-7b3d-9a1e-f2c3d4e5${suffix}` as any, + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + agentId: agent.publicKey, + tokenId: token.tokenId, + resource: token.resource, + action: token.actions[0] ?? "execute", + params: { + codeAsPolicy: { + programRef: "sint://program/code-policy/fold-shirt.py", + programDigest: VALID_PROGRAM_DIGEST, + approvedProgramDigest: VALID_PROGRAM_DIGEST, + skillRef: "sint://skill/fold-grasp/v4", + skillDigest: VALID_SKILL_DIGEST, + approvedSkillDigest: VALID_SKILL_DIGEST, + primitiveSetRef: "sint://primitive-set/manipulation-safe-v1", + primitives: ["bounding_box", "detect_in_base", "preset", "approach_until", "reset_home"], + robotIds: ["arm-left-01", "arm-right-01"], + trialRef: "sint://trial/fold-shirt/001", + trialIndex: 12, + trialBudget: 100, + }, + }, + ...overrides, + }; +} + +function makeGuard() { + return new DefaultCodeAsPolicyGuard({ + allowedPrimitives: ["bounding_box", "detect_in_base", "preset", "approach_until", "reset_home"], + maxTrialBudget: 1_000, + }); +} + +describe("DefaultCodeAsPolicyGuard", () => { + it("verifies matching generated-program and skill digests", () => { + const token = makeToken(); + const request = makeRequest(token); + const result = makeGuard().verify(request, token); + + expect(result.verified).toBe(true); + expect(result.violations).toHaveLength(0); + expect(result.metadata?.programDigest).toBe(VALID_PROGRAM_DIGEST); + expect(result.metadata?.skillDigest).toBe(VALID_SKILL_DIGEST); + }); + + it("requires metadata for code-as-policy resources", () => { + const token = makeToken(); + const request = makeRequest(token, { params: {} }); + const result = makeGuard().verify(request, token); + + expect(result.verified).toBe(false); + expect(result.severity).toBe("high"); + expect(result.violations).toContain("codeAsPolicy metadata is required for generated robot program requests"); + }); + + it("detects program body mutation after approval", () => { + const token = makeToken(); + const request = makeRequest(token, { + params: { + codeAsPolicy: { + programDigest: `digest:sha256:${"3".repeat(64)}`, + approvedProgramDigest: VALID_PROGRAM_DIGEST, + primitives: ["bounding_box"], + }, + }, + }); + + const result = makeGuard().verify(request, token); + + expect(result.verified).toBe(false); + expect(result.violations).toContain("programDigest does not match approvedProgramDigest"); + }); + + it("detects skill mutation and unapproved primitive introduction", () => { + const token = makeToken(); + const request = makeRequest(token, { + params: { + codeAsPolicy: { + programDigest: VALID_PROGRAM_DIGEST, + approvedProgramDigest: VALID_PROGRAM_DIGEST, + skillRef: "sint://skill/fold-grasp/v4", + skillDigest: `digest:sha256:${"4".repeat(64)}`, + approvedSkillDigest: VALID_SKILL_DIGEST, + primitives: ["bounding_box", "raw_joint_override"], + }, + }, + }); + + const result = makeGuard().verify(request, token); + + expect(result.verified).toBe(false); + expect(result.violations).toContain("skillDigest does not match approvedSkillDigest"); + expect(result.violations).toContain("unapproved primitives: raw_joint_override"); + }); + + it("detects trial budget exhaustion for autonomous data collection loops", () => { + const token = makeToken(); + const request = makeRequest(token, { + params: { + codeAsPolicy: { + programDigest: VALID_PROGRAM_DIGEST, + primitives: ["bounding_box"], + trialIndex: 101, + trialBudget: 100, + }, + }, + }); + + const result = makeGuard().verify(request, token); + + expect(result.verified).toBe(false); + expect(result.violations).toContain("trialIndex 101 exceeds trialBudget 100"); + }); +}); + +describe("CodeAsPolicyGuard — Gateway integration", () => { + it("denies mutated generated-program execution before tier decision", async () => { + const token = makeToken(); + const tokenStore = new Map([[token.tokenId, token]]); + const emitSpy = vi.fn(); + + const gateway = new PolicyGateway({ + resolveToken: (id) => tokenStore.get(id), + emitLedgerEvent: emitSpy, + codeAsPolicyGuard: makeGuard(), + }); + + const decision = await gateway.intercept( + makeRequest(token, { + params: { + codeAsPolicy: { + programDigest: `digest:sha256:${"3".repeat(64)}`, + approvedProgramDigest: VALID_PROGRAM_DIGEST, + primitives: ["bounding_box"], + }, + }, + }), + ); + + expect(decision.action).toBe("deny"); + expect(decision.assignedTier).toBe(ApprovalTier.T2_ACT); + expect(decision.denial?.policyViolated).toBe("CONSTRAINT_VIOLATION"); + + const guardEvent = emitSpy.mock.calls.find( + (call) => call[0]?.eventType === "robot.code_policy.guard_violation", + ); + expect(guardEvent).toBeDefined(); + }); + + it("allows verified T1 generated-program staging through normal gateway flow", async () => { + const token = makeToken({ + resource: "engine://system2/plan", + actions: ["generate"], + constraints: {}, + }); + const tokenStore = new Map([[token.tokenId, token]]); + const gateway = new PolicyGateway({ + resolveToken: (id) => tokenStore.get(id), + codeAsPolicyGuard: makeGuard(), + }); + + const decision = await gateway.intercept( + makeRequest(token, { + resource: "engine://system2/plan", + action: "generate", + }), + ); + + expect(decision.action).toBe("allow"); + expect(decision.assignedTier).toBe(ApprovalTier.T1_PREPARE); + }); + + it("fails closed when guard throws", async () => { + const token = makeToken(); + const tokenStore = new Map([[token.tokenId, token]]); + const brokenGuard: CodeAsPolicyGuardPlugin = { + verify: () => { + throw new Error("guard unavailable"); + }, + }; + const gateway = new PolicyGateway({ + resolveToken: (id) => tokenStore.get(id), + codeAsPolicyGuard: brokenGuard, + }); + + const decision = await gateway.intercept(makeRequest(token)); + + expect(decision.action).toBe("deny"); + expect(decision.denial?.policyViolated).toBe("CODE_AS_POLICY_GUARD_ERROR"); + }); +}); diff --git a/packages/policy-gateway/__tests__/spatial-integrity-policy.test.ts b/packages/policy-gateway/__tests__/spatial-integrity-policy.test.ts new file mode 100644 index 00000000..aecaf2f5 --- /dev/null +++ b/packages/policy-gateway/__tests__/spatial-integrity-policy.test.ts @@ -0,0 +1,189 @@ +/** + * SINT Protocol — Spatial integrity policy tests. + */ + +import { describe, expect, it, vi } from "vitest"; +import { + ApprovalTier, + type SintCapabilityToken, + type SintRequest, +} from "@pshkv/core"; +import { + generateKeypair, + issueCapabilityToken, +} from "@pshkv/gate-capability-tokens"; +import { PolicyGateway } from "../src/gateway.js"; +import { DefaultSpatialIntegrityPolicy } from "../src/spatial-integrity-policy.js"; + +const root = generateKeypair(); +const agent = generateKeypair(); + +function isoOffset(ms: number): string { + return new Date(Date.now() + ms) + .toISOString() + .replace(/\.(\d{3})Z$/, ".$1000Z"); +} + +function makeToken(): SintCapabilityToken { + const result = issueCapabilityToken( + { + issuer: root.publicKey, + subject: agent.publicKey, + resource: "ros2:///cmd_vel", + actions: ["publish"], + constraints: { + maxVelocityMps: 1.0, + }, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt: isoOffset(3_600_000), + revocable: false, + }, + root.privateKey, + ); + if (!result.ok) throw new Error(`token issuance failed: ${result.error}`); + return result.value; +} + +function makeRequest( + token: SintCapabilityToken, + overrides: Partial = {}, +): SintRequest { + return { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e5f000", + timestamp: isoOffset(0), + agentId: agent.publicKey, + tokenId: token.tokenId, + resource: "ros2:///cmd_vel", + action: "publish", + params: { velocity: 0.2 }, + physicalContext: { + currentVelocityMps: 0.2, + currentPosition: { x: 1, y: 2, z: 0 }, + frameId: "map:warehouse-a", + localizationConfidence: 0.96, + localizationObservedAt: isoOffset(0), + }, + executionContext: { + deploymentProfile: "gps-denied-indoor", + }, + ...overrides, + }; +} + +function makeGateway(token: SintCapabilityToken, emitLedgerEvent = vi.fn()) { + return new PolicyGateway({ + resolveToken: () => token, + emitLedgerEvent, + spatialIntegrityPolicy: new DefaultSpatialIntegrityPolicy(), + }); +} + +describe("DefaultSpatialIntegrityPolicy", () => { + it("denies GPS-denied physical actions missing a localization frame", async () => { + const token = makeToken(); + const gateway = makeGateway(token); + + const decision = await gateway.intercept( + makeRequest(token, { + physicalContext: { + currentVelocityMps: 0.2, + currentPosition: { x: 1, y: 2, z: 0 }, + localizationConfidence: 0.96, + localizationObservedAt: isoOffset(0), + }, + }), + ); + + expect(decision.action).toBe("deny"); + expect(decision.denial?.policyViolated).toBe("SPATIAL_FRAME_REQUIRED"); + }); + + it("denies stale localization evidence in GPS-denied deployments", async () => { + const token = makeToken(); + const gateway = makeGateway(token); + + const decision = await gateway.intercept( + makeRequest(token, { + physicalContext: { + currentVelocityMps: 0.2, + currentPosition: { x: 1, y: 2, z: 0 }, + frameId: "map:warehouse-a", + localizationConfidence: 0.96, + localizationObservedAt: isoOffset(-5_000), + }, + }), + ); + + expect(decision.action).toBe("deny"); + expect(decision.denial?.policyViolated).toBe("SPATIAL_PROOF_STALE"); + }); + + it("escalates degraded-but-usable localization below autonomous threshold", async () => { + const token = makeToken(); + const emitLedgerEvent = vi.fn(); + const gateway = makeGateway(token, emitLedgerEvent); + + const decision = await gateway.intercept( + makeRequest(token, { + physicalContext: { + currentVelocityMps: 0.2, + currentPosition: { x: 1, y: 2, z: 0 }, + frameId: "map:warehouse-a", + localizationConfidence: 0.82, + localizationObservedAt: isoOffset(0), + }, + }), + ); + + expect(decision.action).toBe("escalate"); + expect(decision.assignedTier).toBe(ApprovalTier.T2_ACT); + expect(decision.escalation?.reason).toContain("below autonomous threshold"); + expect( + emitLedgerEvent.mock.calls.some( + (call) => call[0]?.payload?.source === "spatial_integrity_policy", + ), + ).toBe(true); + }); + + it("lets fresh high-confidence localization continue to normal tiering", async () => { + const token = makeToken(); + const gateway = makeGateway(token); + + const decision = await gateway.intercept(makeRequest(token)); + + expect(decision.action).toBe("escalate"); + expect(decision.assignedTier).toBe(ApprovalTier.T2_ACT); + expect(decision.escalation?.reason).toBe("Action requires human review (T2_act)"); + }); + + it("does not block observe-only resources for the deployment profile", async () => { + const result = issueCapabilityToken( + { + issuer: root.publicKey, + subject: agent.publicKey, + resource: "ros2:///camera/front", + actions: ["subscribe"], + constraints: {}, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt: isoOffset(3_600_000), + revocable: false, + }, + root.privateKey, + ); + if (!result.ok) throw new Error(`token issuance failed: ${result.error}`); + const token = result.value; + const gateway = makeGateway(token); + + const decision = await gateway.intercept( + makeRequest(token, { + resource: "ros2:///camera/front", + action: "subscribe", + params: {}, + physicalContext: undefined, + }), + ); + + expect(decision.action).toBe("allow"); + expect(decision.assignedTier).toBe(ApprovalTier.T0_OBSERVE); + }); +}); diff --git a/packages/policy-gateway/src/code-as-policy-guard.ts b/packages/policy-gateway/src/code-as-policy-guard.ts new file mode 100644 index 00000000..c9f65eb2 --- /dev/null +++ b/packages/policy-gateway/src/code-as-policy-guard.ts @@ -0,0 +1,229 @@ +/** + * SINT Protocol — code-as-policy robot-agent guard. + * + * Guards the hierarchy used by robot coding agents: + * fixed primitives -> agent-created skills -> generated task programs. + */ + +import { ApprovalTier, RiskTier, type PolicyDecision, type SintCapabilityToken, type SintRequest } from "@pshkv/core"; + +export interface CodeAsPolicyMetadata { + readonly programRef?: string; + readonly programDigest?: string; + readonly approvedProgramDigest?: string; + readonly skillRef?: string; + readonly skillDigest?: string; + readonly approvedSkillDigest?: string; + readonly primitiveSetRef?: string; + readonly primitives?: readonly string[]; + readonly robotIds?: readonly string[]; + readonly trialRef?: string; + readonly trialIndex?: number; + readonly trialBudget?: number; +} + +export interface CodeAsPolicyGuardResult { + readonly verified: boolean; + readonly violations: readonly string[]; + readonly severity: "low" | "medium" | "high"; + readonly metadata?: CodeAsPolicyMetadata; +} + +export interface CodeAsPolicyGuardConfig { + readonly allowedPrimitives: readonly string[]; + readonly requireProgramDigest?: boolean; + readonly requireSkillDigest?: boolean; + readonly maxTrialBudget?: number; +} + +export interface CodeAsPolicyGuardPlugin { + verify( + request: SintRequest, + token: SintCapabilityToken, + ): CodeAsPolicyGuardResult; +} + +const CODE_AS_POLICY_RESOURCES = [ + "engine://system2/plan", + "engine://system2/execute", + "engine://capsule/skill-library/register", + "ros2:///joint_trajectory_controller/follow_joint_trajectory", +]; + +const SHA256_DIGEST_RE = /^digest:sha256:[a-f0-9]{64}$/; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function numberValue(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function stringArrayValue(record: Record, key: string): readonly string[] | undefined { + const value = record[key]; + if (!Array.isArray(value)) { + return undefined; + } + return value.every((item) => typeof item === "string") ? value : undefined; +} + +function extractMetadata(params: Record): CodeAsPolicyMetadata | undefined { + const raw = params.codeAsPolicy; + if (!isRecord(raw)) { + return undefined; + } + + return { + programRef: stringValue(raw, "programRef"), + programDigest: stringValue(raw, "programDigest"), + approvedProgramDigest: stringValue(raw, "approvedProgramDigest"), + skillRef: stringValue(raw, "skillRef"), + skillDigest: stringValue(raw, "skillDigest"), + approvedSkillDigest: stringValue(raw, "approvedSkillDigest"), + primitiveSetRef: stringValue(raw, "primitiveSetRef"), + primitives: stringArrayValue(raw, "primitives"), + robotIds: stringArrayValue(raw, "robotIds"), + trialRef: stringValue(raw, "trialRef"), + trialIndex: numberValue(raw, "trialIndex"), + trialBudget: numberValue(raw, "trialBudget"), + }; +} + +function isCodeAsPolicyRequest(request: SintRequest): boolean { + if (isRecord(request.params.codeAsPolicy)) { + return true; + } + return CODE_AS_POLICY_RESOURCES.some((resource) => request.resource === resource); +} + +function denyDecision( + request: SintRequest, + policyViolated: string, + reason: string, +): PolicyDecision { + return { + requestId: request.requestId, + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + action: "deny", + assignedTier: ApprovalTier.T2_ACT, + assignedRisk: RiskTier.T2_STATEFUL, + denial: { + reason, + policyViolated, + }, + }; +} + +export class DefaultCodeAsPolicyGuard implements CodeAsPolicyGuardPlugin { + private readonly allowedPrimitives: ReadonlySet; + private readonly requireProgramDigest: boolean; + private readonly requireSkillDigest: boolean; + private readonly maxTrialBudget: number | undefined; + + constructor(config: CodeAsPolicyGuardConfig) { + this.allowedPrimitives = new Set(config.allowedPrimitives); + this.requireProgramDigest = config.requireProgramDigest ?? true; + this.requireSkillDigest = config.requireSkillDigest ?? true; + this.maxTrialBudget = config.maxTrialBudget; + } + + verify(request: SintRequest, _token: SintCapabilityToken): CodeAsPolicyGuardResult { + if (!isCodeAsPolicyRequest(request)) { + return { + verified: true, + violations: [], + severity: "low", + }; + } + + const metadata = extractMetadata(request.params); + const violations: string[] = []; + + if (!metadata) { + violations.push("codeAsPolicy metadata is required for generated robot program requests"); + return { verified: false, violations, severity: "high" }; + } + + if (this.requireProgramDigest) { + if (!metadata.programDigest || !SHA256_DIGEST_RE.test(metadata.programDigest)) { + violations.push("programDigest must be a digest:sha256 value"); + } + if ( + metadata.approvedProgramDigest + && metadata.programDigest + && metadata.approvedProgramDigest !== metadata.programDigest + ) { + violations.push("programDigest does not match approvedProgramDigest"); + } + } + + if (this.requireSkillDigest && metadata.skillRef) { + if (!metadata.skillDigest || !SHA256_DIGEST_RE.test(metadata.skillDigest)) { + violations.push("skillDigest must be a digest:sha256 value when skillRef is present"); + } + if ( + metadata.approvedSkillDigest + && metadata.skillDigest + && metadata.approvedSkillDigest !== metadata.skillDigest + ) { + violations.push("skillDigest does not match approvedSkillDigest"); + } + } + + if (!metadata.primitives || metadata.primitives.length === 0) { + violations.push("primitives must name the fixed platform primitive vocabulary used"); + } else { + const unapproved = metadata.primitives.filter((primitive) => !this.allowedPrimitives.has(primitive)); + if (unapproved.length > 0) { + violations.push(`unapproved primitives: ${unapproved.join(", ")}`); + } + } + + if (metadata.robotIds && new Set(metadata.robotIds).size !== metadata.robotIds.length) { + violations.push("robotIds must not contain duplicates"); + } + + if ( + this.maxTrialBudget !== undefined + && metadata.trialBudget !== undefined + && metadata.trialBudget > this.maxTrialBudget + ) { + violations.push(`trialBudget ${metadata.trialBudget} exceeds maximum ${this.maxTrialBudget}`); + } + + if ( + metadata.trialIndex !== undefined + && metadata.trialBudget !== undefined + && metadata.trialIndex > metadata.trialBudget + ) { + violations.push(`trialIndex ${metadata.trialIndex} exceeds trialBudget ${metadata.trialBudget}`); + } + + return { + verified: violations.length === 0, + violations, + severity: violations.length === 0 ? "low" : "high", + metadata, + }; + } +} + +export function decisionForCodeAsPolicyViolation( + request: SintRequest, + result: CodeAsPolicyGuardResult, +): PolicyDecision { + const reason = `Code-as-policy guard violation: ${result.violations.join("; ")}`; + const digestMismatch = result.violations.some((violation) => violation.includes("does not match")); + return denyDecision( + request, + digestMismatch ? "CONSTRAINT_VIOLATION" : "CODE_AS_POLICY_GUARD", + reason, + ); +} diff --git a/packages/policy-gateway/src/gateway.ts b/packages/policy-gateway/src/gateway.ts index b58ba2de..67610c2b 100644 --- a/packages/policy-gateway/src/gateway.ts +++ b/packages/policy-gateway/src/gateway.ts @@ -34,6 +34,11 @@ import type { SupplyChainVerifierPlugin } from "./supply-chain.js"; import type { SafetyPermitPlugin } from "./safety-permit.js"; import type { ArgInjectionDetector } from "./arg-injection-detector.js"; import type { RegulatedDataPolicyPlugin } from "./regulated-data-policy.js"; +import type { SpatialIntegrityPolicyPlugin } from "./spatial-integrity-policy.js"; +import { + decisionForCodeAsPolicyViolation, + type CodeAsPolicyGuardPlugin, +} from "./code-as-policy-guard.js"; const INDUSTRIAL_DEPLOYMENT_PROFILES = new Set(["warehouse-amr", "industrial-cell"]); const MAX_HARDWARE_SAFETY_STALENESS_MS = 5_000; @@ -310,6 +315,20 @@ export interface PolicyGatewayConfig { * carrying regulated data metadata. Fail-closed on plugin errors. */ readonly regulatedDataPolicy?: RegulatedDataPolicyPlugin; + /** + * Optional deployment-profile spatial integrity policy. + * Enforces fresh localization evidence for GPS-denied, underground, or other + * degraded physical deployments before normal tier assignment. + * Fail-closed on plugin errors. + */ + readonly spatialIntegrityPolicy?: SpatialIntegrityPolicyPlugin; + /** + * Optional code-as-policy robot-agent guard. + * Enforces generated-program/skill digest binding, fixed primitive + * vocabulary, robot identity shape, and trial-loop budgets for agents that + * write robot control programs. Fail-closed on plugin errors. + */ + readonly codeAsPolicyGuard?: CodeAsPolicyGuardPlugin; } /** @@ -519,6 +538,63 @@ export class PolicyGateway { return decision; } + // 4a-spatial. Deployment-profile spatial integrity policy. + // Token execution envelopes handle per-token spatial proof. This hook + // handles deployment-wide GPS-denied/degraded-perception requirements. + if (this.config.spatialIntegrityPolicy) { + try { + const spatialDecision = await this.config.spatialIntegrityPolicy.evaluate( + request, + token, + { requestId, timestamp }, + ); + if (spatialDecision) { + this.emitEvent("policy.evaluated", request.agentId, request.tokenId, { + decision: spatialDecision.action, + tier: spatialDecision.assignedTier, + risk: spatialDecision.assignedRisk, + source: "spatial_integrity_policy", + policyViolated: spatialDecision.denial?.policyViolated, + escalationReason: spatialDecision.escalation?.reason, + }); + return spatialDecision; + } + } catch { + return this.deny( + requestId, + timestamp, + "SPATIAL_INTEGRITY_POLICY_ERROR", + "Spatial integrity policy failed closed before permission evaluation", + ); + } + } + + // 4a-code. Code-as-policy robot-agent guard. + // Generated robot programs and learned skill-library updates must be bound + // to stable digests and fixed primitive contracts before actuation. + if (this.config.codeAsPolicyGuard) { + try { + const guardResult = this.config.codeAsPolicyGuard.verify(request, token); + if (!guardResult.verified) { + this.emitEvent("robot.code_policy.guard_violation", request.agentId, request.tokenId, { + violations: guardResult.violations, + severity: guardResult.severity, + programRef: guardResult.metadata?.programRef, + skillRef: guardResult.metadata?.skillRef, + primitiveSetRef: guardResult.metadata?.primitiveSetRef, + }); + return decisionForCodeAsPolicyViolation(request, guardResult); + } + } catch { + return this.deny( + requestId, + timestamp, + "CODE_AS_POLICY_GUARD_ERROR", + "Code-as-policy guard failed closed before permission evaluation", + ); + } + } + // 4a-pre. Regulated-data runtime policy. // Evaluates processor/model/region/context metadata after token validation // but before tier assignment so unsafe data paths never execute. diff --git a/packages/policy-gateway/src/index.ts b/packages/policy-gateway/src/index.ts index f17a1104..5162dd42 100644 --- a/packages/policy-gateway/src/index.ts +++ b/packages/policy-gateway/src/index.ts @@ -17,6 +17,13 @@ export type { StaticCorridorGeometry, StaticCorridorResolver, } from "./spatial-corridor.js"; +export { DefaultSpatialIntegrityPolicy } from "./spatial-integrity-policy.js"; +export type { + DefaultSpatialIntegrityPolicyOptions, + SpatialIntegrityPolicyContext, + SpatialIntegrityPolicyPlugin, + SpatialIntegrityProfile, +} from "./spatial-integrity-policy.js"; export { DefaultSupplyChainVerifier } from "./supply-chain.js"; export type { SupplyChainVerifierPlugin, @@ -51,6 +58,16 @@ export type { RegulatedDataPolicyPlugin, RegulatedDataRequestMetadata, } from "./regulated-data-policy.js"; +export { + DefaultCodeAsPolicyGuard, + decisionForCodeAsPolicyViolation, +} from "./code-as-policy-guard.js"; +export type { + CodeAsPolicyGuardConfig, + CodeAsPolicyGuardPlugin, + CodeAsPolicyGuardResult, + CodeAsPolicyMetadata, +} from "./code-as-policy-guard.js"; export { ApprovalQueue } from "./approval-flow.js"; export type { ApprovalRequest, diff --git a/packages/policy-gateway/src/spatial-integrity-policy.ts b/packages/policy-gateway/src/spatial-integrity-policy.ts new file mode 100644 index 00000000..53b51d09 --- /dev/null +++ b/packages/policy-gateway/src/spatial-integrity-policy.ts @@ -0,0 +1,216 @@ +/** + * SINT Protocol — Spatial integrity policy. + * + * Deployment-profile guard for fielded physical AI. Token execution envelopes + * remain the source of truth for per-token spatial proof, while this policy + * lets GPS-denied or degraded-perception deployments fail closed when requests + * lack fresh localization evidence. + */ + +import { + ApprovalTier, + RiskTier, + DEFAULT_APPROVAL_TIMEOUT_MS, + type PolicyDecision, + type SintCapabilityToken, + type SintRequest, +} from "@pshkv/core"; + +export interface SpatialIntegrityProfile { + /** Deployment profile name matched against request.executionContext.deploymentProfile. */ + readonly deploymentProfile: string; + /** Physical actions must include currentPosition. */ + readonly requireCurrentPosition?: boolean; + /** Physical actions must include physicalContext.frameId. */ + readonly requireFrameId?: boolean; + /** Deny when localization confidence is missing or below this floor. */ + readonly minLocalizationConfidence?: number; + /** Escalate, rather than auto-proceed, below this confidence. */ + readonly minAutonomousLocalizationConfidence?: number; + /** Deny when localizationObservedAt is missing or older than this age. */ + readonly maxLocalizationAgeMs?: number; + /** Optional resource prefixes that should be treated as physical actions. */ + readonly physicalResourcePrefixes?: readonly string[]; +} + +export interface SpatialIntegrityPolicyContext { + readonly requestId: string; + readonly timestamp: string; +} + +export interface SpatialIntegrityPolicyPlugin { + evaluate( + request: SintRequest, + token: SintCapabilityToken, + context: SpatialIntegrityPolicyContext, + ): Promise | PolicyDecision | undefined; +} + +export interface DefaultSpatialIntegrityPolicyOptions { + readonly profiles?: readonly SpatialIntegrityProfile[]; +} + +const DEFAULT_PHYSICAL_RESOURCE_PREFIXES = [ + "ros2:///cmd_", + "ros2:///joint_", + "ros2:///gripper/", + "mavlink://", + "px4://", + "humanoid://", + "open-rmf://", + "opcua://", +] as const; + +const DEFAULT_SPATIAL_INTEGRITY_PROFILES: readonly SpatialIntegrityProfile[] = [ + { + deploymentProfile: "gps-denied-indoor", + requireCurrentPosition: true, + requireFrameId: true, + minLocalizationConfidence: 0.7, + minAutonomousLocalizationConfidence: 0.9, + maxLocalizationAgeMs: 2_000, + }, + { + deploymentProfile: "underground-inspection", + requireCurrentPosition: true, + requireFrameId: true, + minLocalizationConfidence: 0.75, + minAutonomousLocalizationConfidence: 0.92, + maxLocalizationAgeMs: 1_500, + }, + { + deploymentProfile: "contested-airspace", + requireCurrentPosition: true, + requireFrameId: true, + minLocalizationConfidence: 0.8, + minAutonomousLocalizationConfidence: 0.95, + maxLocalizationAgeMs: 1_000, + }, +]; + +export class DefaultSpatialIntegrityPolicy implements SpatialIntegrityPolicyPlugin { + private readonly profiles: readonly SpatialIntegrityProfile[]; + + constructor(options: DefaultSpatialIntegrityPolicyOptions = {}) { + this.profiles = options.profiles ?? DEFAULT_SPATIAL_INTEGRITY_PROFILES; + } + + evaluate( + request: SintRequest, + _token: SintCapabilityToken, + context: SpatialIntegrityPolicyContext, + ): PolicyDecision | undefined { + const profile = this.profileFor(request); + if (!profile || !isPhysicalAction(request, profile)) { + return undefined; + } + + const physical = request.physicalContext; + if (profile.requireCurrentPosition && !physical?.currentPosition) { + return deny(context, "SPATIAL_POSITION_REQUIRED", "Spatial integrity requires currentPosition evidence"); + } + + if (profile.requireFrameId && !physical?.frameId) { + return deny(context, "SPATIAL_FRAME_REQUIRED", "Spatial integrity requires a localization frameId"); + } + + if (profile.maxLocalizationAgeMs !== undefined) { + if (!physical?.localizationObservedAt) { + return deny( + context, + "SPATIAL_PROOF_STALE", + "Spatial integrity requires localizationObservedAt for freshness checks", + ); + } + const observedAtMs = new Date(physical.localizationObservedAt).getTime(); + if ( + !Number.isFinite(observedAtMs) + || Date.now() - observedAtMs > profile.maxLocalizationAgeMs + ) { + return deny( + context, + "SPATIAL_PROOF_STALE", + `Spatial integrity proof exceeded maxLocalizationAgeMs ${profile.maxLocalizationAgeMs}`, + ); + } + } + + const confidence = physical?.localizationConfidence; + if ( + profile.minLocalizationConfidence !== undefined + && (confidence === undefined || confidence < profile.minLocalizationConfidence) + ) { + return deny( + context, + "SPATIAL_CONFIDENCE_LOW", + `Localization confidence ${confidence ?? "missing"} is below required ${profile.minLocalizationConfidence}`, + ); + } + + if ( + profile.minAutonomousLocalizationConfidence !== undefined + && confidence !== undefined + && confidence < profile.minAutonomousLocalizationConfidence + ) { + return escalate( + context, + `Localization confidence ${confidence} is below autonomous threshold ${profile.minAutonomousLocalizationConfidence}`, + ); + } + + return undefined; + } + + private profileFor(request: SintRequest): SpatialIntegrityProfile | undefined { + const deploymentProfile = request.executionContext?.deploymentProfile; + if (!deploymentProfile) { + return undefined; + } + return this.profiles.find((profile) => profile.deploymentProfile === deploymentProfile); + } +} + +function isPhysicalAction( + request: SintRequest, + profile: SpatialIntegrityProfile, +): boolean { + if (request.action === "subscribe" || request.action === "observe") { + return false; + } + const prefixes = profile.physicalResourcePrefixes ?? DEFAULT_PHYSICAL_RESOURCE_PREFIXES; + return prefixes.some((prefix) => request.resource.startsWith(prefix)); +} + +function deny( + context: SpatialIntegrityPolicyContext, + policyViolated: string, + reason: string, +): PolicyDecision { + return { + requestId: context.requestId as any, + timestamp: context.timestamp as any, + action: "deny", + denial: { reason, policyViolated }, + assignedTier: ApprovalTier.T3_COMMIT, + assignedRisk: RiskTier.T3_IRREVERSIBLE, + }; +} + +function escalate( + context: SpatialIntegrityPolicyContext, + reason: string, +): PolicyDecision { + return { + requestId: context.requestId as any, + timestamp: context.timestamp as any, + action: "escalate", + escalation: { + requiredTier: ApprovalTier.T2_ACT, + reason, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + fallbackAction: "deny", + }, + assignedTier: ApprovalTier.T2_ACT, + assignedRisk: RiskTier.T2_STATEFUL, + }; +}