diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3227c4a0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,23 @@ +# Review is required where a silent change is a policy bypass rather than a +# regression. That is a narrower set than "the whole repo": these are the paths +# where a diff nobody read can make a governed tool call proceed ungoverned, +# and nothing here will fail a test to tell you. +# +# Not a claim that everything else is unimportant — it is a claim that +# everything else fails loudly. + +# The two sides of the wire. The Guardian decides; the adapter is the only +# thing a host shim is allowed to trust about that decision. +/packages/guardian/ @afogel +/packages/host-adapter/ @afogel + +# Host shims. Each one is a process whose exit code an agent reads as +# permission, so its failure modes are the fail-open surface. +/hosts/ @afogel + +# The policy bundle itself, the verdict-to-ACS translation table, and the +# upstream pin. A change to any of these changes what is allowed without +# changing a line of TypeScript. +/policy/ @afogel +/mapping.yaml @afogel +/agt.lock @afogel diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..433e820e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +# Dependency updates for a repository whose decision path runs through third +# party code: Ajv decides whether an ACS envelope is schema-valid, and AGT's +# published policy library decides everything after that. A silent drift there +# is a change in what this project allows. +# +# Ecosystem is `bun`, not `npm`. Dependabot separated the two: `bun` is its own +# ecosystem, keyed on the text-based `bun.lock` (the legacy binary `bun.lockb` +# is unsupported), requiring bun >= 1.1.39 — this repo uses 1.3.x and the +# text-based lockfile. Pointing `npm` at this tree would read `package.json`, +# find no npm or yarn lockfile, and open PRs that move a declared range while +# leaving `bun.lock` behind — the two would then disagree about what is +# installed, which is the exact failure the exact pins in +# packages/guardian/package.json exist to prevent. +# +# One caveat worth knowing rather than discovering: Dependabot supports +# *version* updates for bun but not *security* updates. A published CVE in Ajv +# will not open its own PR here; it arrives with the ordinary weekly version +# bump, or not at all. +# +# The github-actions entry is deliberately ahead of its subject: this +# repository has no workflows yet (V8 adds the drift harness). It is a no-op +# until then, and live the day the first workflow lands, which is better than +# remembering to add it that day. +version: 2 +updates: + - package-ecosystem: "bun" + # Listed explicitly rather than relying on workspace traversal from the + # root manifest. `bun.lock` is at the root, but the dependency that matters + # most — Ajv — is declared in packages/guardian, and `directories` (which, + # unlike `directory`, takes globs) covers both without assuming how + # Dependabot resolves a workspace. + directories: + - "/" + - "/packages/*" + - "/hosts/*" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.gitignore b/.gitignore index 8647ed5d..a62f7b72 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ build/ *.log .venv/ __pycache__/ + +# Subagent-driven-development scratch workspace (ledger, briefs, review packages) +.superpowers/ diff --git a/README.md b/README.md index eb861824..eb344bd8 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,24 @@ One wire contract between agent hosts and policy runtimes, so governance integra Today every policy vendor writes a module per agent, and every agent waits for a module per vendor. Microsoft's [Agent Governance Toolkit](https://github.com/microsoft/agent-governance-toolkit) ships four host packages with four different architectures — a Copilot CLI extension, subprocess hooks for Claude Code and Antigravity, an in-process plugin for OpenCode — and documents the capability divergence between them in its own READMEs. -This repository shows the other shape. A host implements [ACS](https://github.com/Agent-Control-Standard/ACS) once and is governable by any conformant runtime. A runtime implements ACS once and governs any conformant host. AGT's policy engine runs unforked, with its stock Rego bundle deciding, across two structurally different coding agents — and adding the second host costs zero AGT code. +This repository shows the other shape. A host implements [ACS](https://github.com/Agent-Control-Standard/ACS) once and is governable by any conformant runtime. A runtime implements ACS once and governs any conformant host. This slice (V1) wires one host — Claude Code — to AGT's policy engine running unforked, its stock Rego bundle deciding, entirely over the ACS wire. ## What this proves +**Delivered in V1** — true of this tree today; verifiable by running the commands in Quickstart below. + | Claim | How it is demonstrated | |---|---| -| AGT is completely expressible in ACS | A machine-checked mapping of all eight intervention points and five verdicts, with a round-trip conformance case per cell | -| Interop is real | AGT's published policy library decides, used as shipped, at a pinned upstream commit, with no source changes | -| The collapse is structural | The host adapter contains no AGT-specific code and the AGT bridge contains no host-specific code — verifiable by reading the file list | -| It stays true | The same harness runs against AGT `main` on a schedule, so upstream drift surfaces as a named failing case | +| AGT's policy engine runs unforked, over the ACS wire | AGT's published policy library decides, used as shipped, at a pinned upstream commit, with no source changes (`agt.lock`, [`test/pin.test.ts`](test/pin.test.ts)) | +| The collapse is structural, not incidental | The host adapter contains no AGT-specific code and the AGT bridge contains no host-specific code — verifiable by reading the file list, and enforced by [`test/invariants.test.ts`](test/invariants.test.ts) | + +**Planned, not yet built** — the rest of the claim this project is working toward. None of the following exists yet, and there is no CI in this repository at all. + +| Claim | Slice | +|---|---| +| AGT is completely expressible in ACS: a machine-checked mapping of all eight intervention points and five verdicts, with a round-trip conformance case per cell | V7 | +| The same policy governs two structurally different coding agents, with the second host costing zero added AGT code | V5 | +| A scheduled harness run against AGT `main` catches upstream drift automatically | V8 | ## Layout @@ -30,9 +38,70 @@ The shaping doc is authoritative for requirements, shapes, and the breadboard. T git clone --recurse-submodules https://github.com/afogel/ACS_reference_implementation ``` +## Quickstart (R7.1 — one command on a laptop) + +Requires [`bun`](https://bun.sh) and the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI (`claude`) on your `PATH`. + +**1. Install.** + +```bash +bun install +``` + +**2. Start the Guardian.** In its own terminal, from the repo root: + +```bash +bun run guardian +``` + +This constructs the AGT bridge once, against the pinned stock policy bundle (`policy/lib`, per `agt.lock`), and serves ACS's `POST /acs` JSON-RPC endpoint: + +``` +Guardian listening at http://localhost:8787/acs +``` + +Leave it running. `hosts/claude-code/acs-hook.ts` defaults to exactly this URL; override with `ACS_GUARDIAN_URL` if it's listening elsewhere. + +**3. Wire the hook into Claude Code.** + +```bash +mkdir -p .claude +cp hosts/claude-code/settings.json .claude/settings.json +``` + +This registers `hosts/claude-code/acs-hook.ts` as a `PreToolUse` hook for the `Bash` tool — the "one hook" of this slice's name. + +**4. Run Claude Code with the hook.** + +```bash +claude +``` + +Ask it to run a destructive shell command, e.g. *"Use the Bash tool to run exactly this command: `rm -rf /`"*. The tool call is blocked, with the real policy-engine reasoning surfaced in the transcript — not a canned string, the actual text AGT's stock policy engine produces when it evaluates the pattern it matched. That pattern list is this project's own configuration (`policy/lib/data.json`), not something AGT ships — the stock bundle carries no shell/command patterns of its own, only generic PII regexes; what's stock is the *deciding module* (`agt.patterns`) and the priority chain that consults it, per R2.1 (zero Rego authored). See the framing note in [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md) before narrating this demo. Ask for something harmless (`ls -la`) in the same session and it runs normally. Full walkthrough and what to watch for: [`docs/demos/v1-runbook.md`](docs/demos/v1-runbook.md). + +Steps 1–2 were run against this exact tree to write this README: `bun install` completes clean, and `bun run guardian` prints the line above. Steps 3–4 were verified the same way the project's own tests verify them — piping a Claude Code–shaped `PreToolUse` payload on stdin straight into the hook shim against a running Guardian: + +```bash +echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \ + | bun run hosts/claude-code/acs-hook.ts +# {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"matched pattern ... at offset 0"}} +``` + +which is exactly the JSON Claude Code's own hook protocol sends and expects back; running `claude` interactively for step 4 exercises the identical path through the real CLI. + +### Verify + +```bash +bun test # 97 tests across 12 files (96 pass, 1 skip), including the R3.2/R3.3 gates below + # the skip is the byte-identity check, which needs UPSTREAM_BUNDLE — see verify:pin +bun run typecheck # whole-workspace strict TypeScript check, zero errors +``` + +`bun run verify:pin` additionally re-clones AGT at the pinned ref and byte-diffs the vendored bundle against it (R2.2/R2.3) — it needs network access to GitHub, so it isn't part of the offline quickstart above. + ## Status -Shaped and sliced; implementation has not started. Slices V1–V8 are tracked as issues on the project board, each with a stacked pull request. +V1 ("one host, one hook") is implemented: a Claude Code `PreToolUse` hook, a Guardian process serving ACS over HTTP, and AGT's unforked stock policy bundle deciding behind it — see the quickstart above and [`slices/v1/README.md`](slices/v1/README.md). R3.2 and R3.3 (no AGT vocabulary in the host adapter, no host *output* vocabulary in it either, and no host vocabulary in the AGT bridge) are enforced by [`test/invariants.test.ts`](test/invariants.test.ts), not left to inspection. Slices V2–V8 are shaped and sliced but not started; they are tracked as issues on the project board, each with a stacked pull request. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0cba85cd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,64 @@ +# Security Policy + +## Supported versions + +None, and that is not a placeholder. This repository has no tagged release, no +published package, and no CI. `main` is the only thing that exists, and it is a +reference implementation under construction — the README's own "Planned, not +yet built" table is the honest inventory. + +So there is no support matrix and no backport path: a fix lands on `main` or it +does not exist. Anything here that is running in front of real traffic is doing +so ahead of every claim this project makes. + +## What a vulnerability looks like here + +This is a governance tool, so the interesting failures are not the usual ones. +The bug that matters is a **bypass**: any way to make a tool call proceed when +the policy would have stopped it, or to make a host act on a decision the +policy runtime never issued. + +Concretely, reports are wanted for: + +- A host shim (`hosts/`) that can be made to fail in a way its agent reads as + "the hook never fired", so the tool call proceeds ungoverned. +- A Guardian (`packages/guardian/`) that can be induced to allow, or to answer + one request with another request's decision. +- Anything that reaches the Guardian's socket and changes an outcome. The ACS + wire is unauthenticated at every slice in this tree — the endpoint binds + loopback precisely because reachability is the only access control it has, so + a way around that bind is in scope. +- A crafted envelope, policy bundle, or `mapping.yaml` that turns a `deny` into + anything else, including an error the host treats as permission. + +Out of scope, because they are known and written down rather than hidden: the +absence of wire authentication, described in the header of +`packages/guardian/src/server.ts`, and the fail-open on an unreachable Guardian +at the slices that have not built the posture negotiation yet, described in +`docs/demos/v1-runbook.md`. A way to *trigger* either one against a host that +believes it is governed is very much in scope. + +## Reporting + +Report privately, through GitHub's private vulnerability reporting on this +repository: + +**** + +Please do not open a public issue, discussion, or pull request for a bypass. +A public report is a working recipe for evading a policy engine, and it reaches +every reader before it reaches a fix. + +Include what you have of: + +- The shortest sequence that reproduces it — ideally the hook payload or JSON-RPC + envelope, verbatim, and the commit you ran it against. +- Which side you believe is at fault: the host shim, the Guardian, the mapping, + or the policy bundle. +- What the correct outcome would have been, and what happened instead. +- Whether it needs anything beyond reaching the Guardian's port — a particular + policy, a particular host, a race, local file access. + +A report with a reproducer and no analysis is more useful than analysis with no +reproducer. Expect an acknowledgement rather than a schedule: there is no +release process here to promise a fix window against. diff --git a/agt.lock b/agt.lock new file mode 100644 index 00000000..3367f316 --- /dev/null +++ b/agt.lock @@ -0,0 +1,7 @@ +{ + "agt_repo": "https://github.com/microsoft/agent-governance-toolkit", + "agt_ref": "81955d48025c6b11deb3fc9dabf89f74f4145775", + "bundle_path": "policy-engine/policy/lib", + "sdk_package": "agent-control-specification", + "sdk_version": "0.3.1-beta.0" +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..c6d7d495 --- /dev/null +++ b/bun.lock @@ -0,0 +1,140 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "acs-reference-implementation", + "dependencies": { + "agent-control-specification": "0.3.1-beta.0", + }, + "devDependencies": { + "bun-types": "^1.3.14", + "typescript": "^7.0.2", + }, + }, + "hosts/claude-code": { + "name": "claude-code-host", + "version": "0.1.0", + "dependencies": { + "host-adapter": "workspace:*", + }, + "devDependencies": { + "guardian": "workspace:*", + }, + }, + "packages/agt-bridge": { + "name": "agt-bridge", + "version": "0.1.0", + "dependencies": { + "agent-control-specification": "0.3.1-beta.0", + }, + }, + "packages/guardian": { + "name": "guardian", + "version": "0.1.0", + "dependencies": { + "agt-bridge": "workspace:*", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + }, + }, + "packages/host-adapter": { + "name": "host-adapter", + "version": "0.1.0", + "devDependencies": { + "guardian": "workspace:*", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "agent-control-specification": ["agent-control-specification@0.3.1-beta.0", "", { "optionalDependencies": { "agent-control-specification-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-darwin-x64": "0.3.1-beta.0", "agent-control-specification-linux-arm64-gnu": "0.3.1-beta.0", "agent-control-specification-linux-x64-gnu": "0.3.1-beta.0", "agent-control-specification-opa-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-opa-darwin-x64": "0.3.1-beta.0", "agent-control-specification-opa-linux-arm64": "0.3.1-beta.0", "agent-control-specification-opa-linux-x64": "0.3.1-beta.0", "agent-control-specification-opa-win32-x64": "0.3.1-beta.0", "agent-control-specification-win32-x64-msvc": "0.3.1-beta.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.100.0", "@langchain/core": ">=1.1.0", "@openai/agents": ">=0.11.0", "langchain": ">=1.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/core", "@openai/agents", "langchain"] }, "sha512-vD1N169F2YhTCLbF4u7hEV5xfmAY1vziq4bHL2xhBa5QNOhdeEzrTdOIshWZw46fxxPJzEYDjQyjSmI2NnHCuQ=="], + + "agent-control-specification-darwin-arm64": ["agent-control-specification-darwin-arm64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6kr1DAeFOBf/8Q7eNFqLtUoUfRqIDTqZ2QvkIvSKxDYh3iY2/8cizn0bzbJJofuba02aDV1oLEd5vvgcYkbWAg=="], + + "agent-control-specification-darwin-x64": ["agent-control-specification-darwin-x64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFLEv/u77ZnErjcfLPCExL+ZeNRlOQ1Gg7l1VZjuTH9VQyIrTBXDHVb5czH6T9WFyozHRtt/NPud1XANeKGk+g=="], + + "agent-control-specification-linux-arm64-gnu": ["agent-control-specification-linux-arm64-gnu@0.3.1-beta.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1eKUzmkuPBr4HymQXdVImQA/opjuwv+I1nYkH1TTnkZf9Yvo9PWePbrhDTNH/ZFE/FvUtGLZfUIPU5PJceVEw=="], + + "agent-control-specification-linux-x64-gnu": ["agent-control-specification-linux-x64-gnu@0.3.1-beta.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AaC7jPQe2RX56nGdnrcqL2JmjLMw4bP+J6rJyrNTr8YGgZtAL5R+hcbHZdqNnsUssoD57mJLOjl8OdnOJ07+SA=="], + + "agent-control-specification-opa-darwin-arm64": ["agent-control-specification-opa-darwin-arm64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sdqgve222Y+aq20Rfa4yOWnD+u1SygeFWLD+N0dsrFzSKQCkW3aRg3EJkYBmgzGXvHi6qheL9zWRYkaxqaypww=="], + + "agent-control-specification-opa-darwin-x64": ["agent-control-specification-opa-darwin-x64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6hZCNloScj0RlwvkzOUNcnYEpkvObcy9P88a1tKZA7vztcKfTsKCwAjq1rPUWAvI48BG3bldOLdLaHXbyvutdw=="], + + "agent-control-specification-opa-linux-arm64": ["agent-control-specification-opa-linux-arm64@0.3.1-beta.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7n/0kqzuXVmX/eccpw6yEjD4n5aFd0aRqpXs0EtDZS5P1DhIORKR/w1flrr1FOvVv0UJoFDM7p78STwO8IdIHw=="], + + "agent-control-specification-opa-linux-x64": ["agent-control-specification-opa-linux-x64@0.3.1-beta.0", "", { "os": "linux", "cpu": "x64" }, "sha512-UgvO8h8c3cEVht/Nkflx0BbA5vKFatX3B5Q1D/g6pD+tXidZzs4HmZaKcazaniPuuqzbrz70eAYF92yoyjP4OA=="], + + "agent-control-specification-opa-win32-x64": ["agent-control-specification-opa-win32-x64@0.3.1-beta.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hNHRXGxQ3pnagMdeowu3WgskHrF3QqX/gclesv3fesTq6xn0IKDW2/4iKdAq5lLYFk49f/fQYPV/ybtpaVwmow=="], + + "agent-control-specification-win32-x64-msvc": ["agent-control-specification-win32-x64-msvc@0.3.1-beta.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+9/DAvNb1bGPQwYOppaiVctxDs0i8RalbZnUuobJLxghqmR4GN9JpqGKBwdk4Du9EIRwRkP5C4/AjmPj5vr+RA=="], + + "agt-bridge": ["agt-bridge@workspace:packages/agt-bridge"], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "claude-code-host": ["claude-code-host@workspace:hosts/claude-code"], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + + "guardian": ["guardian@workspace:packages/guardian"], + + "host-adapter": ["host-adapter@workspace:packages/host-adapter"], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/docs/demos/v1-runbook.md b/docs/demos/v1-runbook.md new file mode 100644 index 00000000..60224db7 --- /dev/null +++ b/docs/demos/v1-runbook.md @@ -0,0 +1,104 @@ +# V1 demo runbook: one host, one hook, a real AGT decision + +**The demo, in the slice's own words** (from `docs/shaping/acs-reference-impl-slices.md`): + +> In Claude Code, ask for a destructive shell command. AGT's stock policy denies it; the deny reason appears in the transcript. + +**Framing correction (R4.4) — read before you narrate this.** The AGT stock bundle ships **no** shell or command patterns of its own — `patterns.rego` carries generic PII regexes only. What is stock is the *deciding module* (`agt.patterns`) and the priority chain in `agt_default.rego`; the destructive-command regex list this demo denies against is this project's own configuration, supplied as data (`policy/lib/data.json`), not authored Rego. Narrate this as "AGT's stock policy engine, configured" — never as "Microsoft ships an `rm -rf` deny-list." R2.1 still holds exactly: zero Rego authored, behaviour driven only through `data.agt.defaults.config`. + +**Framing correction (fix wave finding 7) — the handshake declares, it does not negotiate.** Before `steps/toolCallRequest`, the adapter sends a real `handshake/hello` ClientHello (`packages/host-adapter/src/handshake.ts`), and the Guardian answers with a ServerHello (`packages/guardian/src/handshake.ts`). In V1 that ServerHello's `negotiated_version` and `selected_transport` are constants the Guardian returns unconditionally — it never reads what the ClientHello proposed. So don't narrate this as version/transport negotiation actually happening; it is a fixed declaration on both ends, correct for this slice's single Guardian/single host pairing, but not the negotiation the field names imply. Real negotiation (picking a mutually-supported version or transport, refusing when none exists) is future work, not built here. + +## What a viewer should watch for + +1. You ask Claude Code to run a destructive shell command. +2. Claude Code's transcript shows the tool call **blocked**, with a human-readable deny reason — not a generic "permission denied," but the actual text the policy engine produced (something like *"matched pattern `(?i)rm\s+-[a-z]*r[a-z]*f[a-z]*\s+/(?:\s|$)` at offset 0"*). +3. That reason did not come from Claude Code, and it did not come from a hardcoded string in the hook shim — it travelled from the AGT bridge's Rego evaluation, through the Guardian's `POST /acs` endpoint, over HTTP, through `createGuardianClient(...).requestDecision` → `renderDecision`, and into `permissionDecisionReason`. The payoff is that this text is real, not stubbed. +4. Ask for something harmless (e.g. `ls -la`) in the same session and it runs normally — the hook only interrupts what the policy actually denies. + +## Prerequisites + +- `bun` installed. +- This repo cloned with its submodule: `git clone --recurse-submodules ...` (or `git submodule update --init` after a plain clone). +- `bun install` run once at the repo root. +- The [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI installed and on your `PATH` (`claude`). + +## Step 1 — start the Guardian + +From the repo root, in its own terminal: + +```bash +bun run guardian +``` + +This starts the Guardian's `POST /acs` JSON-RPC endpoint (`packages/guardian/src/main.ts`), constructing the AGT bridge once at boot against the pinned stock bundle (`policy/lib`, per `agt.lock`) and `policy/manifest.yaml`. It prints the URL it's listening on: + +``` +Guardian listening at http://localhost:8787/acs +``` + +Leave this running for the rest of the demo. `hosts/claude-code/acs-hook.ts` defaults to exactly this URL (`http://localhost:8787/acs`); set `ACS_GUARDIAN_URL` if you need the Guardian on a different port. + +**What happens if you skip this step, or the Guardian dies mid-demo:** V1 does not implement a considered fail-open/fail-closed posture for an unreachable Guardian (that negotiation is N6/N7, slice V3). The hook shim writes an error to stderr and exits 1 — Claude Code's "non-blocking error" — with nothing on stdout, so the tool call proceeds **ungoverned**, exactly as if the hook had never fired. If the demo's `rm -rf /` unexpectedly seems to go through unblocked, this is the first thing to check. + +## Step 2 — wire the hook into Claude Code + +`hosts/claude-code/settings.json` registers the shim against `PreToolUse` for the `Bash` tool only (matching `policy/manifest.yaml`'s registered tools — this is the "one hook" of V1's name, not a general-purpose interception of every tool call). Install it as this repo's project-level Claude Code settings: + +```bash +mkdir -p .claude +cp hosts/claude-code/settings.json .claude/settings.json +``` + +(If you already have a `.claude/settings.json` here, merge the `hooks.PreToolUse` block in rather than overwriting.) + +The registered command is: + +``` +bun run "$CLAUDE_PROJECT_DIR/hosts/claude-code/acs-hook.ts" +``` + +`$CLAUDE_PROJECT_DIR` is Claude Code's own substitution for the project root, so the hook resolves correctly regardless of what directory you're in when you launch `claude`. + +## Step 3 — launch Claude Code and ask for the destructive command + +From the repo root: + +```bash +claude +``` + +Then ask directly, so Claude actually issues the Bash tool call rather than declining on its own judgment before any hook runs — e.g.: + +> Use the Bash tool to run exactly this command: `rm -rf /` + +## Step 4 — watch the deny land in the transcript + +Claude Code will show the tool call as blocked, with `permissionDecisionReason` surfaced in the transcript UI. It should read close to: + +``` +matched pattern (?i)rm\s+-[a-z]*r[a-z]*f[a-z]*\s+/(?:\s|$) at offset 0 +``` + +This is `data.json`'s `destructive_shell_command_blocked` pattern rule, evaluated for real — not a canned response. (The exact offset/pattern text will track whatever `policy/lib/data.json` configures; the point to watch is that *some* real, specific reasoning text arrives, not a placeholder.) + +## Step 5 — contrast with an allowed command + +Ask Claude Code to run something harmless with the Bash tool, e.g. `ls -la`. It runs normally with no interruption — the hook fired, the Guardian returned `allow`, and `renderDecision` rendered a plain allow with no reason attached (there is nothing to show, by design — an allow is not a warning). + +## Verifying the pieces independently + +If the live demo doesn't behave as expected, each layer can be checked in isolation: + +```bash +# Guardian reachable and evaluating for real (id and request_id must be +# schema-valid uuids -- the Guardian rejects "1" with a schema error): +curl -s -X POST http://localhost:8787/acs \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"steps/toolCallRequest","id":"22222222-2222-2222-2222-222222222222","params":{"acs_version":"0.1.0","request_id":"22222222-2222-2222-2222-222222222222","timestamp":"2026-01-01T00:00:00Z","metadata":{"agent_id":"claude-code","session_id":"11111111-1111-1111-1111-111111111111"},"payload":{"tool":{"name":"Bash"},"arguments":{"command":{"value":"rm -rf /"}}}}}' + +# The shim itself, fed a real PreToolUse payload directly on stdin: +echo '{"session_id":"demo","transcript_path":"/tmp/t.jsonl","cwd":"'"$PWD"'","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \ + | bun run hosts/claude-code/acs-hook.ts +``` + +The second command is exactly what `hosts/claude-code/test/hook.test.ts` automates: it spawns `acs-hook.ts` as a real subprocess against a real (test-instance) Guardian and asserts `permissionDecision: "deny"` with a non-empty, policy-sourced `permissionDecisionReason` for `rm -rf /`, and `permissionDecision: "allow"` for `ls -la` — both exiting 0. diff --git a/docs/shaping/acs-reference-impl-shaping.md b/docs/shaping/acs-reference-impl-shaping.md index ebcdecd4..8d1d1076 100644 --- a/docs/shaping/acs-reference-impl-shaping.md +++ b/docs/shaping/acs-reference-impl-shaping.md @@ -127,7 +127,7 @@ The literal reading of the spec. Hosts are ACS clients; a Guardian is a server; | A1.2 | OpenCode: same shape, driven from `tool.execute.before` / `.after` in-process plugin hooks | | | **A2** | ACS Guardian service: JSON-RPC 2.0 over HTTP, validates every envelope against the v0.1.0 schemas | | | **A3** | Session layer in the Guardian: SessionContext hash chain, Intent, provenance lineage; persists AGT `result_labels` and re-supplies as `input.ifc.source_labels` | | -| **A4** | AGT bridge: embeds the AGT Python SDK; ACS envelope → 5-member policy input; `evaluate_intervention_point`; verdict → ACS decision | | +| **A4** | AGT bridge: embeds the AGT **Node** SDK ⚠️ *amended, was Python*; ACS envelope → 5-member policy input; `evaluateInterventionPoint`; verdict → ACS decision | | | **A5** | Envelope tap: every request and response rendered as pretty JSON in a live viewer | | | **A6** | Demo runbook: one AGT policy bundle, both hosts, side by side with AGT's native packages | | @@ -181,6 +181,7 @@ Post-spike. All flags cleared, so the check now discriminates. - R5 fails B: envelopes that are never serialized are not inspectable on the wire, which is what R5.1 asks for. - R6 fails B: an in-process Guardian sharing a heap with the host adapter makes the stateless/stateful split an assertion rather than an observable property. - **C is selected.** It carries every requirement A does and is the only shape that proves R1. +- ⚠️ **A4's SDK choice is load-bearing for R1.4, discovered during V1 planning.** AGT's PyO3 binding surfaces only `action_identity`, collapsing `input_identity` and `enforced_identity`; the Node binding serializes both. Every shape embeds A4, so on the Python SDK R1.4 ("`enforced_identity` survives the adapter") would be unverifiable in *all three* columns and C's R1 ✅ would not survive contact with C2's harness. A4 is amended to the Node SDK and the verdicts stand as written. No other row moves. --- @@ -201,7 +202,7 @@ All resolved — see `spike-agt-integration.md`. |---|------| | F1 | Confirm by hand that Claude Code `PostToolUse.updatedToolOutput` rewrites tool results as documented | | F2 | Confirm an OpenCode plugin can express deny and modify through `tool.execute.before` / `.after` | -| F3 | Decide Rego (canonical, needs `opa` CLI) versus Cedar (zero extra binary) for the demo bundle | +| ~~F3~~ | ✅ **Resolved: Rego.** The premise was wrong — the SDK bundles OPA 0.70.0 as a platform package, so Rego needs no external binary and Cedar's only advantage disappears. Stock bundle verified 105/105 under the bundled OPA and system OPA 1.18.2. Closes D7 | --- @@ -242,16 +243,16 @@ All resolved — see `spike-agt-integration.md`. |---|-------|-----------|------------|---------|-----------|------------| | N1 | P1 | acs-hook shim | generic hook entrypoint, reads hook JSON on stdin | call | → N2 | — | | N2 | P1 | `@acs/host-adapter` | `buildEnvelope(event, payload, hookmap)` | call | → N4 | — | -| N3 | P1 | `@acs/host-adapter` | `renderDecision(decision, hookmap)` → `hookSpecificOutput` on stdout | call | → U2, → U3 | — | -| N4 | P1 | `@acs/host-adapter` | `guardianClient.post()` JSON-RPC over HTTP | call | → N20 | → N7 | -| N5 | P1 | `@acs/host-adapter` | `handshake()` — `handshake/hello`; negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | +| N3 | P1 | `@acs/host-adapter` | `renderDecision(decision, hookmap)` → the host's output object on stdout, every field name read from the hookmap | call | → U2, → U3 | — | +| N4 | P1 | `@acs/host-adapter` | `createGuardianClient(url).requestDecision()` JSON-RPC over HTTP | call | → N20 | → N7 | +| N5 | P1 | `@acs/host-adapter` | `negotiateSessionConfig()` — `handshake/hello`; negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | | N6 | P1 | `@acs/host-adapter` | `applyFailurePosture()` — no decision within timeout → negotiated posture (default `proceed`); writes an audit event on every fail-open proceed | call | → S14, → N3 | — | | N7 | P1 | `@acs/host-adapter` | `validateDecision()` — malformed `modifications` → `DENY`; `ASK`/`DEFER` expiry → their `timeout_*` defaults | call | → N3, → N6 | — | | N10 | P2 | acs-plugin shim | OpenCode plugin hooks: `session.start`, `event`, `tool.execute.before/after/error` | call | → N11 | — | | N11 | P2 | `@acs/host-adapter` | `buildEnvelope()` — **same module as N2** | call | → N13 | — | | N12 | P2 | `@acs/host-adapter` | `renderDecision()` — **same module as N3** | call | → U11, → U12 | — | -| N13 | P2 | `@acs/host-adapter` | `guardianClient.post()` — **same module as N4** | call | → N20 | → N16 | -| N14 | P2 | `@acs/host-adapter` | `handshake()` — **same module as N5** | call | → N28 | → S15 | +| N13 | P2 | `@acs/host-adapter` | `createGuardianClient().requestDecision()` — **same module as N4** | call | → N20 | → N16 | +| N14 | P2 | `@acs/host-adapter` | `negotiateSessionConfig()` — **same module as N5** | call | → N28 | → S15 | | N15 | P2 | `@acs/host-adapter` | `applyFailurePosture()` — **same module as N6** | call | → S16, → N12 | — | | N16 | P2 | `@acs/host-adapter` | `validateDecision()` — **same module as N7** | call | → N12, → N15 | — | | N20 | P3 | guardian | `POST /acs` JSON-RPC 2.0 endpoint | call | → N21 | — | @@ -262,9 +263,9 @@ All resolved — see `spike-agt-integration.md`. | N25 | P3 | guardian | `persistResultLabels()` — AGT `result_labels` into ACS lineage | call | → S5 | — | | N26 | P3 | guardian | `writeEnvelopeTap()` | call | → S6 | — | | N27 | P3 | guardian | `denyOnInvalidEnvelope()` — schema or bridge failure returns an explicit ACS `deny` **decision**, not a bare error, so the host honors it instead of falling back to posture | call | → N26 | → N4, → N13 | -| N28 | P3 | guardian | `handshakeResponder()` — ServerHello: `timeout_config`, `on_decision_failure`, `profiles_accepted` | call | → N26 | → N5, → N14 | -| N30 | P3.1 | agt-bridge | `evaluate_intervention_point(point, snapshot)` | call | — | → N24 | -| N31 | P3.1 | agt-bridge | `AgentControl.from_path(manifest.yaml)` at boot | call | — | → N30 | +| N28 | P3 | guardian | `buildServerHello()` — ServerHello: `timeout_config`, `on_decision_failure`, `profiles_accepted` | call | → N26 | → N5, → N14 | +| N30 | P3.1 | agt-bridge | `evaluateInterventionPoint(point, snapshot)` — Node SDK | call | — | → N24 | +| N31 | P3.1 | agt-bridge | `AgentControl.fromPath(manifest.yaml)` at boot | call | — | → N30 | | N40 | P5 | conformance | `acs-agt-conformance` runner | call | → N41, → N42, → N43, → N44 | — | | N41 | P5 | conformance | intervention-point round trip, validated against `policy-input.schema.json` | call | — | → N47 | | N42 | P5 | conformance | verdict round trip: AGT verdict → ACS decision → AGT verdict, assert identity | call | — | → N47 | @@ -316,8 +317,8 @@ flowchart TB N1["N1: acs-hook shim"] N2["N2: buildEnvelope()"] N3["N3: renderDecision()"] - N4["N4: guardianClient.post()"] - N5["N5: handshake()"] + N4["N4: createGuardianClient().requestDecision()"] + N5["N5: negotiateSessionConfig()"] N6["N6: applyFailurePosture()"] N7["N7: validateDecision()"] S1["S1: claude-code.hookmap.yaml"] @@ -332,8 +333,8 @@ flowchart TB N10["N10: acs-plugin shim"] N11["N11: buildEnvelope() — same module as N2"] N12["N12: renderDecision() — same as N3"] - N13["N13: guardianClient.post() — same as N4"] - N14["N14: handshake() — same as N5"] + N13["N13: createGuardianClient().requestDecision() — same as N4"] + N14["N14: negotiateSessionConfig() — same as N5"] N15["N15: applyFailurePosture() — same as N6"] N16["N16: validateDecision() — same as N7"] S2["S2: opencode.hookmap.yaml"] @@ -350,15 +351,15 @@ flowchart TB N25["N25: persistResultLabels()"] N26["N26: writeEnvelopeTap()"] N27["N27: denyOnInvalidEnvelope()"] - N28["N28: handshakeResponder()"] + N28["N28: buildServerHello()"] S3["S3: sessionContext chain"] S4["S4: intent"] S5["S5: provenance + result_labels"] S6["S6: envelope log"] subgraph P31["P3.1: AGT bridge"] - N30["N30: evaluate_intervention_point()"] - N31["N31: AgentControl.from_path()"] + N30["N30: evaluateInterventionPoint()"] + N31["N31: AgentControl.fromPath()"] S7["S7: manifest.yaml"] S8["S8: data.agt.defaults.config"] S9["S9: AGT stock bundle (pinned)"] @@ -523,5 +524,6 @@ flowchart TB | D4 | R1.1 — spec `steps/modelCall` for v0.2 as part of this work, or map AGT's two model-call points onto existing hooks and declare the seam | Open | Decides whether this is an implementation project or a spec-and-implementation project | | D5 | R7.3 — determinism | Open | A scripted transcript demos reliably; a live model demos honestly | | D6 | Shape selection | **Decided: C** | C is the only shape that proves R1 rather than asserting it | -| D7 | F3 — Rego or Cedar for the demo bundle | Open | Rego is the canonical default binding; Cedar removes an external binary from setup | -| D8 | 🟡 Which `on_decision_failure` the reference ships as its default | Open | The spec default is `proceed` (fail-open). Shipping the spec default is the honest choice, but a security-facing demo that fails open needs the audit trail on screen (U23) to read correctly | +| ~~D7~~ | F3 — Rego or Cedar for the demo bundle | ✅ **Decided: Rego** | The deciding factor was wrong. Cedar's advantage was removing an external binary, but the SDK ships OPA 0.70.0 as a platform package — so Rego, the canonical binding, costs nothing extra. Verified: stock bundle 105/105 under the bundled OPA | +| D8 | 🟡 Which `on_decision_failure` the reference ships as its default | Open, leaning `proceed` | The spec default is `proceed` (fail-open). Shipping the spec default is the honest choice, but a security-facing demo that fails open needs the audit trail on screen (U23) to read correctly. V1 negotiates and stores it (N5/N28/S13); V3 applies it (N6), so the decision is only needed by V3 | +| D9 | ⚠️ **New.** Report the `./` bundle-path fail-open upstream to AGT? | Open | A `./`-prefixed `bundle:` silently voids all policy and returns `allow` with no error. It is a fail-open in a governance tool and affects any AGT host, not just us. Reporting is the good-citizen move and consistent with R4.3's non-adversarial framing; it is also unattributed outbound traffic, so it needs an explicit decision before anything is sent | diff --git a/docs/shaping/acs-reference-impl-slices.md b/docs/shaping/acs-reference-impl-slices.md index cf02681a..5c3d30e3 100644 --- a/docs/shaping/acs-reference-impl-slices.md +++ b/docs/shaping/acs-reference-impl-slices.md @@ -31,6 +31,8 @@ Every slice ends in something demo-able. **Demo:** In Claude Code, ask for a destructive shell command. AGT's stock policy denies it; the deny reason appears in the transcript. +**⚠️ Framing correction (R4.4).** The stock bundle ships **no** shell or command patterns — `patterns.rego` carries generic PII regexes only. What is stock is the *deciding module* (`agt.patterns`) and the priority chain in `agt_default.rego`; the destructive-command regex list is ours, supplied as configuration. R2.1 still holds exactly — zero Rego authored, behaviour driven only through `data.agt.defaults.config` — but the demo must be narrated as "AGT's stock policy engine, configured", never as "Microsoft ships an `rm -rf` deny-list". Overclaiming here would breach R4.4. + | # | Place | Component | Affordance | Control | Wires Out | Returns To | |---|-------|-----------|------------|---------|-----------|------------| | U1 | P1 | claude-code | prompt input | type | → N1 | — | @@ -38,26 +40,34 @@ Every slice ends in something demo-able. | N1 | P1 | acs-hook shim | generic hook entrypoint, reads hook JSON on stdin | call | → N2 | — | | N2 | P1 | `@acs/host-adapter` | `buildEnvelope(event, payload, hookmap)` | call | → N4 | — | | N3 | P1 | `@acs/host-adapter` | `renderDecision(decision, hookmap)` | call | → U2 | — | -| N4 | P1 | `@acs/host-adapter` | `guardianClient.post()` JSON-RPC over HTTP | call | → N20 | → N3 | -| N5 | P1 | `@acs/host-adapter` | `handshake()` — negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | +| N4 | P1 | `@acs/host-adapter` | `createGuardianClient(url).requestDecision()` JSON-RPC over HTTP | call | → N20 | → N3 | +| N5 | P1 | `@acs/host-adapter` | `negotiateSessionConfig()` — negotiates `timeout_config`, `on_decision_failure`, profiles | call | → N28 | → S13 | | N20 | P3 | guardian | `POST /acs` JSON-RPC 2.0 endpoint | call | → N21 | — | -| N28 | P3 | guardian | `handshakeResponder()` — ServerHello | call | — | → N5 | +| N28 | P3 | guardian | `buildServerHello()` — ServerHello | call | — | → N5 | | S13 | P1 | store | `negotiated session config` | — | — | → N6 (V3) | | N21 | P3 | guardian | `validateEnvelope()` against v0.1.0 schemas | call | → N23 | — | | N23 | P3 | guardian | `assembleSnapshot()` — envelope → AGT snapshot | call | → N30 | — | | N24 | P3 | guardian | `mapVerdict()` — AGT verdict → ACS decision | call | — | → N4 | -| N30 | P3.1 | agt-bridge | `evaluate_intervention_point(point, snapshot)` | call | — | → N24 | -| N31 | P3.1 | agt-bridge | `AgentControl.from_path(manifest.yaml)` at boot | call | — | → N30 | +| N30 | P3.1 | agt-bridge | `evaluateInterventionPoint(point, snapshot)` | call | — | → N24 | +| N31 | P3.1 | agt-bridge | `AgentControl.fromPath(manifest.yaml)` at boot | call | — | → N30 | | S1 | P1 | store | `claude-code.hookmap.yaml` | — | — | → N2, N3 | -| S7 | P3.1 | store | `manifest.yaml`, binding `rego` → `data.agt.defaults.verdict` | — | — | → N31 | -| S8 | P3.1 | store | `data.agt.defaults.config` | — | — | → N31 | -| S9 | P3.1 | store | AGT stock bundle at pinned ref | — | — | → N31 | +| S7 | P3.1 | store | `manifest.yaml`, binding `rego` → `data.agt.defaults.verdict`; `policy_target` **must** resolve to a leaf string (`$.tool_call.args.command`) | — | — | → N31 | +| S8 | P3.1 | store | `data.agt.defaults.config`, shipped as `policy/lib/data.json` **inside** the bundle directory | — | — | → N31 | +| S9 | P3.1 | store | AGT stock bundle at pinned ref, every `.rego` byte-identical | — | — | → N31 | | S10 | shared | store | `mapping.yaml` | — | — | → N23, N24 | | S11 | shared | store | `agt.lock` | — | — | → N31 | **Scope note.** Only `pre_tool_call` is wired. No session state, no tap, no second host. `N23` assembles the snapshot from the envelope alone; it starts reading S3/S4/S5 in V6. -**Setup cost this slice absorbs:** the `opa` CLI on PATH (S9 needs it), the pinned AGT checkout, and the first cut of `mapping.yaml`. +**Setup cost this slice absorbs:** ⚠️ *amended* — the `opa` CLI is **no longer a setup cost*. The npm package pulls `agent-control-specification-opa-darwin-arm64`, which ships OPA 0.70.0, overridable via `ACS_OPA_PATH` / `ACS_OPA_NO_BUNDLE`. The stock bundle passes 105/105 under both it and system OPA 1.18.2. What remains: the pinned AGT checkout and the first cut of `mapping.yaml`. **This closes D7 as Rego** — Cedar's only advantage was removing an external binary, and there is no external binary. + +**⚠️ Watch-for — `bundle:` resolves against the manifest's own directory.** Discovered during V1 execution, after the `./` landmine below: `bundle:` is relative to the directory holding `manifest.yaml`, not the process cwd. With the manifest at `policy/manifest.yaml`, `bundle: policy/lib` resolves to `policy/policy/lib` and every call hard-fails `runtime_error:policy_invocation_failed`. The correct value is `bundle: lib`. This failure is *loud* — unlike the `./` landmine, it denies rather than silently allowing — but the two are easily confused because both stem from how AGT joins this one field. + +**⚠️ Watch-for — the `./` landmine.** `policies..bundle` must **not** begin with `./`. AGT joins the manifest directory to the literal value, yielding `/./policy/lib`; OPA's bundle loader mis-derives the data mount path from the `/./` segment and silently drops `data.json`. The policy then matches nothing and **every decision becomes `allow`** — a fail-open with no error, in a governance tool. Measured: `./policy/lib` loads, `/abs/policy/lib` loads, `/abs/./policy/lib` is UNDEFINED. `createBridge` throws on `/./`, and V1's deny test is the backstop. + +**⚠️ Amendment — the config lives inside the bundle.** `data_paths` cannot deliver `data.agt.defaults.config` while `bundle:` is set: the stock bundle ships no `.manifest`, so its roots default to `""`, it owns the whole data tree, and the `--data` document is discarded. S8 therefore ships as `policy/lib/data.json`. Every stock `.rego` stays byte-identical; `data.json` is the only added file, so R2.1/R2.3 hold — we author a data document, not policy. + +**⚠️ Amendment — the bridge embeds the Node SDK, not the Python SDK.** The PyO3 binding sets only `action_identity`; the Node binding sets `input_identity` and `enforced_identity` distinctly (`sdk/node/native/lib.rs:191-204`). On Python, R1.4 is unverifiable and V7's N43 is impossible. This also makes the whole repo one TypeScript toolchain. Amends shaping A4. --- @@ -92,6 +102,8 @@ Every slice ends in something demo-able. **Two failure domains, kept separate.** AGT fails closed on *evaluation* — bad policy output, invalid transform, missing paths — and that produces a `deny` **verdict**, which §6.4 says the host MUST honor regardless of posture. N27 exists so Guardian-side failures also arrive as decisions rather than bare errors, keeping them in that honored path. `on_decision_failure` only governs *delivery*: Guardian silent, transport dead, error with no decision. Conflating the two would either break AGT's invariant or halt production on a network blip. +**⚠️ Blocker discovered in V1 — S13 has no home across processes.** V1 built `S13` as an in-process store, but the Claude Code shim is a **fresh subprocess per hook invocation**, so an in-memory negotiated session config can never survive to the next hook. `negotiateSessionConfig()` is also not called on the real path in V1 at all. `N6 applyFailurePosture()` reads S13, so V3 cannot work until this is resolved: either persist the negotiated config (a session-keyed file), or have the shim talk to a session-scoped daemon. The choice ripples — V5's second host is in-process and would not share the constraint, and V6's session chain sits on the same seam. Decide this before V3 starts. + **Rest of the slice is data, not structure.** Disposition coverage lives in S1 (every ACS decision → `permissionDecision` / `updatedInput`) and S8 (stock rules configured to actually fire allow, deny, escalate, transform, and drift-warn). Once the adapter is generic, coverage is configuration. Wire N21's error branch to N27, and N4's return through N7 here. @@ -126,8 +138,8 @@ New entries in S1 for `PostToolUse` → `steps/toolCallResult`, and in S8 for th | N10 | P2 | acs-plugin shim | OpenCode plugin hooks: `session.start`, `event`, `tool.execute.before/after/error` | call | → N11 | — | | N11 | P2 | `@acs/host-adapter` | `buildEnvelope()` — same module as N2 | call | → N13 | — | | N12 | P2 | `@acs/host-adapter` | `renderDecision()` — same module as N3 | call | → U11, → U12 | — | -| N13 | P2 | `@acs/host-adapter` | `guardianClient.post()` — same module as N4 | call | → N20 | → N16 | -| N14 | P2 | `@acs/host-adapter` | `handshake()` — same module as N5 | call | → N28 | → S15 | +| N13 | P2 | `@acs/host-adapter` | `createGuardianClient().requestDecision()` — same module as N4 | call | → N20 | → N16 | +| N14 | P2 | `@acs/host-adapter` | `negotiateSessionConfig()` — same module as N5 | call | → N28 | → S15 | | N15 | P2 | `@acs/host-adapter` | `applyFailurePosture()` — same module as N6 | call | → S16, → N12 | — | | N16 | P2 | `@acs/host-adapter` | `validateDecision()` — same module as N7 | call | → N12, → N15 | — | | S2 | P2 | store | `opencode.hookmap.yaml` | — | — | → N11, N12 | @@ -175,6 +187,8 @@ Wire N21 → N22 → N23 in place of V1's direct N21 → N23. | N47 | P5 | conformance | `renderMatrix()` | call | → U30 | — | | N48 | P5 | conformance | `renderMappingTable()` | call | → U32 | — | +**⚠️ Gap discovered in V1 — the Guardian's outbound envelopes are validated by nothing.** Inbound requests get Ajv against all 43 v0.1.0 schemas (N21), but responses are hand-built objects checked by no schema. The conformance harness would therefore measure a wire format that was never itself contract-checked — which quietly weakens exactly the claim C2 exists to prove. Add response validation before the matrix is published. Related: V1 found that `response-envelope.json`'s `result` unconditionally `$ref`s `AcsResult`, which requires `decision` — a ServerHello has no such field, so a handshake response cannot satisfy it. That looks like a genuine v0.1.0 spec gap (no discriminated union for non-decision methods) and is worth an upstream ACS issue, not just a red cell. + **Expect two cells to be honestly red.** `pre_model_call` and `post_model_call` have no ACS v0.1.0 target — see D4. Red cells with a stated reason are worth more than a green matrix that quietly redefines the claim, and they are the forcing function for `steps/modelCall` in v0.2. R5.3 lands here: the matrix *is* the profile declaration. @@ -204,9 +218,12 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b |---|------|-------|----------| | 1 | `updatedToolOutput` does not behave as documented | V4 | Confirm early (F1). V4 drops cleanly if it fails | | 2 | OpenCode plugin cannot express modify | V5 | Confirm early (F2). Falls back to deny-only, weakening but not breaking V5 | -| 3 | `opa` CLI dependency raises setup friction | V1 | Cedar is the built-in fallback with a parity library — that is D7 | +| 3 | ~~`opa` CLI dependency raises setup friction~~ | ~~V1~~ | ✅ **Retired.** The SDK ships OPA 0.70.0 in `agent-control-specification-opa-`. No external binary, so D7 closes as Rego | | 4 | Two model-call cells cannot go green on v0.1.0 | V7 | Ship red with a stated reason; drive `steps/modelCall` into v0.2 | | 5 | Upstream AGT breaks the contract mid-project | all | V8 exists for this, but lands late — consider pulling N45/N46 forward if upstream churn shows up during V1 | +| 6 | ⚠️ A `./`-prefixed `bundle:` path silently disables policy — every decision becomes `allow`, with no error | V1 | `createBridge` throws on `/./`; V1's deny test is the backstop. Worth reporting upstream: a fail-open in a governance tool | +| 7 | ⚠️ `enforced_identity` bisection is unavailable over AGT's Python binding | V7 | Resolved by embedding the **Node** SDK, which serializes `input_identity` and `enforced_identity` distinctly. Had we stayed on Python, R1.4 would be unverifiable and N43 impossible | +| 8 | ⚠️ AGT's verdict carries no `rule_id` / `reason_codes` / `reasoning` | V1, V7 | `mapVerdict` synthesizes them from `reason` / `message`, and `mapping.yaml` is where that synthesis is declared — so V7 measures it rather than assuming it | ## Open decisions carried from shaping @@ -216,4 +233,7 @@ Runs on a schedule in CI. MS-ACS is `0.3.1-beta` and warns of breaking changes b | D3 | Hook coverage beyond AGT's eight | V7 scope | | D4 | Spec `steps/modelCall` for v0.2 as part of this work | V7 red cells | | D5 | Determinism of the demo | V1 onward | -| D7 | Rego versus Cedar for the demo bundle | V1 | +| ~~D7~~ | ✅ **Closed: Rego.** Cedar's sole advantage was avoiding an external binary; the SDK bundles OPA, so that advantage does not exist. Stock bundle verified 105/105 under the bundled OPA | ~~V1~~ | +| D8 | 🟡 Which `on_decision_failure` ships as default — V1 negotiates and stores it (N5/N28/S13); V3 applies it (N6). Leaning to the spec default `proceed`, paired with U23's audit count | V3 | + +**Correction log.** V1 planning verified the AGT surface by running it rather than reading it, and produced ten corrections — the SDK choice, the `./` landmine, config-inside-the-bundle, the absent stock shell patterns, the leaf `policy_target`, AGT's missing `rule_id`/`reason_codes`/`reasoning`, lowercase wire decisions, `steps/toolCallRequest` and the 19-hook count, the retired `opa` setup cost, and the Python identity collapse. Each is recorded above at the row it governs, with its evidence, in `docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md`. diff --git a/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md b/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md new file mode 100644 index 00000000..469cb9d2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md @@ -0,0 +1,647 @@ +# V1 — One host, one hook, a real AGT decision + +## Slice Contract + +| Field | Value | +|---|---| +| Slice ID | [#2](https://github.com/afogel/ACS_reference_implementation/issues/2) (epic [#1](https://github.com/afogel/ACS_reference_implementation/issues/1), PR [#10](https://github.com/afogel/ACS_reference_implementation/pull/10)) | +| Slices doc | `docs/shaping/acs-reference-impl-slices.md` §V1, line 30 | +| Demo | "In Claude Code, ask for a destructive shell command. AGT's stock policy denies it; the deny reason appears in the transcript." | +| Components | U1, U2 (Claude Code surfaces) · N1 (hook shim) · N2 `buildEnvelope` · N3 `renderDecision` · N4 `guardianClient.post` · N5 `handshake` · N20 `POST /acs` · N21 `validateEnvelope` · N23 `assembleSnapshot` · N24 `mapVerdict` · N28 `handshakeResponder` · N30 `evaluateInterventionPoint` · N31 `AgentControl.fromPath` · S1 hookmap · S7 manifest · S8 config · S9 stock bundle · S10 `mapping.yaml` · S11 `agt.lock` · S13 negotiated session config | +| Parked items | Session state (S3/S4/S5) → **V6**. Envelope tap (N26/S6) and Inspector (P4) → **V2**. Failure posture application (N6/N7/S14) → **V3**. `denyOnInvalidEnvelope` (N27) → **V3**. Output redaction (U3) → **V4**. Second host (P2) → **V5**. Conformance harness (P5) → **V7**. | +| Watch-for | "Only `pre_tool_call` is wired. No session state, no tap, no second host. N23 assembles the snapshot from the envelope alone; it starts reading S3/S4/S5 in V6." (slices doc line 58) | +| Corrections | Ten, all discovered during planning and all verified empirically — see **Corrections discovered during planning** below. Each is amended into the slices doc in this PR. | +| Requirements | R0, R1.3, R2.1, R2.2, R2.3, R3.2, R3.3, R5.2, R6.1, R7.1, R7.2 | + +**Global Constraints** (bind every task; copy verbatim into reviewer dispatches): + +1. **Zero Rego authored.** Policy behaviour is configured only through `data.agt.defaults.config`. No `.rego` file is written or edited by us. (R2.1) +2. **Stock bundle byte-identical.** Every `.rego` under `policy/lib/` matches AGT at the pinned ref exactly. The only permitted addition to that directory is `data.json`. (R2.2, R2.3) +3. **The host adapter contains zero AGT-specific code.** No file under `packages/host-adapter/` may mention AGT, Rego, OPA, verdicts, or intervention points. Verifiable by grep. (R3.2) +4. **The AGT bridge contains zero host-specific code.** No file under `packages/agt-bridge/` may mention Claude Code, hooks, OpenCode, or stdin/stdout hook protocols. Verifiable by grep. (R3.3) +5. **ACS decisions are lowercase on the wire** — `allow`, `deny`, `modify`, `ask`, `defer`. Uppercase appears in spec prose only and must never be emitted. +6. **The bundle path in `policy/manifest.yaml` must not begin with `./`.** See Correction C2 — a `./` prefix silently disables the entire policy and every decision becomes `allow`. +7. **AGT is stateless.** Nothing under `packages/agt-bridge/` persists anything between calls. (R6.1) + +--- + +## Corrections discovered during planning + +Every one of these was verified by running code, not by reading docs. They are amended into the slices doc in this PR. + +| # | Correction | Evidence | Amends | +|---|---|---|---| +| C1 | **The bridge embeds the Node SDK, not the Python SDK.** The PyO3 binding sets only `action_identity`; the Node binding sets `input_identity` and `enforced_identity` distinctly. Python would make R1.4 unverifiable and V7's N43 impossible. | `sdk/python/src/lib.rs:152-154` vs `sdk/node/native/lib.rs:191-204`; confirmed on the installed package — both fields present on the result | Shaping A4; slices §V1 N30/N31 | +| C2 | **`bundle:` must not start with `./`.** AGT joins the manifest dir to the literal value, producing `/./policy/lib`; OPA's bundle loader mis-derives the data mount path from a `/./` segment and drops `data.json`. The policy then matches nothing and returns `allow` — a silent fail-open. | Measured: `./policy/lib` → config loads; `/abs/policy/lib` → loads; `/abs/./policy/lib` → UNDEFINED | New watch-for + risk row | +| C3 | **Config must live inside the bundle directory as `data.json`.** `data_paths` cannot deliver `data.agt.defaults.config` when `bundle:` is set: the bundle ships no `.manifest`, so its roots default to `""`, it owns the whole data tree, and the `--data` document is discarded. | Traced argv: `opa eval --bundle --data ` → config UNDEFINED; same with `-d` → config loads | slices §V1 S8 | +| C4 | **The stock bundle ships no shell/command patterns** — only generic PII regexes. `agt.patterns` is the module that denies, but the destructive-command regex list is authored by us as config. R2.1 still holds (zero Rego), but the demo must not claim Microsoft ships an `rm -rf` deny-list. | `policy/lib/patterns.rego:17-33` | slices §V1 demo framing | +| C5 | **`policy_target` must resolve to a leaf string.** `pattern_text()` falls back to `input.policy_target.value` and requires `is_string`. Binding `$.tool_call.args` (an object) makes the check silently never fire. | `agt_default.rego:86-92` | slices §V1 S7 | +| C6 | **AGT's verdict has no `rule_id`, no `reason_codes`, no `reasoning`.** Its shape is `{decision, reason, message, transform?, evidence?, result_labels?}`. ACS's richer fields must be synthesized by `mapVerdict`, and that synthesis is `mapping.yaml`'s job. | `core/src/verdict.rs:99-121`; `grep -rn rule_id policy-engine/spec/` → zero matches | slices §V1 N24/S10 | +| C7 | **ACS decisions are lowercase on the wire.** `docs/acs.md`'s `response.action == "DENY"` pseudocode contradicts `response-envelope.json`. | `specification/v0.1.0/response-envelope.json` | Shaping N7 (writes `DENY`) | +| C8 | **The method is `steps/toolCallRequest`**, and ACS v0.1.0 defines **19** `steps/*` hooks, not the 16 in `specification.md` §5's stale table. | `hooks.md`; 19 payload schemas on disk | slices §V1 N2; D3 framing | +| C9 | **`opa` on PATH is no longer a setup cost.** The npm package pulls `agent-control-specification-opa-darwin-arm64`, shipping OPA 0.70.0, with `ACS_OPA_PATH` / `ACS_OPA_NO_BUNDLE` overrides. The stock bundle passes 105/105 under both that and system OPA 1.18.2. | `bun add` output; `opa test` under both binaries | slices §V1 setup-cost note; **closes D7** | +| C10 | **`enforced_identity` bisection is unavailable over the Python binding** and available over Node. Recorded because it is the reason C1 matters beyond convenience, and because V7's N43 depends on it. | as C1 | New risk row | + +--- + +## Slice accounting + +| From the slice | Handled by | Note | +|---|---|---| +| U1 prompt input | Task 9 | Claude Code's own surface; exercised by the runbook | +| U2 tool permission outcome in transcript | Task 9 | `permissionDecisionReason` carries the deny reason | +| N1 hook shim | Task 9 | | +| N2 `buildEnvelope` | Task 7 | | +| N3 `renderDecision` | Task 8 | | +| N4 `guardianClient.post` | Task 8 | | +| N5 `handshake` | Task 8 | | +| N20 `POST /acs` | Task 6 | | +| N21 `validateEnvelope` | Task 5 | | +| N23 `assembleSnapshot` | Task 4 | Envelope-only, per the watch-for | +| N24 `mapVerdict` | Task 3 | | +| N28 `handshakeResponder` | Task 6 | | +| N30 `evaluateInterventionPoint` | Task 2 | | +| N31 `AgentControl.fromPath` | Task 2 | | +| S1 `claude-code.hookmap.yaml` | Task 7 | | +| S7 `manifest.yaml` | Task 2 | | +| S8 `data.agt.defaults.config` | Task 2 | Ships as `policy/lib/data.json` per C3 | +| S9 AGT stock bundle | Task 1 | | +| S10 `mapping.yaml` | Task 3 | | +| S11 `agt.lock` | Task 1 | | +| S13 negotiated session config | Task 8 | Stored; *applied* in V3 | +| Watch-for: only `pre_tool_call` wired | Tasks 2, 4 honour it | | +| Watch-for: N23 envelope-only | Task 4 honours it | | +| R2.1 policy used as shipped | Task 1 (byte-identity test), Task 2 | | +| R3.2 adapter has zero AGT code | Task 10 (grep gate) | | +| R3.3 bridge has zero host code | Task 10 (grep gate) | | +| R7.1 one command on a laptop | Task 10 | | +| Setup cost: `opa` CLI | **obsolete** — see C9 | | + +--- + +## Tasks + +### Task 1: Workspace, vendored bundle, and the pin · slice #2 · S9, S11 + +**Files:** +- Create: `package.json`, `tsconfig.base.json`, `.gitignore` (append), `agt.lock` +- Create: `policy/lib/**` (vendored from AGT `81955d48025c6b11deb3fc9dabf89f74f4145775`, path `policy-engine/policy/lib`) +- Test: `test/pin.test.ts` + +**Interfaces:** +- Produces: `agt.lock` — `{ "agt_ref": "<40-hex>", "agt_repo": "https://github.com/microsoft/agent-governance-toolkit", "sdk_package": "agent-control-specification", "sdk_version": "0.3.1-beta.0", "bundle_path": "policy-engine/policy/lib" }`. Read by Task 2 and, in V8, by `diffSurfaces()`. + +- [ ] **Step 1: Write the failing test** — `test/pin.test.ts` + +```ts +import { describe, expect, it } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const lock = JSON.parse(readFileSync("agt.lock", "utf8")); + +describe("AGT pin", () => { + it("records a full 40-character commit ref", () => { + expect(lock.agt_ref).toMatch(/^[0-9a-f]{40}$/); + }); + + it("pins the SDK version the bridge installs", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + const dep = + pkg.dependencies?.["agent-control-specification"] ?? + pkg.devDependencies?.["agent-control-specification"]; + expect(dep).toBeDefined(); + expect(dep.replace(/^[^0-9]*/, "")).toBe(lock.sdk_version); + }); + + it("vendors the stock bundle with every stock module present", () => { + const files = readdirSync("policy/lib").filter((f) => f.endsWith(".rego")); + for (const mod of [ + "agt_default.rego", "agt_ifc.rego", "approval.rego", "budgets.rego", + "confidence.rego", "content_hash.rego", "drift.rego", "egress.rego", + "ifc.rego", "patterns.rego", "redact.rego", + ]) { + expect(files).toContain(mod); + } + }); + + it("adds nothing to the bundle except data.json", () => { + const extra = readdirSync("policy/lib").filter( + (f) => !f.endsWith(".rego") && f !== "run_tests.sh" && f !== "data.json", + ); + expect(extra).toEqual([]); + }); + + it("authors no Rego of our own — every .rego is byte-identical to upstream", () => { + // UPSTREAM_BUNDLE is set by `bun run verify:pin`, which clones the pinned ref. + const upstream = process.env.UPSTREAM_BUNDLE; + if (!upstream) return; // skipped in the fast unit run; enforced by verify:pin in CI + for (const f of readdirSync("policy/lib").filter((f) => f.endsWith(".rego"))) { + expect(readFileSync(join("policy/lib", f), "utf8")).toBe( + readFileSync(join(upstream, f), "utf8"), + ); + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** — `bun test test/pin.test.ts` → fails on missing `agt.lock`. +- [ ] **Step 3: Minimal implementation** — create the workspace and vendor the bundle. + +`package.json`: +```json +{ + "name": "acs-reference-implementation", + "private": true, + "type": "module", + "workspaces": ["packages/*"], + "dependencies": { "agent-control-specification": "0.3.1-beta.0" }, + "scripts": { + "test": "bun test", + "verify:pin": "bash scripts/verify-pin.sh" + } +} +``` + +`agt.lock`: +```json +{ + "agt_repo": "https://github.com/microsoft/agent-governance-toolkit", + "agt_ref": "81955d48025c6b11deb3fc9dabf89f74f4145775", + "bundle_path": "policy-engine/policy/lib", + "sdk_package": "agent-control-specification", + "sdk_version": "0.3.1-beta.0" +} +``` + +`scripts/verify-pin.sh` clones the pinned ref into a temp dir, exports `UPSTREAM_BUNDLE`, and re-runs the pin test so the byte-identity assertion actually executes. + +- [ ] **Step 4: Run it, expect PASS** — `bun test test/pin.test.ts` and `bun run verify:pin`. +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: S9, S11` + +--- + +### Task 2: The AGT bridge · slice #2 · N30, N31, S7, S8 + +The single highest-risk task, and the one whose behaviour is already measured — the code below is the verified probe, not a sketch. + +**Files:** +- Create: `packages/agt-bridge/package.json`, `packages/agt-bridge/src/index.ts` +- Create: `policy/manifest.yaml` (S7), `policy/lib/data.json` (S8) +- Test: `packages/agt-bridge/test/bridge.test.ts` + +**Interfaces:** +- Produces: + ```ts + export type AgtVerdict = { + decision: "allow" | "deny" | "warn" | "escalate" | "transform"; + reason?: string; message?: string; + transform?: { path: string; value: unknown }; + result_labels?: string[]; + }; + export type BridgeResult = { + verdict: AgtVerdict; + inputIdentity?: string; + enforcedIdentity?: string; + transformedPolicyTarget?: unknown; + }; + export function createBridge(manifestPath: string): { + evaluate(point: string, snapshot: Record): Promise; + }; + ``` + Consumed by Task 3 (`mapVerdict`) and Task 6 (the Guardian). + +**Constraint:** no file in this package may name a host. It receives an assembled snapshot and returns a verdict. + +- [ ] **Step 1: Write the failing test** — `packages/agt-bridge/test/bridge.test.ts` + +```ts +import { describe, expect, it, beforeAll } from "bun:test"; +import { createBridge } from "../src/index.ts"; + +const snapshotFor = (command: string) => ({ + envelope: { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }, + tool_call: { name: "run_shell", args: { command }, id: "t1" }, +}); + +let bridge: ReturnType; +beforeAll(() => { bridge = createBridge("policy/manifest.yaml"); }); + +describe("agt-bridge", () => { + it("denies a destructive shell command using the stock bundle", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(r.verdict.decision).toBe("deny"); + expect(r.verdict.reason).toBe("destructive_shell_command_blocked"); + expect(r.verdict.message).toContain("matched pattern"); + }); + + it("denies the -fr spelling too", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -fr / --no-preserve-root")); + expect(r.verdict.decision).toBe("deny"); + }); + + it("allows benign commands", async () => { + for (const cmd of ["ls -la", "git status"]) { + expect((await bridge.evaluate("pre_tool_call", snapshotFor(cmd))).verdict.decision).toBe("allow"); + } + }); + + // Guards Correction C2 — the failure mode this catches is a SILENT fail-open. + it("surfaces the policy config to Rego (guards the ./ bundle-path landmine)", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(r.verdict.decision).not.toBe("allow"); + }); + + // Guards Correction C1 — this is why the bridge is Node, not Python. + it("returns input and enforced identity as distinct fields", async () => { + const r = await bridge.evaluate("pre_tool_call", snapshotFor("ls -la")); + expect(r.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(r.enforcedIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** — `bun test packages/agt-bridge` → module not found. +- [ ] **Step 3: Minimal implementation** + +`policy/manifest.yaml` (S7) — note `bundle: policy/lib` with **no** `./`, per C2, and the leaf `policy_target`, per C5: +```yaml +agent_control_specification_version: "0.3.1-beta" +metadata: + name: "acs-reference-implementation" +policies: + agt_stock: + type: rego + bundle: lib # ⚠️ amended in execution: relative to THIS FILE's directory, not cwd. + # `policy/lib` here resolves to policy/policy/lib and hard-fails + # runtime_error:policy_invocation_failed. Still no leading `./` (C2). + query: data.agt.defaults.verdict +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.command" + policy_target_kind: tool_args + tool_name_from: "$.tool_call.name" + policy: + id: agt_stock +tools: + run_shell: + type: Tool + id: run_shell + security_labels: [shell] +``` + +`policy/lib/data.json` (S8) — the only file we add to the bundle, per C3: +```json +{ + "agt": { + "defaults": { + "config": { + "patterns": { + "patterns": [ + "(?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$)", + "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+/(?:\\s|$)" + ], + "reason": "destructive_shell_command_blocked" + } + } + } + } +} +``` + +`packages/agt-bridge/src/index.ts`: +```ts +import { AgentControl } from "agent-control-specification"; + +export type AgtVerdict = { + decision: "allow" | "deny" | "warn" | "escalate" | "transform"; + reason?: string; + message?: string; + transform?: { path: string; value: unknown }; + result_labels?: string[]; +}; + +export type BridgeResult = { + verdict: AgtVerdict; + inputIdentity?: string; + enforcedIdentity?: string; + transformedPolicyTarget?: unknown; +}; + +/** + * N31 — construct once at boot. N30 — evaluate per decision. + * Stateless: nothing is retained between evaluate() calls (R6.1). + */ +export function createBridge(manifestPath: string) { + if (manifestPath.includes("/./")) { + throw new Error( + `manifest path contains "/./": ${manifestPath}. AGT joins this verbatim and OPA ` + + `then drops the bundle's data document, silently disabling policy. See C2.`, + ); + } + const control = AgentControl.fromPath(manifestPath); + + return { + async evaluate(point: string, snapshot: Record): Promise { + const result = await control.evaluateInterventionPoint(point, snapshot as never); + return { + verdict: result.verdict as AgtVerdict, + inputIdentity: result.inputIdentity, + enforcedIdentity: result.enforcedIdentity, + transformedPolicyTarget: result.transformedPolicyTarget, + }; + }, + }; +} +``` + +- [ ] **Step 4: Run it, expect PASS** — `bun test packages/agt-bridge`. +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: N30, N31, S7, S8` + +--- + +### Task 3: `mapping.yaml` and `mapVerdict` · slice #2 · S10, N24 + +S10 is the load-bearing artifact: the same file drives the runtime here and the conformance harness in V7. If they diverge, V7 goes red. + +**Files:** +- Create: `mapping.yaml`, `packages/guardian/src/map-verdict.ts` +- Test: `packages/guardian/test/map-verdict.test.ts` + +**Interfaces:** +- Consumes: `AgtVerdict` from Task 2. +- Produces: `mapVerdict(v: AgtVerdict, mapping: Mapping): AcsDecision` where + ```ts + export type AcsDecision = { + decision: "allow" | "deny" | "modify" | "ask" | "defer"; + reasoning?: string; + reason_codes?: string[]; + policy_references?: { policy_id: string; policy_version?: string; rule_id: string }[]; + }; + ``` + +**Mapping rules** (C6 — AGT carries no `rule_id`/`reason_codes`/`reasoning`, so these are synthesized): +`allow`→`allow` · `deny`→`deny` · `warn`→`allow` **with non-empty `policy_references`** (R1.2) · `escalate`→`ask` · `transform`→`modify`. +`reasoning` ← `verdict.message` · `reason_codes` ← `[verdict.reason]` · `policy_references[0].rule_id` ← `verdict.reason`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from "bun:test"; +import { loadMapping, mapVerdict } from "../src/map-verdict.ts"; + +const m = loadMapping("mapping.yaml"); + +describe("mapVerdict", () => { + it("maps allow to allow", () => { + expect(mapVerdict({ decision: "allow" }, m).decision).toBe("allow"); + }); + + it("maps deny, carrying reason and message into ACS fields", () => { + const d = mapVerdict( + { decision: "deny", reason: "destructive_shell_command_blocked", message: "matched pattern X" }, + m, + ); + expect(d.decision).toBe("deny"); + expect(d.reasoning).toBe("matched pattern X"); + expect(d.reason_codes).toEqual(["destructive_shell_command_blocked"]); + expect(d.policy_references?.[0].rule_id).toBe("destructive_shell_command_blocked"); + }); + + // R1.2 — the whole warn round trip rests on this. + it("maps warn to allow WITH non-empty policy_references", () => { + const d = mapVerdict({ decision: "warn", reason: "drift_detected", message: "drift 0.8" }, m); + expect(d.decision).toBe("allow"); + expect(d.policy_references?.length).toBeGreaterThan(0); + expect(d.policy_references?.[0].rule_id).toBe("drift_detected"); + }); + + it("distinguishes warn-allow from clean allow by policy_references", () => { + expect(mapVerdict({ decision: "allow" }, m).policy_references ?? []).toHaveLength(0); + }); + + it("maps escalate to ask and transform to modify", () => { + expect(mapVerdict({ decision: "escalate", reason: "approval_required" }, m).decision).toBe("ask"); + expect(mapVerdict({ decision: "transform", reason: "redacted" }, m).decision).toBe("modify"); + }); + + it("emits only lowercase decisions (C7)", () => { + for (const dec of ["allow", "deny", "warn", "escalate", "transform"] as const) { + const out = mapVerdict({ decision: dec, reason: "r" }, m).decision; + expect(out).toBe(out.toLowerCase()); + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL.** +- [ ] **Step 3: Minimal implementation** — `mapping.yaml` holds the verdict table and the point table (the latter read by V7): + +```yaml +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +intervention_points: + pre_tool_call: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + output: { acs_method: "steps/agentResponse" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +verdicts: + allow: { decision: allow } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +# AGT carries no rule_id / reason_codes / reasoning (C6). These are synthesized. +field_synthesis: + reasoning: from: verdict.message + reason_codes: from: [verdict.reason] + policy_references[].rule_id: from: verdict.reason + policy_references[].policy_id: literal: agt_stock +``` + +`packages/guardian/src/map-verdict.ts` implements exactly that table — no behaviour that is not expressed in `mapping.yaml`. + +- [ ] **Step 4: Run it, expect PASS.** +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: S10, N24` + +--- + +### Task 4: `assembleSnapshot` · slice #2 · N23 + +**Files:** Create `packages/guardian/src/assemble-snapshot.ts`; Test `packages/guardian/test/assemble-snapshot.test.ts` + +**Interfaces:** +- Consumes: a validated ACS request envelope (Task 5's shape). +- Produces: `assembleSnapshot(envelope): Record` — the AGT snapshot for `pre_tool_call`, shaped per `AGT-SNAPSHOT-1.0.md` §2.5. + +**Watch-for (honoured):** envelope-only. No S3/S4/S5 reads — those arrive in V6. + +- [ ] **Step 1: Write the failing test** — asserts that `params.payload.tool.name` → `tool_call.name`, that each `arguments..value` unwraps to `tool_call.args.` (dropping the ACS provenance wrapper), that `tool_call.args.command` is a **string** (C5), that `envelope.budgets` is always present with four zeroed counters, and that no session-derived key appears in the output. +- [ ] **Step 2: Run it, expect FAIL.** +- [ ] **Step 3: Minimal implementation** — unwrap `{value, provenance}` argument envelopes; emit `{ envelope: { budgets: {...} }, tool_call: { name, args, id } }`. +- [ ] **Step 4: Run it, expect PASS.** +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: N23` + +--- + +### Task 5: `validateEnvelope` · slice #2 · N21 + +**Files:** Create `packages/guardian/src/validate-envelope.ts`; Test `packages/guardian/test/validate-envelope.test.ts` + +Validates against the v0.1.0 schemas in the `spec/acs` submodule — `specification/v0.1.0/request-envelope.json` plus `hooks/tool-call-request.json` — with Ajv, resolving the modular `$ref`s by `$id`. + +**Scope note:** V1 *rejects* invalid envelopes. Turning that rejection into an explicit ACS `deny` **decision** is N27, which belongs to **V3**. Task 5 throws a typed error; Task 6 returns a JSON-RPC error. Do not implement N27 here. + +- [ ] **Step 1: Write the failing test** — a valid `steps/toolCallRequest` envelope passes; each of a missing `params.metadata.session_id`, a missing `payload.tool.name`, and a bad `method` prefix fails with the offending JSON pointer named. +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N21` + +--- + +### Task 6: Guardian JSON-RPC endpoint and handshake · slice #2 · N20, N28 + +**Files:** Create `packages/guardian/src/server.ts`, `packages/guardian/src/handshake.ts`, `packages/guardian/src/index.ts`; Test `packages/guardian/test/server.test.ts` + +**Interfaces:** +- Produces: `startGuardian({ port, manifestPath }): Promise<{ url: string; close(): Promise }>`, serving `POST /acs`. +- Dispatch is by the JSON-RPC `method` field. The spec mandates no URL path; `/acs` is our convention and is recorded as such in the slices doc. + +`handshakeResponder` (N28) returns a ServerHello with the required `negotiated_version`, `methods_evaluated`, `selected_transport`, `timeout_config`, plus `on_decision_failure`. **D8 note:** the reference ships the spec default `proceed`. V1 only negotiates and stores it; V3 applies it. + +- [ ] **Step 1: Write the failing test** — `handshake/hello` returns a schema-valid ServerHello with `timeout_config.default_ms` present and `on_decision_failure: "proceed"`; `steps/toolCallRequest` carrying `rm -rf /` returns `result.decision === "deny"` with `reasoning` and non-empty `reason_codes`; the same carrying `ls -la` returns `allow`; an unknown method returns a JSON-RPC error in `-32000..-32099`; and every response echoes `params.request_id`. +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N20, N28` + +--- + +### Task 7: `buildEnvelope` and the hookmap · slice #2 · N2, S1 + +**Files:** Create `packages/host-adapter/package.json`, `packages/host-adapter/src/build-envelope.ts`, `hosts/claude-code/claude-code.hookmap.yaml`; Test `packages/host-adapter/test/build-envelope.test.ts` + +**Constraint:** this package must contain zero AGT-specific code (R3.2). It knows ACS and hookmaps, nothing else. + +S1 maps Claude Code hook names to ACS methods and ACS decisions to Claude Code outputs: +```yaml +host: claude-code +hooks: + PreToolUse: + acs_method: steps/toolCallRequest + tool_name: $.tool_name + arguments: $.tool_input +decisions: + allow: { permissionDecision: allow } + deny: { permissionDecision: deny, reason_from: reasoning } + ask: { permissionDecision: ask } + defer: { permissionDecision: defer } + modify: { permissionDecision: allow, updatedInput_from: modifications } +``` + +- [ ] **Step 1: Write the failing test** — a real Claude Code `PreToolUse` payload produces an envelope that validates against `request-envelope.json`; `method` is `steps/toolCallRequest`; `arguments` are wrapped as `{value}`; `request_id` is a UUID; `session_id` is carried; and `buildEnvelope` is driven by the hookmap (an unmapped hook name throws rather than defaulting). +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N2, S1` + +--- + +### Task 8: `guardianClient.post`, `handshake`, `renderDecision` · slice #2 · N3, N4, N5, S13 + +**Files:** Create `packages/host-adapter/src/guardian-client.ts`, `packages/host-adapter/src/render-decision.ts`, `packages/host-adapter/src/session-config.ts`; Test `packages/host-adapter/test/client.test.ts`, `packages/host-adapter/test/render-decision.test.ts` + +`renderDecision` (N3) turns an ACS decision into Claude Code's `hookSpecificOutput` **using S1** — no hardcoded dispatch. A `deny` must place the ACS `reasoning` into `permissionDecisionReason`; that string is what U2 renders, and it is the demo's payoff. + +S13 stores the negotiated config. V1 stores it only; N6/N7 consume it in V3. + +- [ ] **Step 1: Write the failing test** — `deny` renders `{hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"deny", permissionDecisionReason:""}}`; `allow` renders `permissionDecision:"allow"`; a `warn`-derived allow (allow + non-empty `policy_references`) still renders `allow`; `handshake()` stores `timeout_config` and `on_decision_failure` into S13; and the client posts JSON-RPC 2.0 with a matching `id`. +- [ ] **Step 2–5** as above. Commit — `Slice: #2` / `Affordances: N3, N4, N5, S13` + +--- + +### Task 9: The Claude Code hook shim and the demo · slice #2 · N1, U1, U2 + +**Files:** Create `hosts/claude-code/acs-hook.ts`, `hosts/claude-code/settings.json`, `docs/demos/v1-runbook.md`; Test `hosts/claude-code/test/hook.test.ts` + +N1 is deliberately thin: read hook JSON on stdin, call `buildEnvelope` → `guardianClient.post` → `renderDecision`, write `hookSpecificOutput` to stdout. All logic lives in the adapter so V5 can reuse it unchanged. + +- [ ] **Step 1: Write the failing test** — spawn the shim as a subprocess against a live Guardian, feed a real `PreToolUse` payload for `rm -rf /` on stdin, and assert stdout parses to `permissionDecision: "deny"` with `permissionDecisionReason` containing the deny reason; repeat with `ls -la` for `allow`; assert exit code 0 in both cases. +- [ ] **Step 2–5** as above. The runbook states the demo in the slice's own words and names what a viewer should watch for. Commit — `Slice: #2` / `Affordances: N1, U1, U2` + +--- + +### Task 10: The invariant gates · slice #2 · R3.2, R3.3, R7.1 + +The claims V1 exists to support, made mechanical so they cannot rot. + +**Files:** Create `test/invariants.test.ts`, `README.md` (quickstart section) + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Glob } from "bun"; + +const read = (dir: string) => + [...new Glob("**/*.ts").scanSync(dir)] + .filter((f) => !f.includes("/test/")) + .map((f) => ({ f, src: readFileSync(`${dir}/${f}`, "utf8") })); + +describe("architectural invariants", () => { + // R3.2 — this is the claim the whole M×N argument rests on. + it("host adapter contains zero AGT-specific code", () => { + for (const { f, src } of read("packages/host-adapter/src")) { + for (const term of ["agt", "AgentControl", "rego", "opa", "intervention_point", "verdict"]) { + expect({ file: f, term, found: new RegExp(term, "i").test(src) }) + .toEqual({ file: f, term, found: false }); + } + } + }); + + // R3.3 — and this is what makes V5 cost zero AGT code. + it("AGT bridge contains zero host-specific code", () => { + for (const { f, src } of read("packages/agt-bridge/src")) { + for (const term of ["claude", "opencode", "hookSpecificOutput", "permissionDecision", "stdin"]) { + expect({ file: f, term, found: new RegExp(term, "i").test(src) }) + .toEqual({ file: f, term, found: false }); + } + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect FAIL** (or pass trivially if earlier tasks were disciplined — either way it is now enforced). +- [ ] **Step 3: Implementation** — fix any leak the gate catches; write the README quickstart proving R7.1 (`bun install && bun run guardian` then launch Claude Code with the hook). +- [ ] **Step 4: Run it, expect PASS** — plus a full `bun test`. +- [ ] **Step 5: Commit** — `Slice: #2` / `Affordances: R3.2, R3.3, R7.1` + +--- + +## Cross-slice work in this plan + +| Task | Belongs to | Why it must happen here | +|---|---|---| +| Task 3 — `mapping.yaml` point table for all 8 AGT points | V7 (C1/N41) | S10 is one file read by both runtime and harness. Writing only the `pre_tool_call` row would mean rewriting the file's shape in V7, and the shaping doc names S10 as the design choice that makes C1 a contract. The rows are data; only `pre_tool_call` is exercised in V1. | +| Task 6 — `on_decision_failure` in ServerHello | V3 (N6) | The handshake is a V1 affordance (N5/N28) and the field is part of ServerHello. V1 negotiates and stores; V3 applies. Storing without applying is the slice boundary, not a gap. | + +## Scope added during planning + +| What | Why the slice cannot ship without it | Slices-doc amendment | +|---|---|---| +| `policy/lib/data.json` inside the bundle | `data_paths` cannot deliver `data.agt.defaults.config` when `bundle:` is set (C3). Without this the policy is inert and the demo silently allows. | S8 row rewritten | +| Bundle-path guard in `createBridge` + a test asserting deny | The `/./` landmine (C2) fails open silently. An assertion that "deny is not allow" is the only thing standing between the demo and a policy that quietly does nothing. | New watch-for + risk row | +| Our own destructive-command regex list | The stock bundle ships none (C4). | Demo framing corrected | +| Task 10 invariant gates | R3.2/R3.3 are "verifiable by inspection" in the shaping doc. Inspection rots; a test does not. | Noted under V1 | +| `scripts/verify-pin.sh` | R2.2/R2.3 claim an unforked pinned engine. Nothing enforced it. | S9/S11 rows | + +--- + +## Risks carried into execution + +| # | Risk | Handling | +|---|---|---| +| 1 | The `/./` landmine reappears via a different path join | `createBridge` throws on `/./`; Task 2's deny test is the backstop | +| 2 | Upstream AGT moves and `data.json`-in-bundle stops working | `agt.lock` pins the ref; V8 watches the surface | +| 3 | Bun's napi support regresses on the AGT addon | Verified loading under both bun and node; pnpm + node is the fallback and costs only the package manager | +| 4 | `enforced_identity` bisection unverified end-to-end | Task 2 asserts both fields are present and well-formed; true bisection needs a `transform` verdict and lands in V3/V7 | diff --git a/hosts/claude-code/acs-hook.ts b/hosts/claude-code/acs-hook.ts new file mode 100644 index 00000000..d14fc9b7 --- /dev/null +++ b/hosts/claude-code/acs-hook.ts @@ -0,0 +1,146 @@ +/** + * acs-hook.ts -- Claude Code's PreToolUse hook shim. + * + * Deliberately thin: read the hook JSON Claude Code sends on stdin, call + * buildEnvelope -> createGuardianClient(...).requestDecision -> renderDecision + * (all three from `host-adapter`, packages/host-adapter), wrap what comes + * back into the JSON Claude Code expects, and write it to stdout. All logic lives + * in the adapter -- this file is only the wiring a Claude Code hook process + * needs (stdin, stdout, exit code, which hookmap file to load) plus the one + * thing the adapter must not know: this host's own output shape. A second + * host is another shim this thin against the same, unchanged adapter, so any + * logic added here is logic that host would have to duplicate. + * + * This file is host-specific by definition (it may name Claude Code + * freely) but must not reach into AGT -- it never imports `agt-bridge` or + * `guardian`'s server-side pieces, only `host-adapter`'s public surface, + * and talks to the Guardian only over HTTP, through the client role + * `createGuardianClient` returns. + * + * Claude Code's hook protocol (see the task brief and + * docs/demos/v1-runbook.md): stdin is one JSON object + * `{ session_id, transcript_path, cwd, hook_event_name, tool_name, + * tool_input }`; stdout is one JSON object + * `{ hookSpecificOutput: { hookEventName, permissionDecision, ... } }`; + * the process **always exits 0** for a real decision -- "deny" travels in + * the JSON body, not the exit code. Exit 2 means "blocking error" to + * Claude Code and exit 1 means "non-blocking error"; neither is how a + * policy deny is expressed, so getting this wrong would make a deny look + * like a crash. + * + * Guardian-unreachable handling is a placeholder, not a considered posture. + * If anything above throws -- the Guardian is down, it returns a JSON-RPC + * error, or the stdin payload is malformed -- this shim writes the error to + * stderr and exits 1 ("non-blocking error" per the hook protocol) with + * nothing on stdout, so Claude Code proceeds as though the hook had not + * fired. Negotiating a fail-open or fail-closed posture, and auditing a + * bypass when one is taken, is not implemented here yet. + */ +import { fileURLToPath } from "node:url"; +import { buildEnvelope, createGuardianClient, loadHookmap, renderDecision, type HostOutput } from "host-adapter"; + +const HOOKMAP_PATH = fileURLToPath(new URL("./claude-code.hookmap.yaml", import.meta.url)); + +// Matches packages/guardian/src/main.ts's own default port -- the runbook +// and this shim agree on 8787 without either hardcoding the other's value. +// Override with ACS_GUARDIAN_URL when the Guardian runs on a different +// host/port (e.g. in tests, which start a Guardian on an ephemeral port). +const DEFAULT_GUARDIAN_URL = "http://localhost:8787/acs"; + +/** + * Claude Code's own output shape, which lives here and nowhere else. + * + * The adapter renders into a shape it does not name: `renderDecision` reads + * the hookmap's dotted output paths and assembles the object they describe, + * knowing ACS decisions and nothing about this host. That is what lets a + * second host arrive as a shim and a hookmap rather than a fork of the + * shared module -- and it is only true while these names appear on this side + * of the seam. The two inside the wrapper appear as data in + * claude-code.hookmap.yaml's output paths; here the wrapper itself is the one + * name this shim needs, because it is the shim that wraps. + */ +const HOOK_SPECIFIC_OUTPUT = "hookSpecificOutput"; + +/** + * Wraps the adapter's host-agnostic output into the exact JSON Claude Code + * expects, adding the one field that is not a function of the decision: the + * name of the hook that asked. + * + * `hookEventName` is deliberately not in the hookmap. Every output field + * declared there is copied from the decision or is a literal; this one is + * neither -- it is the raw host event name that produced the original + * request, the same string passed to buildEnvelope, carried through + * unchanged. Adding it here keeps the adapter's contract exactly "the output + * is a function of the decision and the hookmap". + * + * It goes first, and any field the hookmap declared under the wrapper + * follows; a field the hookmap declared OUTSIDE the wrapper (a top-level key + * alongside it) travels untouched, which is the second half of what the + * generic output shape bought. + * + * A rendered output with no wrapper object in it is a throw rather than a + * repair: writing `{"hookSpecificOutput":{"hookEventName":"PreToolUse"}}` + * would hand Claude Code JSON it reads as no decision at all, and it would + * then let the tool call proceed. Half an output is the one thing this hook + * must never write. + */ +function asClaudeCodeOutput(rendered: HostOutput, hookEventName: string): HostOutput { + const wrapper = rendered[HOOK_SPECIFIC_OUTPUT]; + if (typeof wrapper !== "object" || wrapper === null || Array.isArray(wrapper)) { + throw new Error( + `acs-hook: the rendered output has no "${HOOK_SPECIFIC_OUTPUT}" object for Claude Code to read a ` + + `decision from, so there is no output this host could honestly write`, + ); + } + return { ...rendered, [HOOK_SPECIFIC_OUTPUT]: { hookEventName, ...(wrapper as Record) } }; +} + +/** + * Turns whatever stood in for a decision into one line of stderr. `failure` is + * `unknown` by design -- it is a throw from the wire, a JSON-RPC `error` + * object, or an Error this shim never constructed -- so this formats all three + * rather than assuming any one of them. + */ +function describeFailure(failure: unknown): string { + if (failure instanceof Error) { + return failure.message; + } + return typeof failure === "string" ? failure : JSON.stringify(failure); +} + +async function main(): Promise { + const input = await Bun.stdin.text(); + const payload = JSON.parse(input) as Record; + + const hookEventName = payload.hook_event_name; + if (typeof hookEventName !== "string") { + throw new Error('acs-hook: stdin payload is missing a string "hook_event_name" field'); + } + + const hookmap = loadHookmap(HOOKMAP_PATH); + const envelope = buildEnvelope(hookEventName, payload, hookmap); + + const guardian = createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL); + + // Told whether a decision arrived, rather than handed a JSON-RPC bag to + // interrogate. This shim never reads `.error`, casts `.result`, or decides + // which of those means "no decision" -- getting that branch wrong is a + // fail-open, and a second host would inherit it by copying this file. + const outcome = await guardian.requestDecision(envelope); + if (!outcome.decisionArrived) { + // Placeholder: this throw lands in main().catch below, which exits 1. + // Deciding what a delivery failure means -- the negotiated fail-open or + // fail-closed posture, and auditing a bypass when one is taken -- is not + // implemented here. See this file's header. + throw new Error(`acs-hook: no decision arrived from the Guardian: ${describeFailure(outcome.failure)}`); + } + + const rendered = renderDecision(outcome.decision, hookmap); + + process.stdout.write(JSON.stringify(asClaudeCodeOutput(rendered, hookEventName))); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/hosts/claude-code/claude-code.hookmap.yaml b/hosts/claude-code/claude-code.hookmap.yaml new file mode 100644 index 00000000..a0f3551f --- /dev/null +++ b/hosts/claude-code/claude-code.hookmap.yaml @@ -0,0 +1,84 @@ +# S1 -- the ONLY per-host artifact besides the hook shim (N1, Task 9). +# +# Pure data: maps Claude Code's own hook names and payload shape onto ACS +# methods/arguments (the `hooks` block, read by buildEnvelope / N2), and +# maps ACS decisions onto the output Claude Code reads back (the `decisions` +# block, read by renderDecision / N3, Task 8). +# +# `tool_name` and `arguments` are JSONPath-lite references (`$.`) +# into the raw hook payload Claude Code delivers on stdin for this event -- +# see the real PreToolUse payload shape in the Task 7 brief. +host: claude-code +hooks: + PreToolUse: + acs_method: steps/toolCallRequest + tool_name: $.tool_name + arguments: $.tool_input +# PR #10 review, Critical: the `decisions` block below is now where Claude +# Code's output field names live, and they appear nowhere in the adapter. +# Each entry declares an `output` block whose keys are dotted paths into the +# JSON object this host writes on stdout, and whose values say where each +# field's content comes from -- `value:` for a literal, `from:` for a field +# of the arriving ACS decision (copied only when the decision carries it, +# and only when it has the declared `type:`). +# +# The adapter assembles that object without knowing one name in it, which is +# what makes slice V5's second host a shim and a hookmap rather than a fork +# of the shared module. A host that nests its decision differently, or names +# a field alongside the wrapper rather than inside it, or has no +# permission-style field at all, writes different paths here and changes no +# code. +# +# `hookEventName` is deliberately NOT declared here: it is not derived from +# the decision, it is the name of the hook that asked, and acs-hook.ts adds +# it as it wraps. Everything below is a function of the decision alone. +# +# `type: string` on every reason field is what keeps a Guardian that puts a +# non-string in `reasoning` from producing an output Claude Code cannot read: +# the field is left off instead, exactly as it is for a decision that carries +# no reasoning at all. It is where the old `reason_from` rule's own +# `typeof === "string"` check went, now declared rather than hardcoded. +# +# WHAT IS DELIBERATELY UNCHANGED HERE. `allow` and `modify` still name no +# reason field, so an allow or a policy-ordered rewrite that carries +# `reasoning` says nothing in the transcript a human reads, and `modify` +# still hands `updatedInput` ACS's raw `modifications` object rather than a +# tool input Claude Code can apply. Both are real gaps and both belong to the +# slice that owns dispositions (V3, N27/N6/N7); this change moves where the +# field names live and changes no rendering. +# hosts/claude-code/test/wire-shape.test.ts pins every one of these outputs +# as a literal, so whichever slice fixes them has to say so there. +# +# Fix wave finding 5: Claude Code's PreToolUse output only accepts +# permissionDecision "allow" | "deny" | "ask" (see Claude Code's own hook +# docs) -- there is no "defer" value on the wire, so a `defer` entry +# declaring `permissionDecision: defer` was never a legal Claude Code +# output. It was unreachable in V1 (no verdict in mapping.yaml produces an +# ACS "defer" decision today) but would have broken the moment one did. +# ACS's own defer-details.json defaults an unresolved defer's +# `timeout_decision` to "deny", so `deny` is the spec-consistent fail-closed +# choice for a host (like Claude Code) with no native defer/pending state -- +# NOT a considered posture, just the least-wrong mapping until a host that +# can actually represent deferral exists. V3, which owns posture and +# disposition decisions (N27, N6/N7), should treat this as a known gap +# rather than rediscovering it. +decisions: + allow: + output: + hookSpecificOutput.permissionDecision: { value: allow } + deny: + output: + hookSpecificOutput.permissionDecision: { value: deny } + hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + ask: + output: + hookSpecificOutput.permissionDecision: { value: ask } + hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + defer: + output: + hookSpecificOutput.permissionDecision: { value: deny } + hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + modify: + output: + hookSpecificOutput.permissionDecision: { value: allow } + hookSpecificOutput.updatedInput: { from: modifications } diff --git a/hosts/claude-code/package.json b/hosts/claude-code/package.json new file mode 100644 index 00000000..b6e64607 --- /dev/null +++ b/hosts/claude-code/package.json @@ -0,0 +1,12 @@ +{ + "name": "claude-code-host", + "private": true, + "type": "module", + "version": "0.1.0", + "dependencies": { + "host-adapter": "workspace:*" + }, + "devDependencies": { + "guardian": "workspace:*" + } +} diff --git a/hosts/claude-code/settings.json b/hosts/claude-code/settings.json new file mode 100644 index 00000000..18016bea --- /dev/null +++ b/hosts/claude-code/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bun run \"$CLAUDE_PROJECT_DIR/hosts/claude-code/acs-hook.ts\"" + } + ] + } + ] + } +} diff --git a/hosts/claude-code/test/hook.test.ts b/hosts/claude-code/test/hook.test.ts new file mode 100644 index 00000000..85203d32 --- /dev/null +++ b/hosts/claude-code/test/hook.test.ts @@ -0,0 +1,113 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { fileURLToPath } from "node:url"; +// Test-only import: stands up a real Guardian so this test proves the wire +// contract for real, not against a hand-copied shape -- same precedent as +// packages/host-adapter/test/client.test.ts. `host-adapter` here +// is used the same way the shim itself uses it, so this test also proves +// what a subprocess sees is what the adapter would have produced directly. +import { startGuardian, type StartedGuardian } from "guardian"; +import { buildEnvelope, createGuardianClient, loadHookmap, renderDecision, type Hookmap } from "host-adapter"; + +const SHIM_PATH = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); +const HOOKMAP_PATH = fileURLToPath(new URL("../claude-code.hookmap.yaml", import.meta.url)); + +/** The real PreToolUse payload shape Claude Code delivers on stdin, with + * `tool_name: "Bash"` -- what Claude Code actually sends, and what + * `policy/manifest.yaml` registers. */ +function preToolUsePayload(command: string): Record { + return { + session_id: "abc123", + transcript_path: "/path/to/transcript.jsonl", + cwd: "/current/dir", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }; +} + +/** Spawns the real shim as a subprocess -- exactly how Claude Code invokes + * it -- feeds it `payload` on stdin, and returns what it wrote once it has + * run to completion. */ +async function runHook( + payload: Record, + guardianUrl: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn({ + cmd: ["bun", "run", SHIM_PATH], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ACS_GUARDIAN_URL: guardianUrl }, + }); + proc.stdin.write(JSON.stringify(payload)); + proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} + +let guardian: StartedGuardian; + +beforeAll(async () => { + guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); +}); + +afterAll(async () => { + await guardian.close(); +}); + +describe("acs-hook.ts -- the Claude Code hook shim, run as a real subprocess", () => { + it("denies a real rm -rf / tool call: exit 0, clean JSON on stdout, and the policy's own reasoning in permissionDecisionReason", async () => { + const payload = preToolUsePayload("rm -rf /"); + + const { stdout, stderr, exitCode } = await runHook(payload, guardian.url); + + expect(exitCode).toBe(0); + expect(() => JSON.parse(stdout)).not.toThrow(); + const parsed = JSON.parse(stdout) as { hookSpecificOutput: Record }; + + expect(parsed.hookSpecificOutput.hookEventName).toBe("PreToolUse"); + expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny"); + const reason = parsed.hookSpecificOutput.permissionDecisionReason; + expect(typeof reason).toBe("string"); + expect((reason as string).length).toBeGreaterThan(0); + + // Cross-check against the real Guardian's own decision for the + // identical tool call, obtained independently of the subprocess (the + // exact composition the shim itself performs, called directly rather + // than through stdin/stdout) -- proves the shim relays the policy's + // actual text rather than a hardcoded placeholder, which a suite that + // only checked "reason is a non-empty string" would miss. This is the + // demo's entire payoff: what a human reads in the transcript. + const hookmap: Hookmap = loadHookmap(HOOKMAP_PATH); + const envelope = buildEnvelope("PreToolUse", payload, hookmap); + const response = await createGuardianClient(guardian.url).post(envelope); + expect(response.error).toBeUndefined(); + const expected = renderDecision( + response.result as { decision: string } & Record, + hookmap, + ) as { hookSpecificOutput: Record }; + + expect(reason).toBe(expected.hookSpecificOutput.permissionDecisionReason); + expect(stderr).toBe(""); + }); + + it("allows a real ls -la tool call: exit 0, and clean JSON on stdout with a plain allow", async () => { + const payload = preToolUsePayload("ls -la"); + + const { stdout, stderr, exitCode } = await runHook(payload, guardian.url); + + expect(exitCode).toBe(0); + expect(() => JSON.parse(stdout)).not.toThrow(); + const parsed = JSON.parse(stdout) as { hookSpecificOutput: Record }; + + expect(parsed.hookSpecificOutput).toEqual({ + hookEventName: "PreToolUse", + permissionDecision: "allow", + }); + expect(stderr).toBe(""); + }); +}); diff --git a/hosts/claude-code/test/wire-shape.test.ts b/hosts/claude-code/test/wire-shape.test.ts new file mode 100644 index 00000000..35220df7 --- /dev/null +++ b/hosts/claude-code/test/wire-shape.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "bun:test"; +import { fileURLToPath } from "node:url"; + +/** + * The regression pin for what this deployment actually puts on stdout. + * + * Every other test in this directory asserts one field at a time ("the + * decision is deny", "the reason is a non-empty string"), which is exactly the + * shape of assertion that survives an output quietly gaining, losing or moving + * a field. This file asserts the WHOLE JSON object, as a literal, for every + * decision the shipped hookmap declares -- allow, deny, ask, defer and modify + * -- so that any change to the rendered wire shape has to be a deliberate edit + * to the literals below rather than something a refactor can do by accident. + * + * It is written against the shim as a subprocess, the way Claude Code invokes + * it, and against the real hosts/claude-code/claude-code.hookmap.yaml. That is + * deliberate: it makes this pin independent of how the rendering is factored + * internally -- which module names the host's fields, which layer assembles + * the wrapper -- and dependent only on what a host process reads back. It is + * the contract; the factoring behind it is not. + * + * The Guardian here is a stub rather than the real one. The five decisions + * below are the five the hookmap knows how to render, and the shipped policy + * bundle only ever produces two of them; a pin that could only cover `allow` + * and `deny` would leave the three rarest renderings -- the ones nobody looks + * at, and the ones a careless change breaks first -- unpinned. + * + * The shim makes exactly one call, the step call. It does not handshake -- + * that is exercised from packages/host-adapter/test/client.test.ts instead -- + * so the stub answers one method and needs no ServerHello. If the shim is ever + * made to negotiate first, this stub grows a `handshake/hello` branch, and the + * assertions below should not move. + */ +const SHIM = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); + +/** + * Runs the real shim as a subprocess against a stub Guardian that answers the + * step call with `decision`, and returns the parsed JSON it wrote to stdout. + * + * Asserts exit 0 and a silent stderr first: a decision that renders correctly + * while the process also prints a stack trace, or exits non-zero, is not a + * pass -- and a failure there prints what was actually on stderr rather than + * an opaque JSON.parse error. + */ +async function renderedBy(decision: Record, command: string): Promise { + const stub = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: decision }); + }, + }); + try { + const proc = Bun.spawn(["bun", "run", SHIM], { + stdin: new TextEncoder().encode( + JSON.stringify({ + session_id: "sess-1", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }), + ), + env: { ...process.env, ACS_GUARDIAN_URL: `http://localhost:${stub.port}/acs` }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + expect({ exitCode: await proc.exited, stderr }).toEqual({ exitCode: 0, stderr: "" }); + return JSON.parse(stdout); + } finally { + stub.stop(true); + } +} + +describe("the wire shape this host writes to stdout, pinned decision by decision", () => { + it("renders a plain allow as a bare allow with no other field", async () => { + expect(await renderedBy({ decision: "allow", reason_codes: [], policy_references: [] }, "ls -la")).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }); + }); + + it("renders an allow that carries reasoning as a bare allow too -- this hookmap's allow names no reason field", async () => { + // The shape an observe-only upstream signal produces: an ACS allow with a + // synthesized explanation and non-empty policy_references. This hookmap's + // `allow` entry declares no reason source, so neither the + // explanation nor the references reach the transcript, and this rendering + // is indistinguishable from a plain allow. Pinned as it IS, not as it + // arguably should be: giving `allow` a reason is a behaviour change, and + // whichever slice decides to make it has to edit this literal to say so. + expect( + await renderedBy( + { + decision: "allow", + reasoning: "drift_score 0.9 reached threshold 0.5", + reason_codes: ["drift_detected"], + policy_references: [{ policy_id: "agt_stock", rule_id: "drift_detected" }], + }, + "ls -la", + ), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }); + }); + + it("renders a deny with the policy's own reasoning -- the demo's entire payoff", async () => { + expect( + await renderedBy({ decision: "deny", reasoning: "blocked by policy", reason_codes: ["destructive"] }, "rm -rf /"), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "blocked by policy", + }, + }); + }); + + it("renders an ask as an ask, carrying its reasoning", async () => { + expect( + await renderedBy({ decision: "ask", reasoning: "this needs a human" }, "curl example.com"), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: "this needs a human", + }, + }); + }); + + it("renders a defer as a deny -- this host has no deferral state", async () => { + expect( + await renderedBy({ decision: "defer", reasoning: "waiting on an out-of-band approval" }, "curl example.com"), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "waiting on an out-of-band approval", + }, + }); + }); + + it("renders a modify as an allow carrying the ACS modifications object verbatim as updatedInput", async () => { + // Verbatim, and that is a known V1 gap rather than a target: Claude Code's + // `updatedInput` is a tool-input object, and what lands here is ACS's + // `modifications` shape. The hookmap's `modify` entry names no reason + // source either, so a policy-ordered rewrite says nothing in the + // transcript. Both are pinned as they are; V3 owns the disposition work + // that would change them, and changing them means editing this literal. + expect( + await renderedBy( + { + decision: "modify", + reasoning: "redaction_applied", + modifications: { parameter_overrides: { command: "echo [REDACTED]" } }, + }, + "echo ghp_SECRET123456", + ), + ).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + updatedInput: { parameter_overrides: { command: "echo [REDACTED]" } }, + }, + }); + }); +}); diff --git a/mapping.yaml b/mapping.yaml new file mode 100644 index 00000000..9e711685 --- /dev/null +++ b/mapping.yaml @@ -0,0 +1,72 @@ +# mapping.yaml — the ACS↔AGT translation table. +# +# This file is the single source of truth for how an AGT verdict becomes an +# ACS decision. It is read by two consumers that must never disagree: +# 1. The runtime (packages/guardian/src/map-verdict.ts), at decision time. +# 2. The conformance harness (V7), which publishes a machine-checked +# ACS↔AGT mapping table from this same file. +# +# Any change to the verdict/field_synthesis tables below is a behavior +# change for both consumers. mapVerdict must implement exactly this table — +# no verdict names, decisions, or field derivations hardcoded in code that +# aren't declared here. + +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +# ACS intervention points AGT can be wired to. acs_method: null marks a +# point AGT supports that ACS v0.1.0 has no equivalent for (D4). +# +# READ BY THE RUNTIME, not only by V7 (PR #10 review, Critical). The Guardian +# resolves the AGT intervention point for an arriving ACS method out of this +# table (map-verdict.ts's resolveInterventionPoint, called from server.ts) -- +# it used to hardcode "pre_tool_call" while this table sat here claiming to be +# the mapping, so the two could disagree and nothing would fail. Changing a +# row below now changes which policy actually evaluates that method, which is +# the only condition under which V7's published matrix can be trusted. +# +# One method must map to at most one point: two rows naming the same +# acs_method is a throw, not a first-match win. +intervention_points: + pre_tool_call: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + output: { acs_method: "steps/agentResponse" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +# AGT decision -> ACS decision. decision values are lowercase on the wire +# (C7). require_policy_references marks a verdict for which a non-empty +# policy_references is load-bearing (R1.2): it is what distinguishes an +# observe-only allow (AGT warn) from a clean allow (AGT allow). +verdicts: + allow: { decision: allow } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +# AGT carries no rule_id / reason_codes / reasoning (C6) — its verdict is +# just {decision, reason, message, transform?, evidence?, result_labels?}, +# where `reason` is a single low-cardinality string and `message` is the +# human-facing text. ACS's richer fields are synthesized from those two. +# +# Each leaf is either: +# { source: "verdict." } — copy the field verbatim +# { source: "verdict.", wrap: array } — wrap the field in a +# single-element array +# { literal: } — a fixed value, not read +# from the verdict at all +field_synthesis: + reasoning: + source: verdict.message + reason_codes: + source: verdict.reason + wrap: array + policy_references: + rule_id: + source: verdict.reason + policy_id: + literal: agt_stock diff --git a/package.json b/package.json new file mode 100644 index 00000000..e20357a6 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "acs-reference-implementation", + "private": true, + "type": "module", + "workspaces": ["packages/*", "hosts/*"], + "scripts": { + "test": "bun test", + "typecheck": "tsc -p tsconfig.json --noEmit", + "verify:pin": "bash scripts/verify-pin.sh", + "guardian": "bun run packages/guardian/src/main.ts" + }, + "dependencies": { + "agent-control-specification": "0.3.1-beta.0" + }, + "devDependencies": { + "bun-types": "^1.3.14", + "typescript": "^7.0.2" + } +} diff --git a/packages/agt-bridge/package.json b/packages/agt-bridge/package.json new file mode 100644 index 00000000..89dd8140 --- /dev/null +++ b/packages/agt-bridge/package.json @@ -0,0 +1,11 @@ +{ + "name": "agt-bridge", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "agent-control-specification": "0.3.1-beta.0" + } +} diff --git a/packages/agt-bridge/src/index.ts b/packages/agt-bridge/src/index.ts new file mode 100644 index 00000000..43cbb1a8 --- /dev/null +++ b/packages/agt-bridge/src/index.ts @@ -0,0 +1,64 @@ +import { AgentControl } from "agent-control-specification"; + +export type AgtVerdict = { + decision: "allow" | "deny" | "warn" | "escalate" | "transform"; + reason?: string; + message?: string; + transform?: { path: string; value: unknown }; + result_labels?: string[]; +}; + +/** + * What any snapshot is, structurally: the policy input document, as JSON. + * + * A constraint, not a message. Each intervention point has its own snapshot + * shape (AGT-SNAPSHOT-1.0.md §2.5), which is the assembling caller's knowledge + * rather than this bridge's, so `PolicyBridge` is parameterised by the + * snapshot its holder actually sends and this alias is only the bound on that + * parameter. Used directly as a parameter type it would widen a named snapshot + * back to "any object" at the one seam it was built to cross. + */ +export type InterventionSnapshot = Record; + +/** + * The role the Guardian depends on: something you can tell to evaluate a + * snapshot at an intervention point, which answers with a verdict. + * + * AGT stays out of the name deliberately -- the Guardian depends on this role, + * not on AGT, and an invariant test keeps that honest in the other direction + * by holding AGT's vocabulary out of the Guardian. `Policy` says which bridge, + * since "bridge" alone would not survive a second one. + * + * `evaluate` answers with the verdict itself, not a result object carrying it: + * the SDK's `inputIdentity`, `enforcedIdentity` and `transformedPolicyTarget` + * describe its own evaluation rather than this step's outcome, and are + * confirmed against the SDK in this package's own test. + * + * The snapshot type parameter lets a holder declare which messages it sends + * (`PolicyBridge`) and be checked against that shape. + * `createBridge` answers with the general form, since it can evaluate any point. + */ +export type PolicyBridge = { + evaluate(point: string, snapshot: S): Promise; +}; + +/** + * Construct once at boot, evaluate per decision. Stateless: nothing is + * retained between evaluate() calls. + */ +export function createBridge(manifestPath: string): PolicyBridge { + if (manifestPath.includes("/./")) { + throw new Error( + `manifest path contains "/./": ${manifestPath}. AGT joins this verbatim and OPA ` + + `then drops the bundle's data document, silently disabling policy.`, + ); + } + const control = AgentControl.fromPath(manifestPath); + + return { + async evaluate(point: string, snapshot: InterventionSnapshot): Promise { + const result = await control.evaluateInterventionPoint(point as never, snapshot as never); + return result.verdict as AgtVerdict; + }, + }; +} diff --git a/packages/agt-bridge/test/bridge.test.ts b/packages/agt-bridge/test/bridge.test.ts new file mode 100644 index 00000000..bcc25a1d --- /dev/null +++ b/packages/agt-bridge/test/bridge.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { AgentControl } from "agent-control-specification"; +import { createBridge, type PolicyBridge } from "../src/index.ts"; + +const snapshotFor = (command: string) => ({ + envelope: { budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 } }, + tool_call: { name: "run_shell", args: { command }, id: "t1" }, +}); + +let bridge: PolicyBridge; +beforeAll(() => { bridge = createBridge("policy/manifest.yaml"); }); + +describe("agt-bridge", () => { + it("denies a destructive shell command using the stock bundle", async () => { + const verdict = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(verdict.decision).toBe("deny"); + expect(verdict.reason).toBe("destructive_shell_command_blocked"); + expect(verdict.message).toContain("matched pattern"); + }); + + it("denies the -fr spelling too", async () => { + const verdict = await bridge.evaluate("pre_tool_call", snapshotFor("rm -fr / --no-preserve-root")); + expect(verdict.decision).toBe("deny"); + }); + + it("allows benign commands", async () => { + for (const cmd of ["ls -la", "git status"]) { + expect((await bridge.evaluate("pre_tool_call", snapshotFor(cmd))).decision).toBe("allow"); + } + }); + + // The failure mode this catches is a SILENT fail-open: a manifest path + // containing "/./" makes OPA drop the bundle's data document. + it("surfaces the policy config to Rego (guards the ./ bundle-path landmine)", async () => { + const verdict = await bridge.evaluate("pre_tool_call", snapshotFor("rm -rf /")); + expect(verdict.decision).not.toBe("allow"); + }); + + // Why the bridge is Node, not Python. + // + // Asserted against the SDK DIRECTLY, not through `evaluate`. The claim is + // about what the Node SDK computes, and `PolicyBridge.evaluate` answers with + // a verdict rather than a bag carrying it, so routing this through the + // bridge would mean keeping three fields on every answer that nothing reads, + // just to assert one of them here. The subject of the claim is the SDK, so + // the subject of the test is too -- + // which is a stronger test, not a weaker one: it fails if the SDK stops + // returning distinct identities, where the old one could also fail for a + // change in this package's own pass-through. + it("the Node SDK returns input and enforced identity as distinct fields", async () => { + const control = AgentControl.fromPath("policy/manifest.yaml"); + const result = await control.evaluateInterventionPoint("pre_tool_call" as never, snapshotFor("ls -la") as never); + + expect(result.inputIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(result.enforcedIdentity).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it("createBridge's result satisfies the role", async () => { + // The `let bridge: PolicyBridge` annotation above is already the + // compile-time half of this claim; this is the runtime half, asserting the + // role's one method is the one being called throughout. + const asRole: PolicyBridge = createBridge("policy/manifest.yaml"); + + expect(typeof asRole.evaluate).toBe("function"); + expect((await asRole.evaluate("pre_tool_call", snapshotFor("rm -rf /"))).decision).toBe("deny"); + }); + + it("a caller that never touches createBridge can satisfy the role too", async () => { + // The property that matters for V5 and V7: the Guardian depends on + // something it can be told to evaluate, not on this package's factory. A + // stand-in written by hand type-checks and answers, with no AGT in it -- + // which is what makes `PolicyBridge` a role rather than a synonym for + // `ReturnType` (PR #10 review). + const standIn: PolicyBridge = { + async evaluate(point, snapshot) { + return { decision: "deny", reason: `${point}:${Object.keys(snapshot).sort().join(",")}` }; + }, + }; + + const verdict = await standIn.evaluate("pre_tool_call", snapshotFor("ls -la")); + expect(verdict.reason).toBe("pre_tool_call:envelope,tool_call"); + }); +}); diff --git a/packages/guardian/package.json b/packages/guardian/package.json new file mode 100644 index 00000000..dba7e8ab --- /dev/null +++ b/packages/guardian/package.json @@ -0,0 +1,13 @@ +{ + "name": "guardian", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "agt-bridge": "workspace:*", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } +} diff --git a/packages/guardian/src/acs-result.ts b/packages/guardian/src/acs-result.ts new file mode 100644 index 00000000..fa275147 --- /dev/null +++ b/packages/guardian/src/acs-result.ts @@ -0,0 +1,47 @@ +/** + * The message this Guardian sends back: an ACS final result, as + * response-envelope.json's `AcsResult` defines it. + * + * Structured as the decision PLUS the correlation fields rather than as a flat + * list of its own, because that is what it is: one ACS decision addressed to + * one request. Spreading `AcsDecision` in keeps a single source for the + * decision's own shape (map-verdict.ts), so a field added there cannot fail to + * be sendable from here. Typing it at all is what makes the outbound half of + * this seam as checked as the inbound one, where `validateEnvelope` narrows + * each arrival: without it, nothing confirms the envelope fields ACS requires + * beside the decision are present and belong to this request. + * + * `request_id` is the ACS correlation id from `params.request_id`, NOT the + * JSON-RPC `id`. The two are equal for every envelope `buildEnvelope` sends, + * and they are still different fields: the transport correlates by one and ACS + * correlates by the other, and response-envelope.json requires this one on the + * result object itself. + */ +import type { AcsDecision } from "./map-verdict.ts"; + +export type AcsFinalResult = { + /** response-envelope.json's discriminator. v0.1 emits only "final". */ + type: "final"; + acs_version: string; + request_id: string; +} & AcsDecision; + +/** + * Builds the final result for one request from the decision reached for it. + * + * Here rather than inline at the call site so the three correlation fields are + * read off the envelope's own params in exactly one place: an answer carrying + * some other request's `request_id` is a decision the host will either fail to + * correlate or, worse, correlate to the wrong step. + */ +export function finalResult( + params: { acs_version: string; request_id: string }, + decision: AcsDecision, +): AcsFinalResult { + return { + type: "final", + acs_version: params.acs_version, + request_id: params.request_id, + ...decision, + }; +} diff --git a/packages/guardian/src/assemble-snapshot.ts b/packages/guardian/src/assemble-snapshot.ts new file mode 100644 index 00000000..4d0c0849 --- /dev/null +++ b/packages/guardian/src/assemble-snapshot.ts @@ -0,0 +1,76 @@ +/** + * assemblePreToolCallSnapshot converts a validated ACS request envelope (method + * `steps/toolCallRequest`) into the AGT snapshot for `pre_tool_call`, + * shaped per AGT-SNAPSHOT-1.0.md §2.5. + * + * Envelope-only: this reads nothing but the envelope handed to it -- no + * session state, no chain hash, no prior decisions, no intent. + */ + +/** + * Re-exported from validate-envelope.ts so there is exactly one envelope shape + * rather than two that could silently diverge. The name means what it says: + * the tool-call view of a validated ACS request, reachable only through + * `isToolCallRequest`. So this function cannot be handed a `handshake/hello`, + * off which it would read a `params.payload.tool.name` that is not there. + */ +import type { ToolCallRequestEnvelope } from "./validate-envelope.ts"; +export type { ToolCallRequestEnvelope }; + +/** + * The AGT `pre_tool_call` snapshot. + * + * Named for the intervention point it is the snapshot FOR, because that is what + * fixes its shape -- AGT-SNAPSHOT-1.0.md §2.5 gives each point its own. A + * `post_tool_call` snapshot would be a sibling type beside this one, not a + * widening of it. + * + * `args` stays `Record` on purpose: those are the tool's own + * arguments, unwrapped from ACS's `{value, provenance}` shape, and their keys + * are the tool's business rather than this project's. + */ +export type AgtPreToolCallSnapshot = { + envelope: { + budgets: { + tool_call_count: number; + token_count: number; + elapsed_seconds: number; + cost_usd: number; + }; + }; + tool_call: { + name: string; + args: Record; + id: string; + }; +}; + +export function assemblePreToolCallSnapshot(envelope: ToolCallRequestEnvelope): AgtPreToolCallSnapshot { + const { payload, request_id } = envelope.params; + + // Unwrap every argument. AGT reads raw values -- args.command has to be a + // plain string for the stock pattern check's is_string guard, for instance -- + // so the ACS {value, provenance} wrapper does not survive into the snapshot. + const args: Record = {}; + for (const [key, wrapper] of Object.entries(payload.arguments)) { + args[key] = wrapper.value; + } + + return { + // budgets.rego fails closed on a present-but-wrong-typed counter, so + // these are always real zeros, never undefined/null. + envelope: { + budgets: { + tool_call_count: 0, + token_count: 0, + elapsed_seconds: 0, + cost_usd: 0, + }, + }, + tool_call: { + name: payload.tool.name, + args, + id: request_id, + }, + }; +} diff --git a/packages/guardian/src/handshake.ts b/packages/guardian/src/handshake.ts new file mode 100644 index 00000000..d06388a6 --- /dev/null +++ b/packages/guardian/src/handshake.ts @@ -0,0 +1,49 @@ +/** + * buildServerHello builds the Guardian's answer to `handshake/hello`, per + * handshake.json's ServerHello $def + * (spec/acs/specification/v0.1.0/handshake.json). + * + * `build`, not `negotiate`: this function never reads the incoming + * ClientHello. The host really does send one, but every field below is a + * constant returned unconditionally, so `negotiated_version` and + * `selected_transport` are DECLARED by this Guardian rather than agreed + * against what the client proposed. Real negotiation -- reading the + * ClientHello, picking a mutually supported version and transport, rejecting + * what isn't -- is future work; see docs/demos/v1-runbook.md. + * + * `methods_evaluated` is exactly the set this Guardian wires up. + * `on_decision_failure` ships the spec default, "proceed" (fail-open). This + * side only declares it on the wire; nothing here reads or acts on it. + */ + +export type ServerHello = { + negotiated_version: string; + methods_evaluated: string[]; + selected_transport: "http" | "https" | "stdio"; + timeout_config: { default_ms: number; per_method_ms?: Record }; + on_decision_failure: "proceed" | "deny"; +}; + +/** The ACS spec version every schema and mapping in this repo is pinned to. */ +const NEGOTIATED_VERSION = "0.1.0"; + +/** Only intervention point wired in V1 (mapping.yaml's pre_tool_call). */ +const METHODS_EVALUATED = ["steps/toolCallRequest"]; + +/** + * Deployment-chosen default; handshake.json's timeout_config.default_ms + * carries no spec-mandated number. 5s bounds worst-case added latency on a + * synchronous pre-tool-call decision without being so tight that a + * momentarily slow Guardian trips it. + */ +const DEFAULT_TIMEOUT_MS = 5000; + +export function buildServerHello(): ServerHello { + return { + negotiated_version: NEGOTIATED_VERSION, + methods_evaluated: METHODS_EVALUATED, + selected_transport: "http", + timeout_config: { default_ms: DEFAULT_TIMEOUT_MS }, + on_decision_failure: "proceed", + }; +} diff --git a/packages/guardian/src/index.ts b/packages/guardian/src/index.ts new file mode 100644 index 00000000..cd4d2de2 --- /dev/null +++ b/packages/guardian/src/index.ts @@ -0,0 +1,12 @@ +/** Public surface of the guardian package. */ +export { startGuardian, type StartGuardianOptions, type StartedGuardian } from "./server.ts"; +export { buildServerHello, type ServerHello } from "./handshake.ts"; +export { + validateEnvelope, + isToolCallRequest, + EnvelopeValidationError, + type AcsRequestEnvelope, + type ToolCallRequestEnvelope, +} from "./validate-envelope.ts"; +export { assemblePreToolCallSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; +export { loadMapping, mapVerdict, type Mapping, type AcsDecision } from "./map-verdict.ts"; diff --git a/packages/guardian/src/main.ts b/packages/guardian/src/main.ts new file mode 100644 index 00000000..f46e6b32 --- /dev/null +++ b/packages/guardian/src/main.ts @@ -0,0 +1,31 @@ +/** + * The Guardian's standalone CLI entrypoint -- `bun run guardian`. + * + * The tests use the Guardian in-process, calling `startGuardian` directly. The + * demo needs it as a long-running process a hook subprocess can reach over + * HTTP, so this file makes the runbook's "start the Guardian" step a real + * command. Not re-exported from `./index.ts`: this is a process entrypoint, + * not a library call. + * + * `ACS_GUARDIAN_PORT` defaults to 8787 -- the same default port + * `hosts/claude-code/acs-hook.ts` assumes for `ACS_GUARDIAN_URL` when that + * env var is unset, so the runbook and the shim agree without either + * hardcoding the other's value. + * + * `ACS_GUARDIAN_HOST` is left unset by default, which leaves startGuardian's + * loopback bind in place -- see server.ts's header for why an unauthenticated + * endpoint defaults to the narrowest bind. This is where a deployment that + * needs a routable one says so, since env is this process's configuration + * surface and server.ts reads none itself. + */ +import { startGuardian } from "./server.ts"; + +const DEFAULT_PORT = 8787; +const DEFAULT_MANIFEST_PATH = "policy/manifest.yaml"; + +const port = Number(process.env.ACS_GUARDIAN_PORT ?? DEFAULT_PORT); +const hostname = process.env.ACS_GUARDIAN_HOST; +const manifestPath = process.env.ACS_MANIFEST_PATH ?? DEFAULT_MANIFEST_PATH; + +const guardian = await startGuardian({ port, hostname, manifestPath }); +console.log(`Guardian listening at ${guardian.url}`); diff --git a/packages/guardian/src/map-verdict.ts b/packages/guardian/src/map-verdict.ts new file mode 100644 index 00000000..3b7a7fec --- /dev/null +++ b/packages/guardian/src/map-verdict.ts @@ -0,0 +1,151 @@ +import { readFileSync } from "node:fs"; +import type { AgtVerdict } from "agt-bridge"; + +export type AcsDecision = { + decision: "allow" | "deny" | "modify" | "ask" | "defer"; + reasoning?: string; + reason_codes?: string[]; + policy_references?: { policy_id: string; policy_version?: string; rule_id: string }[]; +}; + +type VerdictRule = { + decision: AcsDecision["decision"]; + require_policy_references?: boolean; +}; + +/** A leaf of field_synthesis that copies a verdict field verbatim. */ +type FieldSource = { source: string }; +/** A leaf of field_synthesis that wraps a scalar verdict field into a + * single-element array -- the only shape `verdict.reason` can take to satisfy + * ACS's `string[]`. `wrap` is required, not optional, so a string source is + * always wrapped and never cast. */ +type WrappedFieldSource = { source: string; wrap: WrapMode }; +type FieldLiteral = { literal: string }; + +/** The wrap modes this mapping can express. Named as a set so `applyWrap` + * can refuse everything outside it. */ +type WrapMode = "array"; + +export type Mapping = { + acs_version: string; + agt_version: string; + intervention_points: Record; + verdicts: Record; + field_synthesis: { + reasoning: FieldSource; + reason_codes: WrappedFieldSource; + policy_references: { + rule_id: FieldSource; + policy_id: FieldLiteral; + }; + }; +}; + +export function loadMapping(path: string): Mapping { + return Bun.YAML.parse(readFileSync(path, "utf8")) as Mapping; +} + +/** + * Resolves an ACS method to the AGT intervention point that answers it. + * + * mapping.yaml lists these the other way round: each entry is keyed by the AGT + * intervention point and names the ACS method it answers. This searches that + * table backwards, because a request arriving off the wire tells us its ACS + * method, and what we need in order to evaluate it is the AGT point. + * + * If the table names no point for the method, or names more than one, this + * throws rather than picking one. Falling back to a default would mean + * evaluating the wrong policy and then returning that answer as this step's + * decision. + */ +export function resolveInterventionPoint(acsMethod: string, mapping: Mapping): string { + const table = mapping.intervention_points; + if (typeof table !== "object" || table === null) { + throw new Error( + `mapping.yaml declares no intervention_points table, so ACS method "${acsMethod}" ` + + `cannot be resolved to an AGT intervention point`, + ); + } + + const points = Object.entries(table) + .filter(([, entry]) => typeof entry === "object" && entry !== null && entry.acs_method === acsMethod) + .map(([point]) => point); + + const [point, ...ambiguous] = points; + if (point === undefined) { + throw new Error( + `mapping.yaml's intervention_points table maps no AGT intervention point to ACS method "${acsMethod}"`, + ); + } + if (ambiguous.length > 0) { + // A table that answers one method with two points has no single right + // answer, and picking the first would make the choice depend on YAML key + // order. Loud beats arbitrary. + throw new Error( + `mapping.yaml's intervention_points table maps ACS method "${acsMethod}" to more than one ` + + `AGT intervention point: ${[point, ...ambiguous].join(", ")}`, + ); + } + return point; +} + +/** Resolves a field_synthesis `source: "verdict."` path against a verdict. */ +function readVerdictField(verdict: AgtVerdict, source: { source: string }): unknown { + const field = source.source.slice("verdict.".length) as keyof AgtVerdict; + return verdict[field]; +} + +/** + * Applies a field_synthesis leaf's declared `wrap` to the string it read. + * + * An unrecognised mode throws rather than falling back to array-wrapping. + * `loadMapping` parses the YAML without validating it, so `wrap` can be + * anything the file happens to say; wrapping it anyway would build a + * `reason_codes` list the mapping never asked for and hand it to the host as + * the machine-readable half of the decision. The Guardian catches the throw + * and denies the step instead, which is the honest answer when it cannot carry + * out the mapping it was given. + */ +function applyWrap(value: string, wrap: WrapMode, leaf: string): string[] { + if (wrap !== "array") { + throw new Error( + `mapping.yaml declares field_synthesis.${leaf}.wrap as ${JSON.stringify(wrap)}, but this mapping can ` + + `only express "array"`, + ); + } + return [value]; +} + +export function mapVerdict(verdict: AgtVerdict, mapping: Mapping): AcsDecision { + const rule = mapping.verdicts[verdict.decision]; + if (!rule) { + throw new Error(`mapping.yaml has no verdict rule for AGT decision "${verdict.decision}"`); + } + + const fs = mapping.field_synthesis; + const out: AcsDecision = { decision: rule.decision }; + + const reasoning = readVerdictField(verdict, fs.reasoning); + if (typeof reasoning === "string") { + out.reasoning = reasoning; + } + + const reasonForCodes = readVerdictField(verdict, fs.reason_codes); + if (typeof reasonForCodes === "string") { + out.reason_codes = applyWrap(reasonForCodes, fs.reason_codes.wrap, "reason_codes"); + } + + const ruleId = readVerdictField(verdict, fs.policy_references.rule_id); + if (typeof ruleId === "string") { + out.policy_references = [{ policy_id: fs.policy_references.policy_id.literal, rule_id: ruleId }]; + } + + if (rule.require_policy_references && !(out.policy_references && out.policy_references.length > 0)) { + throw new Error( + `mapping.yaml declares require_policy_references for AGT decision "${verdict.decision}", ` + + `but no policy_references could be synthesized (verdict.reason was empty)`, + ); + } + + return out; +} diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts new file mode 100644 index 00000000..1a9fae27 --- /dev/null +++ b/packages/guardian/src/server.ts @@ -0,0 +1,249 @@ +/** + * startGuardian serves the ACS wire boundary: a single JSON-RPC 2.0 endpoint, + * POST /acs. The spec mandates no URL path convention -- "/acs" is this + * project's own choice -- so dispatch inside the handler is by the JSON-RPC + * `method` field, never by URL path. + * + * The sequence for `steps/toolCallRequest` is: validateEnvelope -> + * assemblePreToolCallSnapshot -> bridge.evaluate at the intervention point + * resolveInterventionPoint picked -> mapVerdict -> response envelope. + * + * The middle three run inside a try/catch, and that catch is load-bearing. + * Were a throw to escape this handler, Bun.serve would answer with its default + * error page, which is HTML rather than JSON-RPC. The host's client calls + * res.json() unconditionally, so an HTML body raises a SyntaxError there + * instead of surfacing a JSON-RPC error; the hook's catch-all then exits 1 + * with nothing on stdout, which Claude Code reads as "the hook never fired" + * and allows the tool call through ungoverned. That is a fail-open in a + * governance tool, so the catch must stay. + * + * What the catch guarantees is only that a well-formed JSON-RPC error reaches + * the client. It deliberately does not turn the failure into an ACS `deny`: + * which disposition an evaluation failure should carry is a separate question. + * + * The bridge and the mapping table are both built once, when startGuardian is + * called, rather than per request -- AGT is meant to be constructed at boot + * and evaluated statelessly. + * + * The listening socket defaults to loopback (127.0.0.1). Bun.serve with no + * `hostname` binds `*` -- every interface, dual-stack -- and this endpoint has + * no authentication, no origin check and no request signing, so anything that + * can open the port is both a policy oracle (ask it what would be allowed) + * and a policy sink (feed it envelopes it evaluates as if a governed host had + * sent them). Until the wire is authenticated, reachability IS the access + * control, so the default is the narrowest bind a host shim on the same + * machine can still reach. A deployment that genuinely needs a routable bind + * -- a Guardian in its own container, say -- opts in explicitly through the + * `hostname` option (main.ts reads ACS_GUARDIAN_HOST for it). + */ +import { fileURLToPath } from "node:url"; +import { createBridge, type PolicyBridge } from "agt-bridge"; +import { assemblePreToolCallSnapshot, type AgtPreToolCallSnapshot } from "./assemble-snapshot.ts"; +import { finalResult, type AcsFinalResult } from "./acs-result.ts"; +import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "./map-verdict.ts"; +import { + EnvelopeValidationError, + isToolCallRequest, + validateEnvelope, + type AcsRequestEnvelope, +} from "./validate-envelope.ts"; +import { buildServerHello, type ServerHello } from "./handshake.ts"; + +/** + * Every snapshot message this Guardian can send an intervention point. One + * member today; each gate this Guardian learns to assemble adds its own + * point-specific type here. + * + * Declared so the bridge seam carries the message rather than erasing it. + * `PolicyBridge` is parameterised by the snapshot its holder sends, and this + * is what this holder sends, so the `bridge.evaluate` calls below are checked + * against the assemblers' own output types instead of against any object at all. + */ +type GuardianSnapshot = AgtPreToolCallSnapshot; + +const MAPPING_PATH = fileURLToPath(new URL("../../../mapping.yaml", import.meta.url)); + +const HANDSHAKE_METHOD = "handshake/hello"; +const ACS_PATH = "/acs"; + +/** The default bind address -- see the module header for why it is loopback + * and not `*`. Spelled as the literal address rather than "localhost": the + * name resolves to both ::1 and 127.0.0.1, and Bun binds only one of them, + * so the name would make which interfaces are listening a property of the + * machine's resolver rather than of this line. */ +const LOOPBACK_ONLY = "127.0.0.1"; + +/** + * ACS reserves -32000..-32099 for application errors (Specification §17), + * but only enumerates named codes -32000..-32007 in the §17.1 registry + * (SESSION_REFUSED, UNSUPPORTED_VERSION, ...) -- none of which names "the + * envelope failed schema validation", "this method isn't dispatched by this + * Guardian", or "evaluation itself failed". Rather than reach for the + * generic JSON-RPC codes that would otherwise fit (-32602 Invalid params, + * -32601 Method not found), this module mints three codes from the unused + * part of the reserved band, so every application-level failure stays inside + * -32000..-32099. + */ +const ENVELOPE_INVALID_CODE = -32010; +const METHOD_NOT_DISPATCHED_CODE = -32011; +/** A throw from assemblePreToolCallSnapshot, bridge.evaluate, or mapVerdict -- + * mapVerdict's own require_policy_references check, say, or any AGT runtime + * error. The module header explains why this must never become dead code. */ +const EVALUATION_FAILED_CODE = -32020; + +type JsonRpcSuccess = { jsonrpc: "2.0"; id: string | number; result: AcsFinalResult | ServerHello }; +type JsonRpcFailure = { + jsonrpc: "2.0"; + id: string | number | null; + error: { code: number; message: string; data?: unknown }; +}; + +export type StartGuardianOptions = { + port: number; + manifestPath: string; + /** The address to bind. Defaults to loopback; set it only to widen the + * bind deliberately, and read the module header first -- the endpoint is + * unauthenticated, so widening it hands the policy decision to whoever can + * reach the port. main.ts threads ACS_GUARDIAN_HOST into this. */ + hostname?: string; + /** Overrides the mapping.yaml path this Guardian loads. Defaults to the + * repo's real mapping.yaml; exists so tests can inject a deliberately + * misconfigured mapping (e.g. require_policy_references on a decision + * AGT emits with no reason) to exercise the evaluation-failure catch in + * handleAcsRequest against a real bridge, without touching the mapping + * every other consumer reads. Not meant for production use. */ + mappingPath?: string; +}; +export type StartedGuardian = { url: string; close(): Promise }; + +export async function startGuardian({ + port, + hostname, + manifestPath, + mappingPath, +}: StartGuardianOptions): Promise { + // Construct the bridge once at boot, not per request. + const bridge = createBridge(manifestPath); + const mapping = loadMapping(mappingPath ?? MAPPING_PATH); + + const server = Bun.serve({ + hostname: hostname ?? LOOPBACK_ONLY, + port, + async fetch(req) { + const { pathname } = new URL(req.url); + if (req.method !== "POST" || pathname !== ACS_PATH) { + return new Response("Not Found", { status: 404 }); + } + const response = await handleAcsRequest(req, bridge, mapping); + return Response.json(response); + }, + }); + + return { + url: `http://localhost:${server.port}${ACS_PATH}`, + async close() { + await server.stop(true); + }, + }; +} + +async function handleAcsRequest( + req: Request, + // The role, not `ReturnType`: this handler depends on + // something it can tell to evaluate a snapshot, not on the shape one + // factory happens to return. + bridge: PolicyBridge, + mapping: Mapping, +): Promise { + let raw: unknown; + try { + raw = await req.json(); + } catch { + return errorResponse(null, -32700, "Parse error"); + } + + const rpcId = extractId(raw); + + let envelope: AcsRequestEnvelope; + try { + // validateEnvelope checks the general request-envelope.json shape for + // every method, plus -- only for steps/toolCallRequest -- the + // hook-specific payload schema. Failure is a thrown typed error, never a + // decision: we turn it into a bare JSON-RPC error below, rather than into + // {decision: "deny"}. + envelope = validateEnvelope(raw); + } catch (error) { + if (error instanceof EnvelopeValidationError) { + return errorResponse(rpcId, ENVELOPE_INVALID_CODE, error.message, { pointer: error.pointer }); + } + throw error; + } + + if (envelope.method === HANDSHAKE_METHOD) { + return successResponse(envelope.id, buildServerHello()); + } + + // `isToolCallRequest`, not a method comparison spelled out again here: the + // predicate lives beside the payload check it stands for + // (validate-envelope.ts), so this branch cannot come to disagree with the + // module that decided whether `params.payload` was validated as a tool + // call. It also narrows the envelope, which is what lets assemblePreToolCallSnapshot + // take the tool-call view rather than any request at all. + if (isToolCallRequest(envelope)) { + try { + const snapshot = assemblePreToolCallSnapshot(envelope); + // The intervention point comes from mapping.yaml's own + // `intervention_points` table rather than from a literal here, so the + // table cannot drift away from what the runtime actually does. + // An unresolvable method throws into the catch below rather than + // defaulting to a point -- evaluating the wrong policy and calling the + // result a decision is the one outcome worse than a reported failure. + const point = resolveInterventionPoint(envelope.method, mapping); + const verdict = await bridge.evaluate(point, snapshot); + const decision = mapVerdict(verdict, mapping); + + return successResponse(envelope.id, finalResult(envelope.params, decision)); + } catch (error) { + // See the module header. Deliberately a bare JSON-RPC error rather than + // an ACS `deny` decision -- all this guarantees is that the client gets + // a parseable envelope back instead of an HTML 500. + const message = error instanceof Error ? error.message : String(error); + return errorResponse(rpcId, EVALUATION_FAILED_CODE, `evaluation failed: ${message}`); + } + } + + // A well-formed envelope (it passed validateEnvelope: the method matched + // the required prefix pattern) naming a method this Guardian has no + // handler for -- distinct from a malformed envelope, which never reaches + // here because validateEnvelope already threw above. + return errorResponse(rpcId, METHOD_NOT_DISPATCHED_CODE, `method not dispatched by this Guardian: ${envelope.method}`, { + method: envelope.method, + }); +} + +function successResponse(id: string | number, result: AcsFinalResult | ServerHello): JsonRpcSuccess { + return { jsonrpc: "2.0", id, result }; +} + +function errorResponse( + id: string | number | null, + code: number, + message: string, + data?: unknown, +): JsonRpcFailure { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} + +/** Best-effort extraction of the request's JSON-RPC id for error responses, + * per response-envelope.json: id MUST be null when it cannot be determined, + * otherwise it matches the request id. Used only on failure paths -- a + * successful response always uses the schema-validated envelope's id. */ +function extractId(raw: unknown): string | number | null { + if (typeof raw === "object" && raw !== null && "id" in raw) { + const id = (raw as { id: unknown }).id; + if (typeof id === "string" || typeof id === "number") { + return id; + } + } + return null; +} diff --git a/packages/guardian/src/validate-envelope.ts b/packages/guardian/src/validate-envelope.ts new file mode 100644 index 00000000..d43243d4 --- /dev/null +++ b/packages/guardian/src/validate-envelope.ts @@ -0,0 +1,251 @@ +/** + * validateEnvelope checks an incoming ACS request envelope against the + * v0.1.0 JSON Schemas pinned in the spec/acs submodule + * (spec/acs/specification/v0.1.0/request-envelope.json), and -- for + * `steps/toolCallRequest` -- additionally against the hook-specific + * payload schema (hooks/tool-call-request.json). + * + * It returns an `AcsRequestEnvelope`: a request of ANY method, named the + * same thing the host's own builder names it + * (packages/host-adapter/src/build-envelope.ts). Narrowing to the tool-call + * view is a separate, explicit step (`isToolCallRequest`), so a method this + * module validated only generically can never be read as one whose + * hook-specific payload was checked. + * + * Failure is a thrown, typed EnvelopeValidationError -- never a returned + * decision. The Guardian server is the caller, and it is responsible for + * turning a thrown error into a JSON-RPC error response. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import type { ErrorObject, ValidateFunction } from "ajv"; + +/** An ACS argument wrapper: `{value, provenance?}`. */ +export type AcsArgument = { value: unknown; provenance?: unknown }; + +/** request-envelope.json's Metadata $def -- method-independent. */ +export type AcsRequestMetadata = { + agent_id: string; + agent_name?: string; + session_id: string; + turn_id?: string; + parent_turn_id?: string; + session_state?: { chain_hash?: string }; + environment?: "development" | "staging" | "production"; + platform?: string; + platform_version?: string; + user_context?: { user_id?: string; roles?: string[]; authentication_method?: string }; +}; + +/** + * request-envelope.json's AcsParams $def. `payload` is deliberately as open + * here as the schema itself leaves it -- "Hook-specific payload. Schema + * depends on method." -- because that is the honest shape of a request whose + * method has not been discriminated yet. + */ +export type AcsRequestParams = { + acs_version: string; + request_id: string; + timestamp: string; + nonce?: string; + tenant_id?: string; + metadata: AcsRequestMetadata; + payload: Record; + signature?: { algorithm: string; value: string; key_id: string }; +}; + +/** + * A validated ACS request of ANY method -- `handshake/hello`, + * `steps/toolCallRequest`, or anything else request-envelope.json's method + * pattern admits. This is what `validateEnvelope` returns, and it is the + * consumer half of the host's `AcsRequestEnvelope` + * (packages/host-adapter/src/build-envelope.ts): one wire message, one noun, + * on both sides of the seam. + * + * The narrower tool-call shape lives in `ToolCallRequestEnvelope` below, and + * is reachable only once the method has been checked -- so handshake traffic + * can never arrive under a name that claims it is a tool call. + */ +export type AcsRequestEnvelope = { + jsonrpc: "2.0"; + method: string; + id: string | number; + params: AcsRequestParams; +}; + +/** + * hooks/tool-call-request.json's payload shape -- the payload this module + * additionally validates when, and only when, the method is + * `steps/toolCallRequest`. + */ +export type ToolCallRequestPayload = { + tool: { name: string; version?: string; provider?: string }; + operation?: string; + capability?: string; + arguments: Record; + raw_command?: string; + intent?: { description?: string; goal?: string }; +}; + +/** + * The method-narrowed view of an `AcsRequestEnvelope`: same envelope, with + * `method` pinned to the one method whose payload has actually been validated + * against hooks/tool-call-request.json, and `payload` narrowed to that + * schema's shape. + * + * Reachable only through `isToolCallRequest` below, never returned by + * `validateEnvelope` directly: the narrow type is the conclusion of a method + * check, not the type every request is handed back as. + * + * Spelled with `Omit` rather than an intersection so that `params.payload` is + * exactly `ToolCallRequestPayload`. An intersection would type-check, but it + * would leave `Record`'s index signature in play, so a + * mistyped property name would quietly resolve to `unknown` instead of + * becoming an error. + */ +export type ToolCallRequestEnvelope = Omit & { + method: typeof TOOL_CALL_REQUEST_METHOD; + params: Omit & { payload: ToolCallRequestPayload }; +}; + +/** + * Thrown when an envelope fails schema validation. `pointer` is the JSON + * pointer, relative to the envelope root, of the first failing location, so + * that a human -- or the server's JSON-RPC error mapping -- can find the + * offending field without re-deriving it from `errors`. For `required` + * failures it is synthesized from Ajv's instancePath plus missingProperty, + * because Ajv reports those against the parent object rather than the missing + * child. + */ +export class EnvelopeValidationError extends Error { + readonly pointer: string; + readonly errors: ErrorObject[]; + + constructor(pointer: string, errors: ErrorObject[]) { + const detail = errors[0]?.message ?? "invalid"; + super(`ACS envelope failed schema validation at ${pointer}: ${detail}`); + this.name = "EnvelopeValidationError"; + this.pointer = pointer; + this.errors = errors; + } +} + +const SCHEMA_ROOT = fileURLToPath(new URL("../../../spec/acs/specification/v0.1.0/", import.meta.url)); +const REQUEST_ENVELOPE_SCHEMA_ID = "https://acs.org/schema/v0.1.0/request-envelope.json"; +const TOOL_CALL_REQUEST_SCHEMA_ID = "https://acs.org/schema/v0.1.0/hooks/tool-call-request.json"; +const TOOL_CALL_REQUEST_METHOD = "steps/toolCallRequest"; + +function listSchemaFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listSchemaFiles(full)); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + out.push(full); + } + } + return out; +} + +/** + * Builds one Ajv instance with every v0.1.0 schema registered under its own + * $id -- agbom/, hooks/, inspect/ and trace/ included -- so that the modular + * $refs between them resolve against $id rather than file path, the way the + * spec authors intended. One such ref is hooks/tool-call-request.json's + * argument.provenance pointing at "../provenance.json". Ajv resolves a + * relative $ref against the referencing schema's own $id as base URI, so + * registering everything up front is enough; nothing needs inlining or + * rewriting. + * + * strict:true stays on. Every v0.1.0 schema compiles cleanly under it once + * ajv-formats supplies the "uuid" and "date-time" format validators the + * schemas declare -- Ajv core recognizes no formats on its own, and strict + * mode would otherwise reject them as unknown. Nothing else needed disabling. + */ +function buildAjv() { + const ajv = new Ajv2020({ strict: true, allErrors: true }); + addFormats(ajv); + for (const file of listSchemaFiles(SCHEMA_ROOT)) { + const schema = JSON.parse(readFileSync(file, "utf8")) as { $id?: string }; + if (schema.$id && !ajv.getSchema(schema.$id)) { + ajv.addSchema(schema); + } + } + return ajv; +} + +let ajv: ReturnType | undefined; + +function getValidator(schemaId: string): ValidateFunction { + if (!ajv) { + ajv = buildAjv(); + } + const validate = ajv.getSchema(schemaId); + if (!validate) { + throw new Error(`validate-envelope: schema not registered: ${schemaId}`); + } + return validate; +} + +/** The JSON pointer an Ajv error names, honoring `required`'s + * missingProperty (Ajv reports the parent's instancePath there, not the + * missing child's). */ +function pointerOf(error: ErrorObject): string { + if (error.keyword === "required") { + const missingProperty = (error.params as { missingProperty: string }).missingProperty; + return `${error.instancePath}/${missingProperty}`; + } + return error.instancePath || "/"; +} + +function toValidationError(errors: ErrorObject[] | null | undefined, prefix: string): EnvelopeValidationError { + const list = errors ?? []; + const firstPointer = list[0] ? pointerOf(list[0]) : "/"; + return new EnvelopeValidationError(`${prefix}${firstPointer}`, list); +} + +/** + * Validates an incoming envelope against request-envelope.json, and -- + * only when `method` is `steps/toolCallRequest` -- additionally validates + * `params.payload` against hooks/tool-call-request.json. Returns the + * envelope, typed, on success. Throws EnvelopeValidationError on any + * failure; never returns a decision. + */ +export function validateEnvelope(input: unknown): AcsRequestEnvelope { + const validateTopLevel = getValidator(REQUEST_ENVELOPE_SCHEMA_ID); + if (!validateTopLevel(input)) { + throw toValidationError(validateTopLevel.errors, ""); + } + + const envelope = input as AcsRequestEnvelope; + + if (envelope.method === TOOL_CALL_REQUEST_METHOD) { + const validatePayload = getValidator(TOOL_CALL_REQUEST_SCHEMA_ID); + if (!validatePayload(envelope.params.payload)) { + throw toValidationError(validatePayload.errors, "/params/payload"); + } + } + + return envelope; +} + +/** + * The one way to get from a validated ACS request to the tool-call view of + * it. Narrows on `method`, which is exactly the condition under which + * `validateEnvelope` above validated `params.payload` against + * hooks/tool-call-request.json -- so the narrowing is not a convenient cast, + * it is a claim the same module already checked. + * + * Kept next to that check on purpose: the two must agree about which method + * carries a tool-call payload, and they can only be trusted to agree while + * they read the same constant in the same file. A second ACS method adds its + * own predicate and its own narrow type beside these, rather than widening + * either. + */ +export function isToolCallRequest(envelope: AcsRequestEnvelope): envelope is ToolCallRequestEnvelope { + return envelope.method === TOOL_CALL_REQUEST_METHOD; +} diff --git a/packages/guardian/test/assemble-snapshot.test.ts b/packages/guardian/test/assemble-snapshot.test.ts new file mode 100644 index 00000000..f63b733f --- /dev/null +++ b/packages/guardian/test/assemble-snapshot.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { createBridge } from "agt-bridge"; +import { assemblePreToolCallSnapshot, type ToolCallRequestEnvelope } from "../src/assemble-snapshot.ts"; + +function makeEnvelope(overrides: { + toolName?: string; + args?: Record; + requestId?: string; +} = {}): ToolCallRequestEnvelope { + const { + toolName = "run_shell", + args = { command: { value: "ls -la", provenance: { source: "user" } } }, + requestId = "8f14e45f-ceea-467e-bd5f-1d4d9a4e0c8f", + } = overrides; + + return { + jsonrpc: "2.0", + method: "steps/toolCallRequest", + id: 1, + params: { + acs_version: "0.1.0", + request_id: requestId, + timestamp: "2026-08-09T12:00:00Z", + metadata: { + agent_id: "agent-1", + session_id: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + session_state: { chain_hash: "deadbeef" }, + }, + payload: { + tool: { name: toolName }, + arguments: args, + }, + }, + } as unknown as ToolCallRequestEnvelope; +} + +describe("assemblePreToolCallSnapshot", () => { + it("maps params.payload.tool.name to tool_call.name, and unwraps each argument's .value into tool_call.args, dropping provenance", () => { + const envelope = makeEnvelope({ + toolName: "run_shell", + args: { + command: { value: "rm -rf /", provenance: { source: "user", timestamp: "2026-08-09T12:00:00Z" } }, + }, + }); + + const snapshot = assemblePreToolCallSnapshot(envelope); + + expect(snapshot.tool_call.name).toBe("run_shell"); + expect(snapshot.tool_call.args).toEqual({ command: "rm -rf /" }); + }); + + // AGT's stock pattern check reads input.policy_target.value and + // requires is_string. A surviving {value:...} wrapper (or a non-string) + // makes the check silently never fire, and everything is allowed. + it("keeps tool_call.args.command a STRING, not a nested wrapper or object", () => { + const envelope = makeEnvelope({ args: { command: { value: "rm -rf /" } } }); + + const snapshot = assemblePreToolCallSnapshot(envelope); + + expect(typeof snapshot.tool_call.args.command).toBe("string"); + expect(snapshot.tool_call.args.command).toBe("rm -rf /"); + }); + + it("always emits envelope.budgets with all four counters zeroed, even though the envelope says nothing about budgets", () => { + const envelope = makeEnvelope(); + + const snapshot = assemblePreToolCallSnapshot(envelope); + + expect(snapshot.envelope).toEqual({ + budgets: { tool_call_count: 0, token_count: 0, elapsed_seconds: 0, cost_usd: 0 }, + }); + }); + + it("carries params.request_id onto tool_call.id", () => { + const envelope = makeEnvelope({ requestId: "2c3e4f50-1234-4abc-9def-000000000000" }); + + const snapshot = assemblePreToolCallSnapshot(envelope); + + expect(snapshot.tool_call.id).toBe("2c3e4f50-1234-4abc-9def-000000000000"); + }); + + // Envelope-only: nothing is read from session state. Session id, chain + // hash, and every other session-derived key must not survive into the + // snapshot. + it("reads nothing but the envelope: no session-derived key appears anywhere in the output", () => { + const envelope = makeEnvelope(); + + // No cast: assemblePreToolCallSnapshot returns a named snapshot message now, so what + // these read is the type it declares rather than an anonymous dict. + const snapshot = assemblePreToolCallSnapshot(envelope); + + expect(Object.keys(snapshot).sort()).toEqual(["envelope", "tool_call"]); + expect(Object.keys(snapshot.envelope)).toEqual(["budgets"]); + expect(Object.keys(snapshot.tool_call).sort()).toEqual(["args", "id", "name"]); + + const serialized = JSON.stringify(snapshot); + for (const forbidden of ["session_id", "session_state", "chain_hash", "agent_id", "metadata", "intent", "1b9d6bcd"]) { + expect(serialized).not.toContain(forbidden); + } + }); + + // The high-value integration test: this is the first point in the build + // where the two halves (guardian's snapshot assembly, agt-bridge's real + // OPA evaluation) meet. + it("feeds a realistic rm -rf / envelope through the real AGT bridge and gets denied", async () => { + const envelope = makeEnvelope({ + toolName: "run_shell", + args: { command: { value: "rm -rf /", provenance: { source: "user" } } }, + }); + + const snapshot = assemblePreToolCallSnapshot(envelope); + const bridge = createBridge("policy/manifest.yaml"); + const verdict = await bridge.evaluate("pre_tool_call", snapshot); + + expect(verdict.decision).toBe("deny"); + }); +}); diff --git a/packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml b/packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml new file mode 100644 index 00000000..38dc50a3 --- /dev/null +++ b/packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml @@ -0,0 +1,41 @@ +# Test fixture only -- NOT read by production code. Identical to the real +# mapping.yaml except `allow` is (wrongly) marked require_policy_references, +# so a real AGT "allow" verdict for a benign command (which carries no +# `reason`/`message`, per bridge.test.ts) makes mapVerdict throw for real. +# +# Used by packages/guardian/test/server.test.ts (fix wave finding 1) to +# prove the Guardian's evaluation-failure catch turns a genuine +# assembleSnapshot/bridge.evaluate/mapVerdict throw into a well-formed +# JSON-RPC error in the ACS-reserved range, not an unhandled exception that +# Bun.serve would otherwise turn into a text/html 500. +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +intervention_points: + pre_tool_call: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + output: { acs_method: "steps/agentResponse" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +verdicts: + allow: { decision: allow, require_policy_references: true } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +field_synthesis: + reasoning: + source: verdict.message + reason_codes: + source: verdict.reason + wrap: array + policy_references: + rule_id: + source: verdict.reason + policy_id: + literal: agt_stock diff --git a/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml b/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml new file mode 100644 index 00000000..29d4e713 --- /dev/null +++ b/packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml @@ -0,0 +1,42 @@ +# Test fixture only -- NOT read by production code. Identical to the real +# mapping.yaml except one row moves: "steps/toolCallRequest" is answered by the +# `output` intervention point instead of `pre_tool_call`. +# +# Used by packages/guardian/test/server.test.ts (PR #10 review, Critical) to +# prove the runtime resolves the AGT intervention point out of THIS table +# rather than hardcoding "pre_tool_call". `output` is a point AGT knows and +# policy/manifest.yaml does not register, so honouring the table makes AGT fail +# closed with `runtime_error:intervention_point_unknown`, while ignoring it +# would evaluate pre_tool_call and allow a benign command. The two outcomes are +# opposite, so the assertion cannot pass both ways. +acs_version: "0.1.0" +agt_version: "0.3.1-beta" + +intervention_points: + output: { acs_method: "steps/toolCallRequest" } + post_tool_call: { acs_method: "steps/toolCallResult" } + agent_startup: { acs_method: "steps/sessionStart" } + agent_shutdown: { acs_method: "steps/sessionEnd" } + input: { acs_method: "steps/userMessage" } + pre_tool_call: { acs_method: null, note: "deliberately unmapped for this fixture" } + pre_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + post_model_call: { acs_method: null, note: "no ACS v0.1.0 target — D4, V7 red cell" } + +verdicts: + allow: { decision: allow } + deny: { decision: deny } + warn: { decision: allow, require_policy_references: true } + escalate: { decision: ask } + transform: { decision: modify } + +field_synthesis: + reasoning: + source: verdict.message + reason_codes: + source: verdict.reason + wrap: array + policy_references: + rule_id: + source: verdict.reason + policy_id: + literal: agt_stock diff --git a/packages/guardian/test/map-verdict.test.ts b/packages/guardian/test/map-verdict.test.ts new file mode 100644 index 00000000..25da9ebf --- /dev/null +++ b/packages/guardian/test/map-verdict.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "bun:test"; +import { loadMapping, mapVerdict, resolveInterventionPoint, type Mapping } from "../src/map-verdict.ts"; + +const m = loadMapping("mapping.yaml"); + +describe("mapVerdict", () => { + it("maps allow to allow", () => { + expect(mapVerdict({ decision: "allow" }, m).decision).toBe("allow"); + }); + + it("maps deny, carrying reason and message into ACS fields", () => { + const d = mapVerdict( + { decision: "deny", reason: "destructive_shell_command_blocked", message: "matched pattern X" }, + m, + ); + expect(d.decision).toBe("deny"); + expect(d.reasoning).toBe("matched pattern X"); + expect(d.reason_codes).toEqual(["destructive_shell_command_blocked"]); + expect(d.policy_references?.[0]?.rule_id).toBe("destructive_shell_command_blocked"); + }); + + // The whole warn round trip rests on this. + it("maps warn to allow WITH non-empty policy_references", () => { + const d = mapVerdict({ decision: "warn", reason: "drift_detected", message: "drift 0.8" }, m); + expect(d.decision).toBe("allow"); + expect(d.policy_references?.length).toBeGreaterThan(0); + expect(d.policy_references?.[0]?.rule_id).toBe("drift_detected"); + }); + + it("distinguishes warn-allow from clean allow by policy_references", () => { + expect(mapVerdict({ decision: "allow" }, m).policy_references ?? []).toHaveLength(0); + }); + + it("maps escalate to ask and transform to modify", () => { + expect(mapVerdict({ decision: "escalate", reason: "approval_required" }, m).decision).toBe("ask"); + expect(mapVerdict({ decision: "transform", reason: "redacted" }, m).decision).toBe("modify"); + }); + + it("emits only lowercase decisions", () => { + for (const dec of ["allow", "deny", "warn", "escalate", "transform"] as const) { + const out = mapVerdict({ decision: dec, reason: "r" }, m).decision; + expect(out.toLowerCase()).toBe(out); + } + }); + + // Fix wave finding 1 -- previously-deferred coverage gap: this throw path + // (require_policy_references marked true, but no policy_references could + // be synthesized) had no test. It's real: a "warn" verdict with no + // `reason` hits it directly, and it's exactly what the Guardian's + // evaluation-failure catch (server.test.ts) now has to survive without + // turning it into an HTML 500 or a silent decision. + it("throws when require_policy_references is set but verdict.reason is empty", () => { + expect(() => mapVerdict({ decision: "warn" }, m)).toThrow(/require_policy_references/); + }); + + // PR #10 review, second pass: `field_synthesis.reason_codes.wrap` was + // required on the type and written in mapping.yaml while mapVerdict built + // `[value]` from a literal -- the same "declared but unread" defect the + // hardcoded `pre_tool_call` was. These read the declaration rather than the + // literal, so an edit to the table changes behaviour. + describe("field_synthesis.reason_codes.wrap is read, not assumed", () => { + it("wraps per the declared mode, on the shipped mapping", () => { + expect(mapVerdict({ decision: "deny", reason: "r" }, m).reason_codes).toEqual(["r"]); + }); + + it("throws for a wrap mode this mapping cannot express, rather than array-wrapping anyway", () => { + // loadMapping casts the parsed YAML and validates nothing, so a mapping + // declaring an unknown mode type-checks and reaches mapVerdict. Silently + // array-wrapping it would synthesize a reason_codes the mapping never + // asked for and hand it to a host as a decision's machine-readable half. + const unknownMode = { + ...m, + field_synthesis: { ...m.field_synthesis, reason_codes: { source: "verdict.reason", wrap: "csv" } }, + } as unknown as Mapping; + + expect(() => mapVerdict({ decision: "deny", reason: "r" }, unknownMode)).toThrow( + /field_synthesis\.reason_codes\.wrap as "csv"/, + ); + }); + }); +}); + +// PR #10 review, Critical: the intervention_points table used to be a claim +// nobody checked -- declared here, hardcoded in server.ts. These read the real +// mapping.yaml, so a row edited there without a matching runtime change fails +// somewhere rather than nowhere. +describe("resolveInterventionPoint", () => { + it("answers the ACS method the shipped mapping wires, from the table rather than a literal", () => { + expect(resolveInterventionPoint("steps/toolCallRequest", m)).toBe("pre_tool_call"); + expect(resolveInterventionPoint("steps/agentResponse", m)).toBe("output"); + }); + + it("throws for a method no row names, rather than defaulting to a point", () => { + // The fail-open this function is shaped against: any default here would + // evaluate one intervention point's policy for a different method's + // snapshot and call the result a decision. + expect(() => resolveInterventionPoint("steps/userMessage", { ...m, intervention_points: {} })).toThrow( + /maps no AGT intervention point/, + ); + }); + + it("throws when two rows name the same ACS method, rather than letting YAML key order pick", () => { + const ambiguous = { + ...m, + intervention_points: { + pre_tool_call: { acs_method: "steps/toolCallRequest" }, + pre_model_call: { acs_method: "steps/toolCallRequest" }, + }, + }; + + expect(() => resolveInterventionPoint("steps/toolCallRequest", ambiguous)).toThrow(/more than one/); + }); + + it("resolves every other declared ACS method from the same table", () => { + // One row resolving is not evidence the table is being read -- a literal + // would satisfy that. These are the rest of the rows mapping.yaml ships. + expect(resolveInterventionPoint("steps/toolCallResult", m)).toBe("post_tool_call"); + expect(resolveInterventionPoint("steps/sessionStart", m)).toBe("agent_startup"); + expect(resolveInterventionPoint("steps/agentResponse", m)).toBe("output"); + }); + + it("never resolves to a row whose acs_method is null", () => { + // mapping.yaml really does ship two of these: the model-call points have + // no ACS v0.1.0 target. A resolver comparing loosely would match a + // null row and evaluate the wrong intervention point's policy. + const allNull = { + ...m, + intervention_points: { + pre_model_call: { acs_method: null }, + post_model_call: { acs_method: null }, + }, + } as unknown as Mapping; + + expect(() => resolveInterventionPoint("steps/toolCallRequest", allNull)).toThrow( + /maps no AGT intervention point/, + ); + }); + + it("throws when the mapping declares no intervention_points table at all", () => { + // loadMapping's `as Mapping` is unchecked, so a mapping.yaml edited to + // drop the table type-checks and reaches here. Standing in for that. + const tableless = { ...m, intervention_points: undefined } as unknown as Mapping; + + expect(() => resolveInterventionPoint("steps/toolCallRequest", tableless)).toThrow( + /no intervention_points table/, + ); + }); +}); diff --git a/packages/guardian/test/server.test.ts b/packages/guardian/test/server.test.ts new file mode 100644 index 00000000..202f2c9a --- /dev/null +++ b/packages/guardian/test/server.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { readFileSync } from "node:fs"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { startGuardian } from "../src/index.ts"; + +const HANDSHAKE_SCHEMA_PATH = "spec/acs/specification/v0.1.0/handshake.json"; + +/** Compiles the ServerHello $def straight out of the pinned handshake.json -- + * not a hand-copied shape -- so this test fails the moment our ServerHello + * drifts from the schema, per the task's "read the schema yourself" note. */ +function validateServerHello(candidate: unknown): void { + const handshakeSchema = JSON.parse(readFileSync(HANDSHAKE_SCHEMA_PATH, "utf8")) as { + $defs: { ServerHello: Record }; + }; + const ajv = new Ajv2020({ strict: true, allErrors: true }); + addFormats(ajv); + const validate = ajv.compile(handshakeSchema.$defs.ServerHello); + const valid = validate(candidate); + if (!valid) { + throw new Error(`ServerHello failed schema validation: ${JSON.stringify(validate.errors)}`); + } +} + +function makeEnvelope( + method: string, + payload: Record, + overrides: { id?: number; requestId?: string } = {}, +): Record { + const { id = 1, requestId = crypto.randomUUID() } = overrides; + return { + jsonrpc: "2.0", + method, + id, + params: { + acs_version: "0.1.0", + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { agent_id: "agent-1", session_id: crypto.randomUUID() }, + payload, + }, + }; +} + +function toolCallEnvelope(command: string, overrides: { id?: number; requestId?: string } = {}) { + return makeEnvelope( + "steps/toolCallRequest", + { tool: { name: "run_shell" }, arguments: { command: { value: command } } }, + overrides, + ); +} + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +}; + +async function postAcs(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return (await res.json()) as JsonRpcResponse; +} + +let url: string; +let close: () => Promise; + +beforeAll(async () => { + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + url = guardian.url; + close = guardian.close; +}); + +afterAll(async () => { + await close(); +}); + +describe("startGuardian POST /acs", () => { + it("handshake/hello returns a schema-valid ServerHello with timeout_config.default_ms and on_decision_failure: proceed", async () => { + const response = await postAcs(url, makeEnvelope("handshake/hello", {}, { id: 42 })); + + expect(response.error).toBeUndefined(); + expect(response.id).toBe(42); + expect(response.result).toBeDefined(); + + const serverHello = response.result as Record; + validateServerHello(serverHello); + expect((serverHello.timeout_config as { default_ms: number }).default_ms).toBeGreaterThan(0); + expect(serverHello.on_decision_failure).toBe("proceed"); + }); + + it("steps/toolCallRequest carrying rm -rf / denies, with non-empty reasoning and reason_codes, echoing request_id", async () => { + const requestId = crypto.randomUUID(); + const response = await postAcs(url, toolCallEnvelope("rm -rf /", { requestId })); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("deny"); + expect(response.result?.request_id).toBe(requestId); + expect(typeof response.result?.reasoning).toBe("string"); + expect((response.result?.reasoning as string).length).toBeGreaterThan(0); + expect(Array.isArray(response.result?.reason_codes)).toBe(true); + expect((response.result?.reason_codes as unknown[]).length).toBeGreaterThan(0); + }); + + it("steps/toolCallRequest carrying ls -la allows, echoing request_id", async () => { + const requestId = crypto.randomUUID(); + const response = await postAcs(url, toolCallEnvelope("ls -la", { requestId })); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("allow"); + expect(response.result?.request_id).toBe(requestId); + }); + + it("an unknown method returns a JSON-RPC error in the ACS-reserved -32000..-32099 range, not a decision", async () => { + // Well-formed envelope (matches the method-prefix pattern, real ACS hook + // name) but not one this Guardian dispatches -- distinct from a + // malformed envelope, which fails schema validation instead. + const response = await postAcs(url, makeEnvelope("steps/sessionStart", {}, { id: 7 })); + + expect(response.result).toBeUndefined(); + expect(response.id).toBe(7); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + }); + + // Scope boundary: a Guardian-side validation failure must surface as a bare + // JSON-RPC error, never as an explicit ACS "deny" decision -- see + // validate-envelope.test.ts's identical guard. + it("an envelope that fails schema validation returns a JSON-RPC error in -32000..-32099, never a deny decision", async () => { + const bad = toolCallEnvelope("rm -rf /"); + delete (bad.params as Record).acs_version; + + const response = await postAcs(url, bad); + + expect(response.result).toBeUndefined(); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + }); +}); + +// Fix wave finding 1 -- a real fail-open bug: an unhandled throw from +// assemblePreToolCallSnapshot/bridge.evaluate/mapVerdict inside handleAcsRequest used +// to escape uncaught, and Bun.serve's default error page for a rejected +// fetch() is `text/html`, not JSON. guardianClient.post's `res.json()` would +// then throw a SyntaxError instead of surfacing a JSON-RPC error, and +// acs-hook.ts's catch-all exits 1 with nothing on stdout -- Claude Code +// treats that as "the hook never fired" and the tool call proceeds +// ungoverned. This guards the fix, against a real (not mocked) AGT +// evaluation -- only mapping.yaml is swapped for a fixture that marks +// `allow` require_policy_references, so a genuine AGT "allow" verdict for a +// benign command (which carries no reason/message) makes mapVerdict throw +// inside handleAcsRequest for real. +describe("startGuardian POST /acs -- evaluation failure inside handleAcsRequest", () => { + it("a real mapVerdict throw (require_policy_references unmet) still returns a parseable JSON-RPC error in -32000..-32099, not an HTML 500", async () => { + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + mappingPath: "packages/guardian/test/fixtures/mapping.require-policy-references-on-allow.yaml", + }); + + try { + // res.json() below is exactly guardianClient.post's call. Before the + // fix, Bun.serve's unhandled-rejection page is text/html and this + // throws a SyntaxError instead of resolving -- the same failure mode + // the finding describes at guardian-client.ts:70. + const response = await postAcs(guardian.url, toolCallEnvelope("ls -la")); + + expect(response.result).toBeUndefined(); + expect(response.error).toBeDefined(); + expect(response.error?.code).toBeGreaterThanOrEqual(-32099); + expect(response.error?.code).toBeLessThanOrEqual(-32000); + // Never a decision -- same scope boundary as the schema-validation + // guard above. + expect((response as Record).decision).toBeUndefined(); + } finally { + await guardian.close(); + } + }); +}); + +// PR #10 review, Critical: mapping.yaml's intervention_points table is what +// V7's conformance matrix publishes, and the runtime used to hardcode +// "pre_tool_call" instead of consulting it, so the two could disagree without +// anything failing. +describe("startGuardian POST /acs -- the intervention point comes from mapping.yaml", () => { + it("evaluates the point the table names, not pre_tool_call: a moved row changes the decision", async () => { + // The fixture answers steps/toolCallRequest with `output`, which + // policy/manifest.yaml does not register -- so honouring the table makes + // AGT fail closed, while ignoring it would allow this benign command. The + // two outcomes are opposite: this cannot pass against a hardcoded point. + const guardian = await startGuardian({ + port: 0, + manifestPath: "policy/manifest.yaml", + mappingPath: "packages/guardian/test/fixtures/mapping.tool-call-at-a-different-point.yaml", + }); + + try { + const response = await postAcs(guardian.url, toolCallEnvelope("ls -la")); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("deny"); + expect(response.result?.reason_codes).toEqual(["runtime_error:intervention_point_unknown"]); + } finally { + await guardian.close(); + } + }); +}); + +// PR #10 review, Critical: Bun.serve with no `hostname` binds `*` -- every +// interface, dual-stack -- and this endpoint has no auth, no origin check and +// no request signing, so every host that could route to the port was a policy +// oracle and a policy sink. The observable that separates the two binds is +// reachability, so that is what is asserted, rather than the label Bun prints +// for the socket (`server.hostname` reads "localhost" for a wildcard bind, +// which is exactly the reading that hid this). +describe("startGuardian binds loopback only", () => { + async function reachable(url: string): Promise { + try { + await fetch(url, { method: "POST", body: "{}", signal: AbortSignal.timeout(2000) }); + return true; + } catch { + return false; + } + } + + it("refuses a connection to ::1, which a wildcard bind accepts", async () => { + // `hostname: "::"` reproduces the pre-fix bind exactly: dual-stack + // wildcard, ::1 and 127.0.0.1 both answering. It is the control, and it + // is what stops the assertion below from passing vacuously on a machine + // with no IPv6 loopback -- there, this expectation fails first and says + // so, rather than letting an unreachable address look like a narrow bind. + const wildcard = await startGuardian({ port: 0, hostname: "::", manifestPath: "policy/manifest.yaml" }); + const loopback = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); + + try { + // Path and port both come from the url the Guardian reported, so the + // probe cannot drift from what it actually serves. + const wildcardEndpoint = new URL(wildcard.url); + const loopbackEndpoint = new URL(loopback.url); + + expect(await reachable(`http://[::1]:${wildcardEndpoint.port}${wildcardEndpoint.pathname}`)).toBe(true); + expect(await reachable(`http://127.0.0.1:${loopbackEndpoint.port}${loopbackEndpoint.pathname}`)).toBe(true); + expect(await reachable(`http://[::1]:${loopbackEndpoint.port}${loopbackEndpoint.pathname}`)).toBe(false); + } finally { + await wildcard.close(); + await loopback.close(); + } + }); +}); diff --git a/packages/guardian/test/validate-envelope.test.ts b/packages/guardian/test/validate-envelope.test.ts new file mode 100644 index 00000000..76c4acd6 --- /dev/null +++ b/packages/guardian/test/validate-envelope.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "bun:test"; +import { EnvelopeValidationError, isToolCallRequest, validateEnvelope } from "../src/validate-envelope.ts"; + +function makeEnvelope(overrides: { + method?: string; + toolName?: string; + args?: Record; + omitSessionId?: boolean; + omitToolName?: boolean; +} = {}): unknown { + const { + method = "steps/toolCallRequest", + toolName = "run_shell", + args = { command: { value: "ls -la", provenance: { provenance_id: "p1", origin: "user_input" } } }, + omitSessionId = false, + omitToolName = false, + } = overrides; + + const metadata: Record = { agent_id: "agent-1" }; + if (!omitSessionId) { + metadata.session_id = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; + } + + const tool: Record = {}; + if (!omitToolName) { + tool.name = toolName; + } + + return { + jsonrpc: "2.0", + method, + id: 1, + params: { + acs_version: "0.1.0", + request_id: "8f14e45f-ceea-467e-bd5f-1d4d9a4e0c8f", + timestamp: "2026-08-09T12:00:00Z", + metadata, + payload: { + tool, + arguments: args, + }, + }, + }; +} + +describe("validateEnvelope", () => { + it("passes a well-formed steps/toolCallRequest envelope", () => { + const envelope = makeEnvelope(); + + // Narrowed through the predicate rather than cast: `validateEnvelope` + // returns a request of any method, and `params.payload.tool` only exists + // on the tool-call view of one. A `throw` rather than an expect, so the + // assertion below stays the assertion this test is about. + const validated = validateEnvelope(envelope); + if (!isToolCallRequest(validated)) { + throw new Error(`validateEnvelope returned a non-tool-call request: ${validated.method}`); + } + + expect(validated.params.payload.tool.name).toBe("run_shell"); + }); + + it("throws EnvelopeValidationError naming /params/metadata/session_id when session_id is missing", () => { + const envelope = makeEnvelope({ omitSessionId: true }); + + expect(() => validateEnvelope(envelope)).toThrow(EnvelopeValidationError); + try { + validateEnvelope(envelope); + throw new Error("expected validateEnvelope to throw"); + } catch (error) { + expect(error).toBeInstanceOf(EnvelopeValidationError); + expect((error as EnvelopeValidationError).pointer).toBe("/params/metadata/session_id"); + expect((error as EnvelopeValidationError).message).toContain("/params/metadata/session_id"); + } + }); + + it("throws EnvelopeValidationError naming /params/payload/tool/name when the tool name is missing", () => { + const envelope = makeEnvelope({ omitToolName: true }); + + expect(() => validateEnvelope(envelope)).toThrow(EnvelopeValidationError); + try { + validateEnvelope(envelope); + throw new Error("expected validateEnvelope to throw"); + } catch (error) { + expect(error).toBeInstanceOf(EnvelopeValidationError); + expect((error as EnvelopeValidationError).pointer).toBe("/params/payload/tool/name"); + expect((error as EnvelopeValidationError).message).toContain("/params/payload/tool/name"); + } + }); + + it("throws EnvelopeValidationError naming /method when method does not match the required prefix pattern", () => { + const envelope = makeEnvelope({ method: "notARealPrefix/toolCallRequest" }); + + expect(() => validateEnvelope(envelope)).toThrow(EnvelopeValidationError); + try { + validateEnvelope(envelope); + throw new Error("expected validateEnvelope to throw"); + } catch (error) { + expect(error).toBeInstanceOf(EnvelopeValidationError); + expect((error as EnvelopeValidationError).pointer).toBe("/method"); + expect((error as EnvelopeValidationError).message).toContain("/method"); + } + }); + + // Scope boundary: a rejection is a thrown error, never a returned + // {decision: "deny"} object. + it("throws rather than returning a decision object", () => { + const envelope = makeEnvelope({ omitSessionId: true }); + + let thrown: unknown; + let returned: unknown; + try { + returned = validateEnvelope(envelope); + } catch (error) { + thrown = error; + } + + expect(returned).toBeUndefined(); + expect(thrown).toBeInstanceOf(EnvelopeValidationError); + expect((thrown as Record).decision).toBeUndefined(); + }); +}); + +describe("isToolCallRequest -- the method discrimination the narrow type depends on", () => { + it("validates a handshake/hello envelope without claiming it is a tool call", () => { + const handshake = { + jsonrpc: "2.0", + method: "handshake/hello", + id: 7, + params: { + acs_version: "0.1.0", + request_id: "8f14e45f-ceea-467e-bd5f-1d4d9a4e0c8f", + timestamp: "2026-08-09T12:00:00Z", + metadata: { agent_id: "agent-1", session_id: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed" }, + // No tool, no arguments -- a handshake carries neither, which is + // exactly why typing it as a tool call was wrong rather than untidy. + payload: {}, + }, + }; + + const validated = validateEnvelope(handshake); + + expect(validated.method).toBe("handshake/hello"); + expect(isToolCallRequest(validated)).toBe(false); + }); + + it("says yes to steps/toolCallRequest", () => { + expect(isToolCallRequest(validateEnvelope(makeEnvelope()))).toBe(true); + }); + + it("says no to any other steps/* method", () => { + // Nothing dispatches this method today. The point is that a second + // steps/* method is NOT silently a tool call just because it validated. + const validated = validateEnvelope(makeEnvelope({ method: "steps/sessionStart" })); + + expect(isToolCallRequest(validated)).toBe(false); + }); +}); diff --git a/packages/host-adapter/package.json b/packages/host-adapter/package.json new file mode 100644 index 00000000..2b426330 --- /dev/null +++ b/packages/host-adapter/package.json @@ -0,0 +1,11 @@ +{ + "name": "host-adapter", + "private": true, + "type": "module", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "devDependencies": { + "guardian": "workspace:*" + } +} diff --git a/packages/host-adapter/src/build-envelope.ts b/packages/host-adapter/src/build-envelope.ts new file mode 100644 index 00000000..4da3120e --- /dev/null +++ b/packages/host-adapter/src/build-envelope.ts @@ -0,0 +1,172 @@ +/** + * buildEnvelope turns a host's own hook invocation into an ACS v0.1.0 request + * envelope (a `steps/*` method), driven entirely by a hookmap -- never by + * host-specific literals baked into this function. + * + * This package knows ACS and hookmaps, nothing else. No file under + * packages/host-adapter/ may name a policy runtime, its rule language, or its + * decision vocabulary, and a grep gate in the test suite checks that. This + * module has no runtime dependency on the Guardian package or the policy + * bridge behind it: it talks to the Guardian over the wire, never in-process. + */ +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; + +/** One hook's mapping onto an ACS method: the hookmap's `hooks.` entry. */ +export type HookmapHookEntry = { + /** The ACS `steps/*` method this hook fires. Never hardcoded here. */ + acs_method: string; + /** JSONPath-lite (`$.foo.bar`) into the raw hook payload for the tool/step name. */ + tool_name: string; + /** JSONPath-lite (`$.foo.bar`) into the raw hook payload for the argument bag. */ + arguments: string; +}; + +/** + * A hookmap in full: the hook-name -> ACS method mapping this module consumes, + * plus the decision -> host-output mapping renderDecision consumes. The second + * is on the type only so that a hookmap loaded whole, as `loadHookmap` does, + * round-trips without losing it. + */ +export type Hookmap = { + host: string; + hooks: Record; + decisions?: Record; +}; + +/** An ACS argument wrapper: every hook argument is `{value, provenance?}`. */ +type AcsArgument = { value: unknown }; + +/** The ACS v0.1.0 request envelope this module produces. */ +export type AcsRequestEnvelope = { + jsonrpc: "2.0"; + method: string; + id: string; + params: { + acs_version: string; + request_id: string; + timestamp: string; + metadata: { + agent_id: string; + session_id: string; + }; + payload: { + tool: { name: string }; + arguments: Record; + }; + }; +}; + +const ACS_VERSION = "0.1.0"; + +/** Loads and parses a hookmap YAML file, e.g. claude-code.hookmap.yaml. */ +export function loadHookmap(path: string): Hookmap { + return Bun.YAML.parse(readFileSync(path, "utf8")) as Hookmap; +} + +/** + * Resolves a JSONPath-lite reference (`$.foo.bar`, or `$` alone) against a + * raw hook payload. Only dotted field access is supported: every hookmap path + * is a single top-level field, and nothing here needs array indexing or filters. + */ +function resolvePath(payload: Record, path: string): unknown { + const segments = path.replace(/^\$\.?/, "").split(".").filter(Boolean); + let current: unknown = payload; + for (const segment of segments) { + if (current === null || typeof current !== "object") { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// Fixed, arbitrary namespace UUID used only to derive a stable v5 UUID from +// a host session id that is not itself a UUID -- Claude Code's session_id +// is host-assigned free-form text with no uuid guarantee, but ACS's +// metadata.session_id schema field requires format "uuid". Never +// regenerate this constant: doing so changes every derived session_id. +const SESSION_ID_NAMESPACE = "11c79ebd-8469-4b4e-8de0-72674add484c"; + +/** + * Maps a host's raw session id onto a schema-valid uuid. If the host + * already hands us a uuid, it is carried through unchanged (lowercased). + * Otherwise a RFC 4122 version-5 (namespace + SHA-1) uuid is derived + * deterministically -- the same raw session id always derives the same + * uuid, so per-session correlation on the Guardian side survives the + * translation honestly, rather than a random uuid being minted and + * silently discarding the host's real session identity. + */ +export function toSessionUuid(rawSessionId: string): string { + if (UUID_RE.test(rawSessionId)) { + return rawSessionId.toLowerCase(); + } + + const namespaceBytes = Buffer.from(SESSION_ID_NAMESPACE.replace(/-/g, ""), "hex"); + const hash = createHash("sha1") + .update(Buffer.concat([namespaceBytes, Buffer.from(rawSessionId, "utf8")])) + .digest(); + + hash[6] = (hash[6]! & 0x0f) | 0x50; // version 5 + hash[8] = (hash[8]! & 0x3f) | 0x80; // RFC 4122 variant + + const hex = hash.subarray(0, 16).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +/** + * Builds an ACS v0.1.0 request envelope from a raw hook invocation. + * `hookmap` drives every host-shape decision: the ACS method, and where + * the tool/step name and argument bag live in the raw payload. An event + * name absent from `hookmap.hooks` throws -- there is no default method + * and no partial envelope. + */ +export function buildEnvelope(event: string, payload: Record, hookmap: Hookmap): AcsRequestEnvelope { + const entry = hookmap.hooks[event]; + if (!entry) { + throw new Error(`buildEnvelope: hookmap has no entry for hook "${event}"`); + } + + const toolName = resolvePath(payload, entry.tool_name); + if (typeof toolName !== "string") { + throw new Error( + `buildEnvelope: hookmap path "${entry.tool_name}" for hook "${event}" did not resolve to a string`, + ); + } + + const rawArguments = resolvePath(payload, entry.arguments); + const args: Record = {}; + if (rawArguments !== null && typeof rawArguments === "object") { + for (const [key, value] of Object.entries(rawArguments as Record)) { + args[key] = { value }; + } + } + + const rawSessionId = payload.session_id; + if (typeof rawSessionId !== "string" || rawSessionId.length === 0) { + throw new Error(`buildEnvelope: hook payload for "${event}" is missing a "session_id" string field`); + } + + const requestId = randomUUID(); + + return { + jsonrpc: "2.0", + method: entry.acs_method, + id: requestId, + params: { + acs_version: ACS_VERSION, + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { + agent_id: hookmap.host, + session_id: toSessionUuid(rawSessionId), + }, + payload: { + tool: { name: toolName }, + arguments: args, + }, + }, + }; +} diff --git a/packages/host-adapter/src/decision-message.ts b/packages/host-adapter/src/decision-message.ts new file mode 100644 index 00000000..b594f179 --- /dev/null +++ b/packages/host-adapter/src/decision-message.ts @@ -0,0 +1,28 @@ +/** + * The ACS decision message this adapter speaks. Kept in a module of its own so + * every module that touches one -- render-decision.ts (which renders one), + * guardian-client.ts (which receives one off the wire), and whatever later + * slices add between them -- speaks the same message rather than each + * declaring its own shape of it. + * + * Deliberately not an import of the Guardian's own `AcsDecision`. The Guardian + * declares the same message (packages/guardian/src/map-verdict.ts) as a strict + * union of the five ACS dispositions, because it is the side that *builds* + * one. This side reads one off HTTP, so it is deliberately loose: only + * `decision` is required, and everything else a caller touches is named by the + * hookmap rather than assumed to exist under a fixed key. The Guardian's type + * is assignable to this one; the two share the NAME rather than an import, + * because this package must not depend on the Guardian's type graph. + * + * One noun for "the ACS decision for this step", across both sides of the + * seam. Any later refinement should stay an adjective containing this stem + * (`ValidatedAcsDecision`), never a parallel noun. + * + * ACS's decision vocabulary, nothing else. + */ + +/** + * The ACS decision for one step, as this adapter reads it. Loose on purpose -- + * see the module header: only `decision` is required. + */ +export type AcsDecision = { decision: string } & Record; diff --git a/packages/host-adapter/src/guardian-client.ts b/packages/host-adapter/src/guardian-client.ts new file mode 100644 index 00000000..fbfdf80d --- /dev/null +++ b/packages/host-adapter/src/guardian-client.ts @@ -0,0 +1,205 @@ +/** + * The Guardian client: the adapter's wire seam. `createGuardianClient` binds + * one ACS endpoint and returns the role a caller collaborates with. + * + * Two methods, because two callers want opposite things from a failure: + * + * - `requestDecision` never throws. Every way of not getting a decision -- + * a dead transport, an uncorrelated response, a JSON-RPC error, a result + * naming no decision -- becomes the same answer, so a caller cannot forget + * to handle one. Getting that wrong is a fail-open, because every branch + * that mishandles a decision ends with the tool call proceeding ungoverned. + * - `post` is the wire primitive underneath it, for the one caller whose + * result is not a decision: the handshake, whose result is a ServerHello + * (handshake.ts). It throws on every delivery failure, which is what that + * caller wants -- a handshake failure is not a step's failure and travels + * separately. + * + * What a host DOES with `decisionArrived: false` -- negotiating and applying a + * fail-open or fail-closed posture -- is not decided here. + * + * Correlation happens at two layers, because the two say different things. + * buildEnvelope sets the envelope's top-level `id` equal to + * `params.request_id`. `fetch` already pairs one HTTP request with one + * response, so the transport-id check below is a defensive assertion against + * a Guardian that echoes back the wrong id, not a lookup table for concurrent + * requests. The ACS layer is a separate claim: `result.request_id` names the + * step the decision is ABOUT, and matching transport ids say nothing about + * it -- a Guardian, or anything else that reaches an unauthenticated socket, + * can answer with the right `id` and another step's decision. Both are + * checked, and both are checked in `post`, so no caller can hold a response + * that was never correlated. + * + * This module knows JSON-RPC, HTTP and ACS's decision vocabulary, nothing else + * -- no policy-runtime vocabulary and no host vocabulary. It has no runtime + * dependency on the Guardian or policy-bridge packages: it talks to the + * Guardian only over the wire, at whatever `url` the caller gives. + */ +import type { AcsRequestEnvelope } from "./build-envelope.ts"; +import type { AcsDecision } from "./decision-message.ts"; + +/** + * The minimal JSON-RPC 2.0 request shape this client sends -- the transport + * shape, used by `post` alone, not the shim-facing vocabulary. + * + * Loose on `params` on purpose: `post` carries whatever a caller hands it, and + * its one non-decision caller is the handshake, whose ClientHello params are + * not an ACS request at all. + */ +export type JsonRpcRequest = { + jsonrpc: "2.0"; + method: string; + id: string | number; + params: Record; +}; + +export type JsonRpcSuccessResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result: Record; + error?: undefined; +}; + +export type JsonRpcErrorResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result?: undefined; + error: { code: number; message: string; data?: unknown }; +}; + +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; + +/** Thrown when the Guardian's response id doesn't match the request's -- + * i.e. the response cannot be trusted to belong to this request. */ +export class GuardianResponseMismatchError extends Error { + constructor(requestId: string | number, responseId: string | number | null) { + super( + `GuardianClient.post: response id ${JSON.stringify(responseId)} does not correlate with request id ${JSON.stringify(requestId)}`, + ); + this.name = "GuardianResponseMismatchError"; + } +} + +/** Thrown when the Guardian's response correlates at the transport layer but + * its ACS result names a different request -- i.e. a real decision, about + * some other step. A sibling of GuardianResponseMismatchError rather than the + * same error: the transport id matching and the ACS request_id matching are + * different claims, and a reader of the message needs to know which one the + * Guardian broke. */ +export class GuardianResultCorrelationError extends Error { + constructor(requestId: unknown, resultRequestId: unknown) { + super( + `GuardianClient.post: result.request_id ${JSON.stringify(resultRequestId)} does not correlate with the request's request_id ${JSON.stringify(requestId)}`, + ); + this.name = "GuardianResultCorrelationError"; + } +} + +/** + * What came back when a decision was asked for. Exactly one of the two cases, + * discriminated by the only question that matters at this seam: did a decision + * arrive? + * + * - `decisionArrived: true` -- one did. What the Guardian sent, unvalidated. + * - `decisionArrived: false` -- none did, and `failure` is whatever stands + * in its place: the throw from the wire, the JSON-RPC `error` object the + * Guardian answered with, or (for a response carrying neither) an Error + * saying so. + * + * A union rather than a bag with optional fields, so a caller that reads + * `decision` without checking does not compile. + */ +export type DecisionOrFailure = + | { readonly decisionArrived: true; readonly decision: AcsDecision } + | { readonly decisionArrived: false; readonly failure: unknown }; + +/** + * The Guardian, as the adapter's callers depend on it: a role bound to one ACS + * endpoint, not a URL passed around and a namespace of free functions. + */ +export type GuardianClient = { + /** + * Asks for the decision on `envelope` and answers with a message. Never + * throws -- see DecisionOrFailure. + * + * Takes the ACS request message and answers with an ACS decision: both ends of + * this method are ACS's vocabulary, and JSON-RPC is the transport it happens to + * travel over (see JsonRpcRequest). + */ + requestDecision(envelope: AcsRequestEnvelope): Promise; + /** + * The wire primitive: POSTs `envelope` as JSON, parses the JSON-RPC + * response, and returns it once it is confirmed to answer this request -- + * at the transport layer by `id`, and, when the result carries one, at the + * ACS layer by `request_id`. Throws for every delivery failure. + * + * For a method whose result is not a decision -- today only + * `handshake/hello`, whose result is a ServerHello. A caller after a + * decision uses `requestDecision` instead, and no caller in this package + * inspects a response for one. + */ + post(envelope: JsonRpcRequest): Promise; +}; + +/** Binds a Guardian's ACS endpoint and returns the client role for it. */ +export function createGuardianClient(url: string): GuardianClient { + async function post(envelope: JsonRpcRequest): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }); + + const response = (await res.json()) as JsonRpcResponse; + + if (response.id !== envelope.id) { + throw new GuardianResponseMismatchError(envelope.id, response.id); + } + + // The ACS-layer claim, checked only when a result makes it: a ServerHello + // and a JSON-RPC error carry no request_id, and neither is broken here. + // `undefined !== envelope.params.request_id` would fire on a handshake, + // so the guard is on the result's field, not on the envelope's. + const resultRequestId = (response.result as { request_id?: unknown } | undefined)?.request_id; + if (resultRequestId !== undefined && resultRequestId !== envelope.params.request_id) { + throw new GuardianResultCorrelationError(envelope.params.request_id, resultRequestId); + } + + return response; + } + + return { + post, + + async requestDecision(envelope: AcsRequestEnvelope): Promise { + let response: JsonRpcResponse; + try { + response = await post(envelope); + } catch (failure) { + // A dead transport, an uncorrelated response, a body that is not JSON. + // None of them carry a decision, so all of them are the same answer -- + // and turning the throw into that answer here is what stops a caller's + // catch block from having to work out which stage of its own sequence + // threw. + return { decisionArrived: false, failure }; + } + + // An arriving decision is checked for FIRST. A JSON-RPC response + // carrying both `error` and `result` is malformed per JSON-RPC, but if + // the `result` names a decision then a decision did arrive, and an + // arriving `deny` must be honoured -- answering it with a failure + // instead would let a delivery-failure rule overrule a policy decision. + const arrived = response.result as AcsDecision | undefined; + if (typeof arrived?.decision === "string") { + return { decisionArrived: true, decision: arrived }; + } + + // Anything with no decision in it is a delivery failure, and the `error` + // is used as the failure when there is one. + return { + decisionArrived: false, + failure: response.error ?? new Error("guardian's response carried neither a decision nor an error"), + }; + }, + }; +} diff --git a/packages/host-adapter/src/handshake.ts b/packages/host-adapter/src/handshake.ts new file mode 100644 index 00000000..139c92b8 --- /dev/null +++ b/packages/host-adapter/src/handshake.ts @@ -0,0 +1,99 @@ +/** + * The host half of `handshake/hello`: send a ClientHello, keep what comes back + * as this session's config. + * + * The stored message is a `SessionConfig`, never a `ServerHello`. + * `isSessionConfig` requires the two fields this host actually needs, not the + * five handshake.json's ServerHello $def requires, so naming the stored type + * after the wire message would over-claim what this host validates. + * `ServerHello` stays scoped to one thing: what the Guardian sent, before this + * host has confirmed it can use it. + * + * Stores only. Applying the negotiated posture -- falling back to + * `timeout_config` when the Guardian is slow or silent, recording a fail-open + * audit event per `on_decision_failure: "proceed"` -- happens elsewhere, which + * keeps a policy runtime's own evaluation-layer fail-closed behaviour distinct + * from wire-delivery failure. This module does not retry, does not time out, + * and does not write to an audit sink: it sends one request, stores one response. + * + * This module knows ACS handshake vocabulary and JSON-RPC, nothing else. It has + * no runtime dependency on the Guardian package -- it talks to the Guardian + * only through the client role it is given, over the wire. + */ +import { randomUUID } from "node:crypto"; +import type { GuardianClient, JsonRpcRequest } from "./guardian-client.ts"; +import { isSessionConfig, type SessionConfig, type SessionConfigStore } from "./session-config.ts"; + +const HANDSHAKE_METHOD = "handshake/hello"; +const ACS_VERSION = "0.1.0"; + +export type HandshakeOptions = { + /** The Guardian to negotiate with -- `createGuardianClient(url)` for the + * endpoint startGuardian returned (test-only) or the one this deployment is + * configured for. A client rather than a URL because that is the seam: this + * module knows the handshake, not how to reach a Guardian. */ + guardian: GuardianClient; + /** This Observed Agent's identity on the wire (ACS metadata.agent_id). */ + agentId: string; + /** This session's identity on the wire (ACS metadata.session_id, a uuid). */ + sessionId: string; +}; + +/** + * Sends `handshake/hello`, waits for the Guardian's ServerHello, stores the + * session config it validates out of that arrival, and returns that config. + * Throws if the Guardian responds with a JSON-RPC error rather than a result, + * or if what arrived is not a usable session config. + */ +export async function negotiateSessionConfig( + options: HandshakeOptions, + store: SessionConfigStore, +): Promise { + const requestId = randomUUID(); + + const envelope: JsonRpcRequest = { + jsonrpc: "2.0", + method: HANDSHAKE_METHOD, + id: requestId, + params: { + acs_version: ACS_VERSION, + request_id: requestId, + timestamp: new Date().toISOString(), + metadata: { agent_id: options.agentId, session_id: options.sessionId }, + // ClientHello shape (handshake.json's $defs.ClientHello). Not schema- + // enforced on this method by the Guardian's own validateEnvelope + // (Task 5) today, but supplied honestly rather than left empty. + payload: { + acs_versions_supported: [ACS_VERSION], + methods_implemented: ["steps/toolCallRequest"], + transports_supported: ["http"], + provenance_producer: "none", + }, + }, + }; + + // `post`, not `requestDecision`: this method's result is a ServerHello, not + // a decision, and a handshake failure is a different incident from a step + // that got no decision. It travels as a throw, separately. + const response = await options.guardian.post(envelope); + if (response.error) { + throw new Error(`handshake: Guardian rejected handshake/hello: ${response.error.message}`); + } + + // Validated BEFORE storing, not cast and hoped for. This is the single point + // where the Guardian's ServerHello becomes this host's SessionConfig, and it + // becomes one by being checked -- named for the wire while it is still only + // an arrival, and for the store once it is one. + const serverHello: unknown = response.result; + if (!isSessionConfig(serverHello)) { + throw new Error( + `handshake: the Guardian's ServerHello is not a usable session config -- expected an object with ` + + `on_decision_failure "proceed" or "deny" and a numeric timeout_config.default_ms, got ` + + `${JSON.stringify(serverHello)}`, + ); + } + + const sessionConfig: SessionConfig = serverHello; + store.set(sessionConfig); + return sessionConfig; +} diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts new file mode 100644 index 00000000..ba68fecf --- /dev/null +++ b/packages/host-adapter/src/index.ts @@ -0,0 +1,36 @@ +/** + * Public surface of the host-adapter package (packages/host-adapter). + * + * Every host shim imports from here rather than from individual src files, + * so this list is the whole contract a shim relies on. The package boundary + * still binds every file this barrel re-exports: no policy-runtime vocabulary + * of any kind anywhere under packages/host-adapter/. + */ +export { + buildEnvelope, + loadHookmap, + toSessionUuid, + type Hookmap, + type HookmapHookEntry, + type AcsRequestEnvelope, +} from "./build-envelope.ts"; +export { + createGuardianClient, + GuardianResponseMismatchError, + GuardianResultCorrelationError, + type DecisionOrFailure, + type GuardianClient, + type JsonRpcRequest, + type JsonRpcResponse, + type JsonRpcSuccessResponse, + type JsonRpcErrorResponse, +} from "./guardian-client.ts"; +export { renderDecision, type HostOutput } from "./render-decision.ts"; +export { type AcsDecision } from "./decision-message.ts"; +export { negotiateSessionConfig, type HandshakeOptions } from "./handshake.ts"; +export { + createSessionConfigStore, + isSessionConfig, + type SessionConfig, + type SessionConfigStore, +} from "./session-config.ts"; diff --git a/packages/host-adapter/src/render-decision.ts b/packages/host-adapter/src/render-decision.ts new file mode 100644 index 00000000..ccbbfe59 --- /dev/null +++ b/packages/host-adapter/src/render-decision.ts @@ -0,0 +1,181 @@ +/** + * renderDecision turns an ACS decision into the output its host expects -- + * without naming one field of that output anywhere in this module. + * + * The hookmap declares the whole shape. Each `decisions.` entry + * carries an `output` block whose keys are dotted paths into the object the + * host reads, and whose values say where each field's content comes from: a + * literal (`value:`) or a field of the arriving ACS decision (`from:`, with an + * optional `type:` the arriving value must have). This module walks that block + * and assembles the object. It knows ACS decisions, dotted paths, and nothing + * else; the field names, their nesting, and which of them a given decision + * even has are all data. + * + * That is what lets one adapter serve many hosts rather than one adapter per + * host: a second host gets this module unchanged, plus a shim and a hookmap. + * So nothing here is mandatory -- a host with no permission-style field at all + * still renders -- and a path with no dot places a field outside whatever + * wrapper the host nests its decision in, rather than inside it. + * + * The wrapper itself, and the one field that is not a function of the decision + * (the name of the hook that asked), belong to the host shim: it wraps what + * this returns. That keeps this module's contract exactly "the output is a + * function of the decision and the hookmap". + * + * The load-bearing behaviour: a `deny` decision's `reasoning` string must + * reach the human reading the host's transcript. It happens generically -- + * `deny`'s hookmap entry names the decision field to copy and the host path to + * copy it to, and this module copies whatever those two say. + * + * No policy-runtime vocabulary here and no host vocabulary either; + * test/invariants.test.ts gates both. An observe-only upstream signal has + * already become an ACS `allow` (with policy_references) by the time it + * reaches this module, and dispatches through the same `decisions.allow` entry + * a plain allow does. + */ +import type { Hookmap } from "./build-envelope.ts"; +import type { AcsDecision } from "./decision-message.ts"; + +/** + * A rendered host output: an ordinary JSON object whose keys this module never + * chose. A host shim receives one of these and hands it to its host -- it is + * the shim, not the adapter, that knows what the keys mean. + */ +export type HostOutput = Record; + +/** + * One field of a host output, as the hookmap declares it: exactly one source, + * plus an optional type the arriving value must have. + * + * `type` is a `typeof` string, and it is not decoration. A host field declared + * to hold prose must not be handed an object because some Guardian put one in + * the decision field it names: the host would either display a shape it cannot + * render or reject the whole output as malformed and treat the hook as having + * produced no decision -- a fail-open, from a decision that arrived perfectly + * well. A wrong-typed value leaves the field off, exactly as a missing one does. + */ +type HostOutputField = { + /** A literal, copied through as-is. Mutually exclusive with `from`. */ + value?: unknown; + /** The name of the ACS decision field whose value to copy. */ + from?: string; + /** `typeof` the value must satisfy for a `from` field to be copied. */ + type?: string; +}; + +/** One decision's hookmap-declared rendering rule (its `decisions.` entry). */ +type DecisionRenderRule = { output: Record }; + +/** + * Path segments no output field may name. `__proto__` is the one that matters + * -- assigning to it through a plain object mutates the prototype instead of + * adding a key, so a hookmap naming it would produce an output missing the + * field it declared while changing something else entirely. The other two are + * rejected beside it rather than reasoned about individually. + */ +const RESERVED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Writes `value` into `output` at a dotted `path`, creating the objects along + * the way. + * + * Every failure here is a throw rather than a skip or an overwrite. A hookmap + * that declares two fields at the same key, or one field nested underneath + * another, describes an output nobody can render as written -- and a renderer + * that silently picked one of the two would hand the host something that + * merely looks like a decision. + */ +function place(output: HostOutput, path: string, value: unknown): void { + const segments = path.split("."); + for (const segment of segments) { + if (segment.length === 0 || RESERVED_SEGMENTS.has(segment)) { + throw new Error(`renderDecision: output path "${path}" names the segment ${JSON.stringify(segment)}, which addresses no field`); + } + } + + const leaf = segments[segments.length - 1] as string; + let cursor = output; + for (const segment of segments.slice(0, -1)) { + const existing = cursor[segment]; + if (existing === undefined) { + cursor[segment] = {}; + } else if (!isPlainObject(existing)) { + throw new Error( + `renderDecision: output path "${path}" nests under "${segment}", which another field already holds a value at`, + ); + } + cursor = cursor[segment] as HostOutput; + } + if (Object.prototype.hasOwnProperty.call(cursor, leaf)) { + throw new Error(`renderDecision: output path "${path}" is declared twice, or collides with a field nested under it`); + } + cursor[leaf] = value; +} + +/** + * Renders `decision` per `hookmap.decisions[decision.decision]`, returning the + * host output that entry declares. + * + * Throws if the hookmap has no `decisions` block, has no entry for this + * decision, or has an entry this module cannot render -- there is no default + * rendering and no partial output. A caller that cannot render a decision + * still has a decision it must answer; answering it with half an output is the + * one thing this function will not do. + */ +export function renderDecision(decision: AcsDecision, hookmap: Hookmap): HostOutput { + const decisions = hookmap.decisions; + if (!isPlainObject(decisions)) { + throw new Error("renderDecision: hookmap has no decisions block"); + } + if (!Object.prototype.hasOwnProperty.call(decisions, decision.decision)) { + throw new Error(`renderDecision: hookmap has no decisions entry for ACS decision "${decision.decision}"`); + } + + const rule = decisions[decision.decision]; + if (!isPlainObject(rule) || !isPlainObject(rule.output) || Object.keys(rule.output).length === 0) { + // An entry that is null, or carries no `output` block, satisfies a bare + // presence check and then renders nothing -- an output with no decision in + // it, which a host reads as "the hook produced nothing" exactly as surely + // as a missing entry does, just more quietly. + throw new Error( + `renderDecision: hookmap's "decisions.${decision.decision}" entry needs a non-empty "output" block, ` + + `got ${JSON.stringify((rule as { output?: unknown } | null)?.output)}`, + ); + } + + const output: HostOutput = {}; + for (const [path, field] of Object.entries((rule as DecisionRenderRule).output)) { + if (!isPlainObject(field)) { + throw new Error( + `renderDecision: hookmap's "decisions.${decision.decision}" output field "${path}" must be an object ` + + `naming "value" or "from", got ${JSON.stringify(field)}`, + ); + } + if (Object.prototype.hasOwnProperty.call(field, "value")) { + place(output, path, field.value); + continue; + } + if (typeof field.from !== "string" || field.from.length === 0) { + // Not skipped: a field naming neither source is a hookmap typo, and + // rendering around it would produce an output missing a field its author + // believes is there. + throw new Error( + `renderDecision: hookmap's "decisions.${decision.decision}" output field "${path}" must name a literal ` + + `"value" or a non-empty string "from", got ${JSON.stringify(field.from)}`, + ); + } + const carried = decision[field.from]; + if (carried === undefined) { + continue; + } + if (field.type !== undefined && typeof carried !== field.type) { + continue; + } + place(output, path, carried); + } + return output; +} diff --git a/packages/host-adapter/src/session-config.ts b/packages/host-adapter/src/session-config.ts new file mode 100644 index 00000000..e48a66a4 --- /dev/null +++ b/packages/host-adapter/src/session-config.ts @@ -0,0 +1,76 @@ +/** + * The negotiated session config store: where `negotiateSessionConfig` writes + * this session's config once capability negotiation completes. + * + * `SessionConfig` is the one noun for that stored message, here as in + * handshake.ts. "ServerHello" names what the Guardian sent, and appears below + * only where the subject genuinely is the arrival rather than the stored value. + * + * Storage only. Nothing in this module reads or acts on the stored config -- + * falling back to timeout_config when the Guardian is silent, and honouring + * on_decision_failure's fail-open or fail-closed posture, happen elsewhere. + * + * A factory rather than a module-level singleton, matching this package's + * existing style (loadHookmap, buildEnvelope take every dependency as an + * argument; nothing here relies on hidden shared state) and so a test can + * hold its own store without one test's handshake bleeding into another's + * assertions. + */ + +/** The two fields of the Guardian's answer this host actually reads, which is + * what makes this the stored config rather than a claim about the wire message. + * Loose on purpose beyond them: a Guardian may return fields this host never + * names, and `Record` lets those round-trip through the store + * untouched rather than being silently dropped. */ +export type SessionConfig = { + timeout_config: { default_ms: number; per_method_ms?: Record }; + on_decision_failure: "proceed" | "deny"; +} & Record; + +export type SessionConfigStore = { + /** The most recently stored config, or undefined before any handshake completes. */ + get(): SessionConfig | undefined; + /** Overwrites the stored config. Called by negotiateSessionConfig with the + * config it validated out of the Guardian's ServerHello. */ + set(config: SessionConfig): void; +}; + +/** + * A `SessionConfig` must at minimum carry the two fields this host reads. The + * predicate exists so `negotiateSessionConfig` can ASK that question of the + * Guardian's ServerHello before storing one, rather than casting the arrival + * into this type and calling it a config -- a cast would make the name a claim + * nothing checked. + * + * Deliberately NOT the five fields handshake.json's ServerHello $def requires. + * This checks what this host needs, so `SessionConfig` is the honest name for + * what it certifies; naming the stored type after the wire message would + * over-claim in exactly the way a cast would. + */ +export function isSessionConfig(value: unknown): value is SessionConfig { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as Record; + const posture = candidate.on_decision_failure; + const timeout = candidate.timeout_config; + return ( + (posture === "proceed" || posture === "deny") && + typeof timeout === "object" && + timeout !== null && + typeof (timeout as Record).default_ms === "number" + ); +} + +/** Creates a fresh, empty session config store. */ +export function createSessionConfigStore(): SessionConfigStore { + let current: SessionConfig | undefined; + return { + get(): SessionConfig | undefined { + return current; + }, + set(config: SessionConfig): void { + current = config; + }, + }; +} diff --git a/packages/host-adapter/test/build-envelope.test.ts b/packages/host-adapter/test/build-envelope.test.ts new file mode 100644 index 00000000..5bd94da9 --- /dev/null +++ b/packages/host-adapter/test/build-envelope.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "bun:test"; +import { validateEnvelope } from "guardian"; +import { buildEnvelope, loadHookmap, toSessionUuid, type Hookmap } from "../src/build-envelope.ts"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// The real PreToolUse payload shape Claude Code delivers on stdin, per the +// Task 7 brief -- not a sketch. +const preToolUsePayload = { + session_id: "abc123", + transcript_path: "/path/to/transcript.jsonl", + cwd: "/current/dir", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "rm -rf /", description: "clean up" }, +}; + +const hookmap: Hookmap = { + host: "claude-code", + hooks: { + PreToolUse: { + acs_method: "steps/toolCallRequest", + tool_name: "$.tool_name", + arguments: "$.tool_input", + }, + }, + decisions: { + allow: { permissionDecision: "allow" }, + deny: { permissionDecision: "deny", reason_from: "reasoning" }, + ask: { permissionDecision: "ask" }, + defer: { permissionDecision: "defer" }, + modify: { permissionDecision: "allow", updatedInput_from: "modifications" }, + }, +}; + +describe("buildEnvelope", () => { + it("produces an envelope that validates against the real ACS v0.1.0 request-envelope + tool-call-request schemas", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + // guardian's own validateEnvelope (Task 5) -- imported here, in the + // TEST file only, so the runtime adapter stays dependency-free while + // this proves the two sides genuinely agree on the wire format. + expect(() => validateEnvelope(envelope)).not.toThrow(); + }); + + it("takes method from the hookmap's acs_method, never hardcoded", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(envelope.method).toBe("steps/toolCallRequest"); + }); + + it("wraps each tool_input argument as {value: ...} per ACS", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(envelope.params.payload.arguments.command).toEqual({ value: "rm -rf /" }); + expect(envelope.params.payload.arguments.description).toEqual({ value: "clean up" }); + }); + + it("carries tool_name into params.payload.tool.name", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(envelope.params.payload.tool.name).toBe("Bash"); + }); + + it("assigns a fresh uuid request_id per call", () => { + const first = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + const second = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(first.params.request_id).toMatch(UUID_RE); + expect(second.params.request_id).toMatch(UUID_RE); + expect(first.params.request_id).not.toBe(second.params.request_id); + }); + + it("stamps an ISO 8601 timestamp", () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(new Date(envelope.params.timestamp).toISOString()).not.toBe("Invalid Date"); + expect(envelope.params.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("carries the host's session_id, derived deterministically into a uuid (session_id must be a uuid on the wire)", () => { + const first = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + const second = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + + expect(first.params.metadata.session_id).toMatch(UUID_RE); + // Deterministic: the same host session_id always derives the same uuid, + // even though request_id (above) is fresh every call. + expect(first.params.metadata.session_id).toBe(second.params.metadata.session_id); + }); + + it("preserves an already-valid uuid session_id rather than re-deriving it", () => { + const realUuid = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; + const payload = { ...preToolUsePayload, session_id: realUuid }; + + const envelope = buildEnvelope("PreToolUse", payload, hookmap); + + expect(envelope.params.metadata.session_id).toBe(realUuid); + }); + + it("toSessionUuid is deterministic and produces a valid uuid for a non-uuid input", () => { + expect(toSessionUuid("abc123")).toMatch(UUID_RE); + expect(toSessionUuid("abc123")).toBe(toSessionUuid("abc123")); + expect(toSessionUuid("abc123")).not.toBe(toSessionUuid("xyz789")); + }); + + it("throws on an unmapped hook name, rather than defaulting or producing a partial envelope", () => { + expect(() => buildEnvelope("PostToolUse", preToolUsePayload, hookmap)).toThrow(); + }); + + it("is hookmap-driven: changing the hookmap's acs_method changes the envelope's method", () => { + const alternateHookmap: Hookmap = { + host: "claude-code", + hooks: { + PreToolUse: { + acs_method: "steps/toolCallResult", + tool_name: "$.tool_name", + arguments: "$.tool_input", + }, + }, + }; + + const original = buildEnvelope("PreToolUse", preToolUsePayload, hookmap); + const changed = buildEnvelope("PreToolUse", preToolUsePayload, alternateHookmap); + + expect(original.method).toBe("steps/toolCallRequest"); + expect(changed.method).toBe("steps/toolCallResult"); + }); + + it("loadHookmap parses the real claude-code.hookmap.yaml and buildEnvelope drives off it end to end", () => { + const parsed = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); + + const envelope = buildEnvelope("PreToolUse", preToolUsePayload, parsed); + + expect(envelope.method).toBe("steps/toolCallRequest"); + expect(() => validateEnvelope(envelope)).not.toThrow(); + }); +}); diff --git a/packages/host-adapter/test/client.test.ts b/packages/host-adapter/test/client.test.ts new file mode 100644 index 00000000..ab7ef612 --- /dev/null +++ b/packages/host-adapter/test/client.test.ts @@ -0,0 +1,355 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +// Test-only import: stands up a real Guardian so these tests prove the +// wire contract for real, not against a hand-copied shape. Never imported +// by packages/host-adapter/src, which must not depend on the Guardian -- +// see build-envelope.test.ts for the same arrangement. +import { startGuardian, type StartedGuardian } from "guardian"; +import { buildEnvelope, loadHookmap, type Hookmap } from "../src/build-envelope.ts"; +import { createGuardianClient, GuardianResultCorrelationError } from "../src/guardian-client.ts"; +import { negotiateSessionConfig } from "../src/handshake.ts"; +import { renderDecision } from "../src/render-decision.ts"; +import { createSessionConfigStore } from "../src/session-config.ts"; + +const hookmap: Hookmap = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); + +function preToolUsePayload(command: string): Record { + return { + session_id: "abc123", + transcript_path: "/path/to/transcript.jsonl", + cwd: "/current/dir", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }; +} + +let guardian: StartedGuardian; + +beforeAll(async () => { + guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml" }); +}); + +afterAll(async () => { + await guardian.close(); +}); + +describe("GuardianClient.post", () => { + it("posts the envelope as well-formed JSON-RPC 2.0 (jsonrpc, method, id, params) over HTTP", async () => { + let capturedBody: Record | undefined; + let capturedContentType: string | null = null; + const mock = Bun.serve({ + port: 0, + async fetch(req) { + capturedContentType = req.headers.get("content-type"); + capturedBody = (await req.json()) as Record; + return Response.json({ jsonrpc: "2.0", id: (capturedBody as { id: unknown }).id, result: { decision: "allow" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const response = await createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope); + + expect(capturedBody).toEqual(envelope as unknown as Record); + if (capturedContentType === null) { + throw new Error("mock server captured no content-type header"); + } + // Rebind to a plain (non-closure-mutated) const: `capturedContentType` + // is reassigned inside the fetch handler above, and TS's generic + // inference for expect() doesn't pick up the flow-narrowing on a + // variable a closure can still write to, even though the narrowing + // itself is sound here (the closure has already run by this point). + const contentType: string = capturedContentType; + expect(contentType).toContain("application/json"); + expect(response.result?.decision).toBe("allow"); + } finally { + mock.stop(true); + } + }); + + it("correlates the response to the request by JSON-RPC id -- buildEnvelope's id equals params.request_id, and that round-trips fine", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const response = await createGuardianClient(guardian.url).post(envelope); + + expect(response.id).toBe(envelope.id); + expect(envelope.id).toBe(envelope.params.request_id); + }); + + it("throws when the response id does not correlate with the request id", async () => { + const mock = Bun.serve({ + port: 0, + fetch() { + return Response.json({ jsonrpc: "2.0", id: "not-the-request-id", result: { decision: "allow" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + await expect(createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope)).rejects.toThrow(); + } finally { + mock.stop(true); + } + }); + + it("throws when the transport id correlates but result.request_id names another request", async () => { + // The one a transport-only check cannot catch: `id` is echoed faithfully, + // so `fetch`'s own pairing and the JSON-RPC id check both pass, and what + // comes back is a real, well-formed decision -- about a different step. + // Answering this tool call with it is answering a question nobody asked. + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { decision: "allow", request_id: crypto.randomUUID() }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + await expect(createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope)).rejects.toThrow( + GuardianResultCorrelationError, + ); + } finally { + mock.stop(true); + } + }); + + it("leaves a ServerHello alone -- a result carrying no request_id is not an uncorrelated one", async () => { + // The check must not fire on the handshake, whose result is a ServerHello + // and has no request_id at all: `undefined` there means "this result is + // not about a step", not "this result is about someone else's step". + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { negotiated_version: "0.1.0" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const response = await createGuardianClient(`http://localhost:${mock.port}/acs`).post(envelope); + + expect(response.result?.negotiated_version).toBe("0.1.0"); + } finally { + mock.stop(true); + } + }); + + it("against a real Guardian: steps/toolCallRequest for ls -la allows, echoing the request id", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const response = await createGuardianClient(guardian.url).post(envelope); + + expect(response.error).toBeUndefined(); + expect(response.result?.decision).toBe("allow"); + }); +}); + +// PR #10 review, Important: the shim used to receive a raw JSON-RPC response +// and work out for itself whether a decision was in it. These are the branches +// it no longer owns -- and the last of them is the one a copy of that +// inspection would get wrong. +describe("GuardianClient.requestDecision", () => { + it("answers with the decision when one arrives", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const outcome = await createGuardianClient(guardian.url).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(true); + expect(outcome.decisionArrived && outcome.decision.decision).toBe("allow"); + }); + + it("answers 'no decision' with the Guardian's own JSON-RPC error as the failure, rather than throwing", async () => { + // A method this Guardian dispatches no handler for. + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const unknownMethod = { ...envelope, method: "steps/sessionStart" }; + + const outcome = await createGuardianClient(guardian.url).requestDecision(unknownMethod); + + expect(outcome.decisionArrived).toBe(false); + expect((outcome.decisionArrived === false ? outcome.failure : undefined) as { code?: number }).toHaveProperty( + "code", + ); + }); + + it("answers 'no decision' when the transport itself fails, rather than throwing", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + // Port 1 with nothing listening: a refused connection, not a slow one. + const outcome = await createGuardianClient("http://localhost:1/acs").requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + }); + + it("honours a decision that arrives alongside a malformed `error`, never letting the failure outrank it", async () => { + // A response carrying both is malformed per JSON-RPC, but a decision is in + // it -- and a caller that checked `error` first would answer a real deny + // with a delivery-failure path instead of honouring it. + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { decision: "deny", reasoning: "blocked" }, + error: { code: -32020, message: "something also went wrong" }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(true); + expect(outcome.decisionArrived && outcome.decision.decision).toBe("deny"); + } finally { + mock.stop(true); + } + }); + + it("answers 'no decision' for a result naming none, rather than passing an undecided bag on to be rendered", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { type: "final" } }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + } finally { + mock.stop(true); + } + }); + + it("answers 'no decision' for another request's decision, however well-formed it is", async () => { + // A decision that names a foreign request_id is not this step's decision, + // and the dangerous shape of it is `allow`: honouring it would let a + // benign step's verdict stand in for one this Guardian never ruled on. + // The throw from post lands in the same "no decision" answer as a dead + // transport, so the caller's posture path resolves it and nothing here + // has to invent a taxonomy for it. + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ + jsonrpc: "2.0", + id: body.id, + result: { decision: "allow", request_id: crypto.randomUUID() }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + } finally { + mock.stop(true); + } + }); +}); + +describe("negotiateSessionConfig", () => { + it("sends handshake/hello and stores timeout_config and on_decision_failure into the session config store", async () => { + const store = createSessionConfigStore(); + expect(store.get()).toBeUndefined(); + + const sessionConfig = await negotiateSessionConfig( + { guardian: createGuardianClient(guardian.url), agentId: "claude-code", sessionId: crypto.randomUUID() }, + store, + ); + + expect(sessionConfig.on_decision_failure).toBe("proceed"); + expect(sessionConfig.timeout_config.default_ms).toBeGreaterThan(0); + + const stored = store.get(); + expect(stored).toBeDefined(); + expect(stored?.timeout_config).toEqual(sessionConfig.timeout_config); + expect(stored?.on_decision_failure).toBe("proceed"); + }); + + // PR #10 review, Important: the ServerHello used to become a SessionConfig by + // `as unknown as SessionConfig` -- a rename dressed as a type. Now it becomes + // one by being checked, so a Guardian emitting the wrong shape fails at the + // handshake instead of writing an unusable config that every later read + // silently rejects. + it("refuses a ServerHello that is not a usable session config, rather than casting it into one", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { id: string | number }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { negotiated_version: "0.1.0" } }); + }, + }); + + try { + const store = createSessionConfigStore(); + + await expect( + negotiateSessionConfig( + { + guardian: createGuardianClient(`http://localhost:${mock.port}/acs`), + agentId: "claude-code", + sessionId: crypto.randomUUID(), + }, + store, + ), + ).rejects.toThrow(/not a usable session config/); + + expect(store.get()).toBeUndefined(); + } finally { + mock.stop(true); + } + }); +}); + +describe("host -> wire -> policy -> host, end to end", () => { + it("a real rm -rf / tool call denies through the real Guardian, and renders with the reasoning in permissionDecisionReason", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + + const response = await createGuardianClient(guardian.url).post(envelope); + expect(response.error).toBeUndefined(); + + const { hookSpecificOutput } = renderDecision( + response.result as { decision: string } & Record, + hookmap, + ) as { hookSpecificOutput: Record }; + + expect(hookSpecificOutput.permissionDecision).toBe("deny"); + expect(typeof hookSpecificOutput.permissionDecisionReason).toBe("string"); + expect((hookSpecificOutput.permissionDecisionReason as string).length).toBeGreaterThan(0); + }); + + it("a real ls -la tool call allows through the real Guardian, and renders as a plain allow", async () => { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + + const response = await createGuardianClient(guardian.url).post(envelope); + expect(response.error).toBeUndefined(); + + const { hookSpecificOutput } = renderDecision( + response.result as { decision: string } & Record, + hookmap, + ) as { hookSpecificOutput: Record }; + + // No `hookEventName` here: it is not a function of the decision, so the + // shim adds it as it wraps (PR #10 review, Critical). What a Claude Code + // process actually reads back, with that field in place, is pinned in + // hosts/claude-code/test/wire-shape.test.ts against the real shim. + expect(hookSpecificOutput).toEqual({ permissionDecision: "allow" }); + }); +}); diff --git a/packages/host-adapter/test/render-decision.test.ts b/packages/host-adapter/test/render-decision.test.ts new file mode 100644 index 00000000..643d3f35 --- /dev/null +++ b/packages/host-adapter/test/render-decision.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "bun:test"; +import { loadHookmap, type Hookmap } from "../src/build-envelope.ts"; +import { renderDecision } from "../src/render-decision.ts"; + +/** + * A hookmap for a host that is not Claude Code, deliberately. + * + * The old version of this file used the real hookmap's decisions block, which + * meant every assertion here was also an assertion about one host's field + * names -- and that was the shape of the problem PR #10's Critical finding + * named: the adapter's own tests could not tell "renders what the hookmap + * says" apart from "renders permissionDecision". This host nests its decision + * two levels deep under different names, carries a field OUTSIDE that wrapper, + * and has no permission-style field at all. Nothing in the module under test + * knows any of it. + * + * The one test at the bottom does load the real file, so the shipped hookmap + * is still exercised end to end. + */ +const hookmap: Hookmap = { + host: "some-other-host", + hooks: { + BeforeTool: { + acs_method: "steps/toolCallRequest", + tool_name: "$.tool_name", + arguments: "$.tool_input", + }, + }, + decisions: { + allow: { + output: { + "gate.verdict": { value: "pass" }, + "gate.note": { from: "reasoning", type: "string" }, + }, + }, + deny: { + output: { + "gate.verdict": { value: "fail" }, + "gate.note": { from: "reasoning", type: "string" }, + blocked: { value: true }, + }, + }, + modify: { + output: { + "gate.verdict": { value: "pass" }, + "gate.rewritten": { from: "modifications" }, + }, + }, + }, +}; + +describe("renderDecision", () => { + it("renders exactly the object the hookmap's output paths describe, nesting as declared", () => { + expect(renderDecision({ decision: "deny", reasoning: "blocked: destructive command" }, hookmap)).toEqual({ + gate: { verdict: "fail", note: "blocked: destructive command" }, + blocked: true, + }); + }); + + it("leaves a `from` field off entirely when the decision does not carry it", () => { + expect(renderDecision({ decision: "allow" }, hookmap)).toEqual({ gate: { verdict: "pass" } }); + }); + + it("leaves a `from` field off when the decision carries the wrong type for it", () => { + // A Guardian putting an object where the hookmap declared prose would + // otherwise produce an output the host cannot render -- and a host that + // rejects the whole output treats the hook as having produced no decision, + // which is a fail-open from a decision that arrived perfectly well. + expect(renderDecision({ decision: "allow", reasoning: { text: "not a string" } }, hookmap)).toEqual({ + gate: { verdict: "pass" }, + }); + }); + + it("a warn-derived allow (allow + non-empty policy_references) still renders as a plain allow -- no separate rendering", () => { + expect( + renderDecision( + { + decision: "allow", + policy_references: [{ policy_id: "stock_policy_bundle", rule_id: "drift_detected" }], + }, + hookmap, + ), + ).toEqual({ gate: { verdict: "pass" } }); + }); + + it("copies a whole object through a `from` field with no declared type", () => { + expect( + renderDecision( + { + decision: "modify", + reasoning: "redacted a secret", + modifications: { parameter_overrides: { command: "echo [REDACTED]" } }, + }, + hookmap, + ), + ).toEqual({ + gate: { verdict: "pass", rewritten: { parameter_overrides: { command: "echo [REDACTED]" } } }, + }); + }); + + it("is hookmap-driven: mutating a decision's literal changes the rendered output with no code change", () => { + const mutated: Hookmap = { + ...hookmap, + decisions: { ...hookmap.decisions, allow: { output: { "gate.verdict": { value: "review" } } } }, + }; + + expect(renderDecision({ decision: "allow" }, hookmap)).toEqual({ gate: { verdict: "pass" } }); + expect(renderDecision({ decision: "allow" }, mutated)).toEqual({ gate: { verdict: "review" } }); + }); + + it("is hookmap-driven: mutating a `from` changes which decision field feeds the host field", () => { + const mutated: Hookmap = { + ...hookmap, + decisions: { + ...hookmap.decisions, + deny: { output: { "gate.verdict": { value: "fail" }, "gate.note": { from: "reason_codes" } } }, + }, + }; + + expect(renderDecision({ decision: "deny", reasoning: "human text", reason_codes: "machine_code" }, mutated)).toEqual( + { gate: { verdict: "fail", note: "machine_code" } }, + ); + }); + + it("is hookmap-driven: a path with no dot puts the field alongside the wrapper, not inside it", () => { + // The shape the old, host-named types could not express at all. + const flat: Hookmap = { + ...hookmap, + decisions: { allow: { output: { verdict: { value: "pass" } } } }, + }; + + expect(renderDecision({ decision: "allow" }, flat)).toEqual({ verdict: "pass" }); + }); + + it("throws on an ACS decision absent from the hookmap's decisions block, rather than defaulting", () => { + const noDeny: Hookmap = { ...hookmap, decisions: { allow: hookmap.decisions!.allow! } }; + + expect(() => renderDecision({ decision: "deny", reasoning: "x" }, noDeny)).toThrow(/no decisions entry/); + }); + + it("throws when the hookmap has no decisions block at all", () => { + const noDecisions: Hookmap = { host: "some-other-host", hooks: hookmap.hooks }; + + expect(() => renderDecision({ decision: "allow" }, noDecisions)).toThrow(/no decisions block/); + }); + + it("throws on an entry with an empty or missing output block, rather than rendering nothing", () => { + // An output with no decision in it is read by a host as "the hook produced + // nothing", which is the same bypass a missing entry is, only quieter. + for (const broken of [{}, { output: {} }, null]) { + const bad: Hookmap = { ...hookmap, decisions: { allow: broken } }; + expect(() => renderDecision({ decision: "allow" }, bad)).toThrow(/non-empty "output" block/); + } + }); + + it("throws on an output field naming neither a literal nor a source", () => { + const bad: Hookmap = { ...hookmap, decisions: { allow: { output: { "gate.verdict": { tpye: "string" } } } } }; + + expect(() => renderDecision({ decision: "allow" }, bad)).toThrow(/must name a literal/); + }); + + it("throws on two output paths that collide, rather than silently picking one", () => { + const collides: Hookmap = { + ...hookmap, + decisions: { allow: { output: { gate: { value: "pass" }, "gate.verdict": { value: "pass" } } } }, + }; + + expect(() => renderDecision({ decision: "allow" }, collides)).toThrow(/nests under/); + }); + + it("throws on an output path naming a reserved segment, which would mutate a prototype instead of adding a key", () => { + const reserved: Hookmap = { + ...hookmap, + decisions: { allow: { output: { "__proto__.verdict": { value: "pass" } } } }, + }; + + expect(() => renderDecision({ decision: "allow" }, reserved)).toThrow(/addresses no field/); + }); + + it("loads the real claude-code.hookmap.yaml and renders a deny end to end", () => { + const real = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); + + expect(renderDecision({ decision: "deny", reasoning: "blocked" }, real)).toEqual({ + hookSpecificOutput: { + permissionDecision: "deny", + permissionDecisionReason: "blocked", + }, + }); + }); +}); diff --git a/policy/lib/agt_default.rego b/policy/lib/agt_default.rego new file mode 100644 index 00000000..5aed2735 --- /dev/null +++ b/policy/lib/agt_default.rego @@ -0,0 +1,174 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT default policy. Hosts that do not author Rego of their own bind their +# manifest's `rego` policy to `data.agt.defaults.verdict`. The library +# imports every AGT stock helper and consults each one in priority order: +# IFC deny > confidence deny > budget deny > content_hash deny > egress +# deny > pattern deny > drift warn > allow. Configuration travels through +# the host-supplied `data.agt.defaults.config` document so a manifest only +# has to set thresholds, allowlists, and pattern lists in YAML without +# touching Rego. The M6 GovernancePolicy migration tool emits this exact +# binding for hosts coming from the legacy declarative policy. + +package agt.defaults + +import data.agt.approval +import data.agt.budgets +import data.agt.confidence +import data.agt.content_hash +import data.agt.drift +import data.agt.egress +import data.agt.ifc +import data.agt.patterns +import data.agt.redact +import rego.v1 + +# Host-supplied configuration lives at data.agt.defaults.config (loaded +# from a JSON or YAML data document or pushed by the SDK). Referencing +# it via the cfg helper avoids a self-recursive rule named `config` in +# this package. +cfg := value if { + value := data.agt.defaults.config + is_object(value) +} else := {} + +# --------------------------------------------------------------------------- +# Per-class verdict shortcuts. Each is `undefined` (no verdict) when its +# configuration is absent or the rule it gates does not match. + +ifc_verdict := value if { + clearance := cfg.ifc.sink_clearance + is_string(clearance) + labels := ifc_labels + value := ifc.verdict_propagating(clearance, labels) +} + +ifc_labels := labels if { + input.intervention_point == "output" + labels := ifc.result_labels +} else := labels if { + labels := ifc.source_labels +} + +confidence_verdict := value if { + threshold := cfg.confidence.min_score + value := confidence.deny_if_low_confidence(threshold) +} + +budgets_verdict := value if { + thresholds := cfg.budgets + is_object(thresholds) + value := budgets.deny_if_budget_exceeded(thresholds) +} + +content_hash_verdict := value if { + cfg.content_hash.enforce == true + value := content_hash.deny_if_mismatch +} + +egress_verdict := value if { + rules := cfg.egress + is_object(rules) + value := egress.deny_egress(rules) +} + +pattern_verdict := value if { + rules := cfg.patterns + is_object(rules) + pats := rules.patterns + is_array(pats) + text := pattern_text(rules) + reason := pattern_reason(rules) + value := patterns.deny_if_pattern(text, pats, reason) +} + +pattern_text(rules) := value if { + is_string(rules.text) + value := rules.text +} else := value if { + value := input.policy_target.value + is_string(value) +} else := "" + +pattern_reason(rules) := value if { + is_string(rules.reason) + value := rules.reason +} else := "pattern_blocked" + +redact_verdict := value if { + rules := cfg.redact + is_object(rules) + pats := rules.patterns + is_array(pats) + text := input.policy_target.value + is_string(text) + replacement := redact_replacement(rules) + value := redact.redact_text(text, pats, replacement) +} + +redact_replacement(rules) := value if { + is_string(rules.replacement) + value := rules.replacement +} else := redact.default_replacement + +drift_verdict := value if { + threshold := cfg.drift.warn_threshold + value := drift.warn_if_drift(threshold) +} + +approval_verdict := value if { + required := cfg.approval.required + required == true + approvers := cfg.approval.approvers + value := approval.escalate_if_approver_required(approvers) +} + +# --------------------------------------------------------------------------- +# Final verdict. Highest-severity decision wins; transform short-circuits +# above warn but below deny, matching the AGT verdict severity (deny > +# escalate > transform > warn > allow). The order below encodes that +# ranking. + +default verdict := {"decision": "allow"} + +verdict := ifc_verdict if { + ifc_verdict.decision == "deny" +} + +else := confidence_verdict if { + confidence_verdict.decision == "deny" +} + +else := budgets_verdict if { + budgets_verdict.decision == "deny" +} + +else := content_hash_verdict if { + content_hash_verdict.decision == "deny" +} + +else := egress_verdict if { + egress_verdict.decision == "deny" +} + +else := pattern_verdict if { + pattern_verdict.decision == "deny" +} + +else := approval_verdict if { + approval_verdict.decision == "escalate" +} + +else := redact_verdict if { + redact_verdict.decision == "transform" +} + +else := drift_verdict if { + drift_verdict.decision == "warn" +} + +else := ifc_verdict if { + ifc_verdict.decision == "allow" + ifc_verdict.result_labels +} diff --git a/policy/lib/agt_default_test.rego b/policy/lib/agt_default_test.rego new file mode 100644 index 00000000..537ace02 --- /dev/null +++ b/policy/lib/agt_default_test.rego @@ -0,0 +1,135 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.defaults_test + +import data.agt.defaults +import data.agt.patterns +import rego.v1 + +base_snapshot := { + "intervention_point": "pre_tool_call", + "snapshot": {"envelope": {"budgets": {"tool_call_count": 0, "token_count": 0, "elapsed_seconds": 0, "cost_usd": 0}}}, + "annotations": {}, + "tool": {}, + "policy_target": {"value": "benign text"}, +} + +test_default_allows_with_empty_config if { + defaults.verdict == {"decision": "allow"} with input as base_snapshot + with data.agt.defaults.config as {} +} + +test_budget_exceeded_denies if { + verdict := defaults.verdict with input as object.union(base_snapshot, {"snapshot": {"envelope": {"budgets": {"tool_call_count": 20, "token_count": 0, "elapsed_seconds": 0, "cost_usd": 0}}}}) + with data.agt.defaults.config as {"budgets": {"tool_call_count": 10, "token_count": 99999, "elapsed_seconds": 9999, "cost_usd": 9999}} + verdict.decision == "deny" + verdict.reason == "budget_tool_calls_exceeded" +} + +test_malformed_budget_counter_denies if { + verdict := defaults.verdict with input as object.union(base_snapshot, {"snapshot": {"envelope": {"budgets": {"tool_call_count": 0, "token_count": "999999", "elapsed_seconds": 0, "cost_usd": 0}}}}) + with data.agt.defaults.config as {"budgets": {"tool_call_count": 99999, "token_count": 1, "elapsed_seconds": 9999, "cost_usd": 9999}} + verdict.decision == "deny" + verdict.reason == "budget_counter_invalid" +} + +test_confidence_low_denies if { + pi := object.union(base_snapshot, {"annotations": {"confidence": {"score": 0.2}}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"confidence": {"min_score": 0.5}} + verdict.decision == "deny" + verdict.reason == "confidence_below_threshold" +} + +test_content_hash_mismatch_denies if { + pi := object.union(base_snapshot, { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": { + "envelope": base_snapshot.snapshot.envelope, + "tool_call": {"content_hash": "sha256:zzz"}, + }, + }) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"content_hash": {"enforce": true}} + verdict.decision == "deny" + verdict.reason == "tool_content_hash_mismatch" +} + +test_egress_denies if { + pi := object.union(base_snapshot, { + "tool": {"security_labels": ["allowed.com"]}, + "snapshot": { + "envelope": base_snapshot.snapshot.envelope, + "tool_call": {"args": {"url": "https://attacker.com/x"}}, + }, + }) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"egress": {}} + verdict.decision == "deny" + verdict.reason == "egress_destination_not_allowed" +} + +test_pattern_denies if { + pi := object.union(base_snapshot, {"policy_target": {"value": "ssn 111-22-3333"}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"patterns": {"patterns": [patterns.pii_ssn], "reason": "pii_detected"}} + verdict.decision == "deny" + verdict.reason == "pii_detected" +} + +test_redact_transforms if { + pi := object.union(base_snapshot, {"policy_target": {"value": "ssn 111-22-3333"}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"redact": {"patterns": [patterns.pii_ssn]}} + verdict.decision == "transform" + verdict.transform.path == "$policy_target" + verdict.transform.value == "ssn [REDACTED]" +} + +test_drift_warns if { + pi := object.union(base_snapshot, {"annotations": {"drift_score": 0.8}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"drift": {"warn_threshold": 0.5}} + verdict.decision == "warn" + verdict.reason == "drift_detected" +} + +test_approval_escalates if { + verdict := defaults.verdict with input as base_snapshot + with data.agt.defaults.config as {"approval": {"required": true, "approvers": ["alice"]}} + verdict.decision == "escalate" + verdict.reason == "approval_required" +} + +test_ifc_violation_denies_at_input_intervention_point if { + pi := { + "intervention_point": "input", + "snapshot": { + "envelope": base_snapshot.snapshot.envelope, + "input": {"ifc": {"source_labels": ["secret"]}}, + }, + "annotations": {}, + "tool": {}, + "policy_target": {"value": ""}, + } + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as {"ifc": {"sink_clearance": "internal"}} + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_severity_ranking_deny_beats_transform if { + pi := object.union(base_snapshot, {"policy_target": {"value": "ssn 111-22-3333"}, "annotations": {"confidence": {"score": 0.1}}}) + verdict := defaults.verdict with input as pi + with data.agt.defaults.config as { + "confidence": {"min_score": 0.5}, + "redact": {"patterns": [patterns.pii_ssn]}, + } + verdict.decision == "deny" + verdict.reason == "confidence_below_threshold" +} + +test_missing_config_falls_through_to_allow if { + defaults.verdict == {"decision": "allow"} with input as base_snapshot +} diff --git a/policy/lib/agt_ifc.rego b/policy/lib/agt_ifc.rego new file mode 100644 index 00000000..4557f8c2 --- /dev/null +++ b/policy/lib/agt_ifc.rego @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock IFC label-flow library. This file replaces the upstream +# `agent_control_specification.lib.ifc` library for AGT users authoring +# manifests against the AGT snapshot shape. The function surface +# (`dominates`, `max_sensitivity`, `flow_allowed`, `allow`, `deny`, +# `verdict`, `verdict_propagating`, and their `_with_lattice` variants) +# matches the upstream library so policies authored against the AGT helpers +# remain familiar. The snapshot paths are AGT-correct per +# AGT-SNAPSHOT-1.0.md §2.2 (input) and §2.7 (output): +# `input.snapshot.input.ifc.source_labels` at the `input` intervention +# point, and `input.snapshot.response.ifc.result_labels` at `output`. The +# upstream `agent_control_specification.lib.ifc` library reads +# `input.snapshot.ifc.*`, which the AGT host SDKs do not populate and which +# would therefore fail closed on every call. AGT users MUST import +# `data.agt.ifc` rather than the upstream package. + +package agt.ifc + +import rego.v1 + +default_lattice := {"dominates": { + "public": ["public"], + "internal": ["public", "internal"], + "confidential": ["public", "internal", "confidential"], + "secret": ["public", "internal", "confidential", "secret"], +}} + +dominates(clearance, label) if { + dominates_with_lattice(default_lattice, clearance, label) +} + +dominates_with_lattice(lattice, clearance, label) if { + is_string(clearance) + is_string(label) + dominance := object.get(lattice, "dominates", {}) + labels := object.get(dominance, clearance, []) + some dominated in labels + dominated == label +} + +max_sensitivity(labels) := label if { + label := max_sensitivity_with_lattice(default_lattice, labels) +} + +max_sensitivity_with_lattice(lattice, labels) := label if { + count(labels) > 0 + label := labels[_] + every other in labels { + dominates_with_lattice(lattice, label, other) + } +} + +flow_allowed(clearance, labels) if { + flow_allowed_with_lattice(default_lattice, clearance, labels) +} + +flow_allowed_with_lattice(lattice, clearance, labels) if { + is_string(clearance) + is_array(labels) + count(labels) > 0 + sensitivity := max_sensitivity_with_lattice(lattice, labels) + dominates_with_lattice(lattice, clearance, sensitivity) +} + +allow(clearance, labels) := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +allow_with_lattice(lattice, clearance, labels) := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +deny(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} + +deny_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} + +verdict(clearance, labels) := value if { + not flow_allowed(clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +verdict_with_lattice(lattice, clearance, labels) := value if { + not flow_allowed_with_lattice(lattice, clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +violation(clearance, labels) := { + "decision": "deny", + "reason": "ifc_clearance_violation", + "message": sprintf("IFC clearance violation for sink clearance %v and data labels %v.", [clearance, labels]), +} + +propagated_labels(labels) := [max_sensitivity(labels)] if { + count(labels) > 0 +} else := [] + +propagated_labels_with_lattice(lattice, labels) := [max_sensitivity_with_lattice(lattice, labels)] if { + count(labels) > 0 +} else := [] + +verdict_propagating(clearance, labels) := value if { + not flow_allowed(clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels(labels)} if { + flow_allowed(clearance, labels) +} + +verdict_propagating_with_lattice(lattice, clearance, labels) := value if { + not flow_allowed_with_lattice(lattice, clearance, labels) + value := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels_with_lattice(lattice, labels)} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +# Convenience helper that reads AGT snapshot source labels at the input +# intervention point. +source_labels := value if { + value := input.snapshot.input.ifc.source_labels + is_array(value) +} else := [] + +# Convenience helper that reads AGT snapshot result labels at the output +# intervention point. +result_labels := value if { + value := input.snapshot.response.ifc.result_labels + is_array(value) +} else := [] + +# allow_if_dominates is the AGT shorthand for the no-write-down policy. +# The sink clearance must dominate every source label on the AGT snapshot. +allow_if_dominates(sink_clearance, labels) := value if { + value := verdict(sink_clearance, labels) +} diff --git a/policy/lib/agt_ifc_test.rego b/policy/lib/agt_ifc_test.rego new file mode 100644 index 00000000..9c5fc6cb --- /dev/null +++ b/policy/lib/agt_ifc_test.rego @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.ifc_test + +import data.agt.ifc +import rego.v1 + +test_clearance_dominates_data_allows if { + ifc.flow_allowed("secret", ["confidential"]) + ifc.max_sensitivity(["confidential"]) == "confidential" + ifc.verdict("secret", ["confidential"]).decision == "allow" +} + +test_data_exceeds_clearance_denies if { + not ifc.flow_allowed("internal", ["confidential"]) + verdict := ifc.deny("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_incomparable_labels_deny_fail_closed if { + lattice := {"dominates": { + "public": ["public"], + "pii": ["public", "pii"], + "pci": ["public", "pci"], + }} + not ifc.flow_allowed_with_lattice(lattice, "pii", ["pci"]) + verdict := ifc.deny_with_lattice(lattice, "pii", ["pci"]) + verdict.reason == "ifc_clearance_violation" +} + +test_missing_and_empty_labels_deny_fail_closed if { + not ifc.flow_allowed("secret", []) + verdict := ifc.deny("secret", []) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_multi_label_uses_max_sensitivity if { + labels := ["public", "confidential", "internal"] + ifc.max_sensitivity(labels) == "confidential" + ifc.flow_allowed("secret", labels) + not ifc.flow_allowed("internal", labels) +} + +test_verdict_propagating_returns_joined_label if { + verdict := ifc.verdict_propagating("secret", ["public", "confidential", "internal"]) + verdict.decision == "allow" + verdict.result_labels == ["confidential"] +} + +test_verdict_propagating_deny_omits_result_labels if { + verdict := ifc.verdict_propagating("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" + not verdict.result_labels +} + +test_source_labels_reads_agt_input_path if { + ifc.source_labels == ["confidential"] with input as {"snapshot": {"input": {"ifc": {"source_labels": ["confidential"]}}}} +} + +test_source_labels_defaults_to_empty if { + ifc.source_labels == [] with input as {"snapshot": {}} + ifc.source_labels == [] with input as {} +} + +test_result_labels_reads_agt_output_path if { + ifc.result_labels == ["internal"] with input as {"snapshot": {"response": {"ifc": {"result_labels": ["internal"]}}}} +} + +test_result_labels_defaults_to_empty if { + ifc.result_labels == [] with input as {"snapshot": {}} +} + +test_allow_if_dominates_returns_verdict if { + allow := ifc.allow_if_dominates("secret", ["internal"]) + allow.decision == "allow" + deny := ifc.allow_if_dominates("public", ["secret"]) + deny.decision == "deny" + deny.reason == "ifc_clearance_violation" +} + +test_does_not_read_upstream_ifc_path if { + # The upstream library reads `input.snapshot.ifc.source_labels`. AGT + # hosts put source labels under `input.snapshot.input.ifc.source_labels`. + # Confirm the AGT helper sees nothing at the upstream path. + ifc.source_labels == [] with input as {"snapshot": {"ifc": {"source_labels": ["secret"]}}} +} diff --git a/policy/lib/approval.rego b/policy/lib/approval.rego new file mode 100644 index 00000000..017cec1a --- /dev/null +++ b/policy/lib/approval.rego @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock approval helpers. These rules produce `escalate` verdicts per +# SPECIFICATION.md §13.1; the host approval path (§17.1) resolves +# them through the resolver declared in the `approval` manifest section +# (§D5). Approver-list helpers let manifests express "this action requires +# named approvers" without authoring a custom Rego rule. + +package agt.approval + +import rego.v1 + +escalate_if(condition, reason) := verdict if { + condition + is_string(reason) + verdict := { + "decision": "escalate", + "reason": reason, + } +} + +escalate_if_approver_required(approvers) := verdict if { + is_array(approvers) + count(approvers) > 0 + verdict := { + "decision": "escalate", + "reason": "approval_required", + "message": sprintf("requires approval from %v", [approvers]), + } +} + +escalate_with_message(reason, message) := verdict if { + is_string(reason) + is_string(message) + verdict := { + "decision": "escalate", + "reason": reason, + "message": message, + } +} diff --git a/policy/lib/approval_test.rego b/policy/lib/approval_test.rego new file mode 100644 index 00000000..168282a0 --- /dev/null +++ b/policy/lib/approval_test.rego @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.approval_test + +import data.agt.approval +import rego.v1 + +test_escalate_if_true_condition_emits_verdict if { + verdict := approval.escalate_if(true, "needs_review") + verdict.decision == "escalate" + verdict.reason == "needs_review" +} + +test_escalate_if_false_condition_emits_nothing if { + not approval.escalate_if(false, "needs_review") +} + +test_escalate_if_approver_required_lists_approvers if { + verdict := approval.escalate_if_approver_required(["alice", "bob"]) + verdict.decision == "escalate" + verdict.reason == "approval_required" + contains(verdict.message, "alice") +} + +test_escalate_if_approver_required_empty_emits_nothing if { + not approval.escalate_if_approver_required([]) +} + +test_escalate_with_message_carries_message if { + verdict := approval.escalate_with_message("high_value_action", "spend exceeds 1000") + verdict.decision == "escalate" + verdict.reason == "high_value_action" + verdict.message == "spend exceeds 1000" +} + +test_escalate_if_non_string_reason_emits_nothing if { + not approval.escalate_if(true, 123) +} diff --git a/policy/lib/budgets.rego b/policy/lib/budgets.rego new file mode 100644 index 00000000..3ae6cbc4 --- /dev/null +++ b/policy/lib/budgets.rego @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock budget helpers. These rules read the +# `input.snapshot.envelope.budgets` block per AGT-SNAPSHOT-1.0.md §1 and emit +# AGT verdicts per SPECIFICATION.md §13.1. A budget is exceeded when +# the host-tracked counter has already reached the configured limit. The +# helpers fail safe when a counter or threshold is absent. Present malformed +# counters fail closed instead of being coerced to zero. + +package agt.budgets + +import rego.v1 + +budget_counter_names := {"tool_call_count", "token_count", "elapsed_seconds", "cost_usd"} + +budget_counter_present(name) if { + _ := input.snapshot.envelope.budgets[name] +} + +malformed_budget_counter(name) if { + budget_counter_names[name] + value := input.snapshot.envelope.budgets[name] + not is_number(value) +} + +budget_counter(name) := value if { + value := input.snapshot.envelope.budgets[name] + is_number(value) +} else := 0 if { + not budget_counter_present(name) +} + +tool_call_count := budget_counter("tool_call_count") + +token_count := budget_counter("token_count") + +elapsed_seconds := budget_counter("elapsed_seconds") + +cost_usd := budget_counter("cost_usd") + +max_tool_calls_exceeded(limit) if { + is_number(limit) + tool_call_count >= limit +} + +max_tokens_exceeded(limit) if { + is_number(limit) + token_count >= limit +} + +timeout_exceeded(limit) if { + is_number(limit) + elapsed_seconds >= limit +} + +max_cost_exceeded(limit) if { + is_number(limit) + cost_usd >= limit +} + +deny_if_budget_exceeded(thresholds) := verdict if { + some name in budget_counter_names + malformed_budget_counter(name) + verdict := { + "decision": "deny", + "reason": "budget_counter_invalid", + "message": sprintf("budget counter %s must be a number", [name]), + } +} else := verdict if { + max_tool_calls_exceeded(thresholds.tool_call_count) + verdict := { + "decision": "deny", + "reason": "budget_tool_calls_exceeded", + "message": sprintf("tool_call_count %v reached limit %v", [tool_call_count, thresholds.tool_call_count]), + } +} else := verdict if { + max_tokens_exceeded(thresholds.token_count) + verdict := { + "decision": "deny", + "reason": "budget_tokens_exceeded", + "message": sprintf("token_count %v reached limit %v", [token_count, thresholds.token_count]), + } +} else := verdict if { + timeout_exceeded(thresholds.elapsed_seconds) + verdict := { + "decision": "deny", + "reason": "budget_timeout_exceeded", + "message": sprintf("elapsed_seconds %v reached limit %v", [elapsed_seconds, thresholds.elapsed_seconds]), + } +} else := verdict if { + max_cost_exceeded(thresholds.cost_usd) + verdict := { + "decision": "deny", + "reason": "budget_cost_exceeded", + "message": sprintf("cost_usd %v reached limit %v", [cost_usd, thresholds.cost_usd]), + } +} diff --git a/policy/lib/budgets_test.rego b/policy/lib/budgets_test.rego new file mode 100644 index 00000000..5ab02746 --- /dev/null +++ b/policy/lib/budgets_test.rego @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.budgets_test + +import data.agt.budgets +import rego.v1 + +snapshot_with(values) := {"snapshot": {"envelope": {"budgets": values}}} + +test_tool_call_count_reads_envelope if { + budgets.tool_call_count == 7 with input as snapshot_with({"tool_call_count": 7}) +} + +test_token_count_reads_envelope if { + budgets.token_count == 1024 with input as snapshot_with({"token_count": 1024}) +} + +test_missing_budgets_defaults_to_zero if { + budgets.tool_call_count == 0 with input as {"snapshot": {"envelope": {}}} + budgets.token_count == 0 with input as {"snapshot": {"envelope": {}}} + budgets.elapsed_seconds == 0 with input as {"snapshot": {"envelope": {}}} + budgets.cost_usd == 0 with input as {"snapshot": {"envelope": {}}} +} + +test_present_malformed_counter_is_not_coerced_to_zero if { + not budgets.token_count with input as snapshot_with({"token_count": "999999"}) + budgets.malformed_budget_counter("token_count") with input as snapshot_with({"token_count": "999999"}) + budgets.malformed_budget_counter("elapsed_seconds") with input as snapshot_with({"elapsed_seconds": null}) +} + +test_max_tool_calls_under_limit_does_not_match if { + not budgets.max_tool_calls_exceeded(10) with input as snapshot_with({"tool_call_count": 3}) +} + +test_max_tool_calls_at_limit_matches if { + budgets.max_tool_calls_exceeded(10) with input as snapshot_with({"tool_call_count": 10}) +} + +test_max_tokens_exceeded_matches if { + budgets.max_tokens_exceeded(1000) with input as snapshot_with({"token_count": 1200}) +} + +test_timeout_exceeded_matches if { + budgets.timeout_exceeded(60) with input as snapshot_with({"elapsed_seconds": 90.5}) +} + +test_max_cost_exceeded_matches if { + budgets.max_cost_exceeded(1.5) with input as snapshot_with({"cost_usd": 2.0}) +} + +test_deny_if_budget_exceeded_returns_no_verdict_when_under_limits if { + not budgets.deny_if_budget_exceeded({ + "tool_call_count": 100, + "token_count": 10000, + "elapsed_seconds": 600, + "cost_usd": 5, + }) with input as snapshot_with({ + "tool_call_count": 1, + "token_count": 50, + "elapsed_seconds": 1.5, + "cost_usd": 0.01, + }) +} + +test_deny_if_budget_exceeded_malformed_counter if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 1, "elapsed_seconds": 9999, "cost_usd": 9999}) with input as snapshot_with({"token_count": "999999"}) + verdict.decision == "deny" + verdict.reason == "budget_counter_invalid" +} + +test_deny_if_budget_exceeded_tool_calls if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 5, "token_count": 99999, "elapsed_seconds": 9999, "cost_usd": 9999}) with input as snapshot_with({"tool_call_count": 5}) + verdict.decision == "deny" + verdict.reason == "budget_tool_calls_exceeded" +} + +test_deny_if_budget_exceeded_tokens if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 100, "elapsed_seconds": 9999, "cost_usd": 9999}) with input as snapshot_with({"token_count": 200}) + verdict.decision == "deny" + verdict.reason == "budget_tokens_exceeded" +} + +test_deny_if_budget_exceeded_timeout if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 99999, "elapsed_seconds": 30, "cost_usd": 9999}) with input as snapshot_with({"elapsed_seconds": 45.2}) + verdict.decision == "deny" + verdict.reason == "budget_timeout_exceeded" +} + +test_deny_if_budget_exceeded_cost if { + verdict := budgets.deny_if_budget_exceeded({"tool_call_count": 9999, "token_count": 99999, "elapsed_seconds": 9999, "cost_usd": 1.0}) with input as snapshot_with({"cost_usd": 2.5}) + verdict.decision == "deny" + verdict.reason == "budget_cost_exceeded" +} + +test_missing_snapshot_does_not_emit_verdict if { + not budgets.deny_if_budget_exceeded({"tool_call_count": 10, "token_count": 10, "elapsed_seconds": 10, "cost_usd": 10}) with input as {} +} diff --git a/policy/lib/confidence.rego b/policy/lib/confidence.rego new file mode 100644 index 00000000..acab99ff --- /dev/null +++ b/policy/lib/confidence.rego @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock confidence gate. Hosts attach a self-assessed confidence score +# under `input.annotations.confidence.score` (range 0..1) and this library +# denies the action with `confidence_below_threshold` when the score falls +# under the manifest-configured minimum. + +package agt.confidence + +import rego.v1 + +score := value if { + value := input.annotations.confidence.score + is_number(value) +} + +below(threshold) if { + is_number(threshold) + value := score + value < threshold +} + +deny_if_low_confidence(threshold) := verdict if { + below(threshold) + verdict := { + "decision": "deny", + "reason": "confidence_below_threshold", + "message": sprintf("confidence %v below threshold %v", [score, threshold]), + } +} diff --git a/policy/lib/confidence_test.rego b/policy/lib/confidence_test.rego new file mode 100644 index 00000000..362ee5ff --- /dev/null +++ b/policy/lib/confidence_test.rego @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.confidence_test + +import data.agt.confidence +import rego.v1 + +test_score_reads_annotation if { + confidence.score == 0.92 with input as {"annotations": {"confidence": {"score": 0.92}}} +} + +test_missing_annotation_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {}} + not confidence.deny_if_low_confidence(0.5) with input as {} +} + +test_score_above_threshold_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": 0.9}}} +} + +test_score_at_threshold_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": 0.5}}} +} + +test_score_below_threshold_denies if { + verdict := confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": 0.2}}} + verdict.decision == "deny" + verdict.reason == "confidence_below_threshold" +} + +test_non_numeric_score_does_not_deny if { + not confidence.deny_if_low_confidence(0.5) with input as {"annotations": {"confidence": {"score": "high"}}} +} diff --git a/policy/lib/content_hash.rego b/policy/lib/content_hash.rego new file mode 100644 index 00000000..365dcadb --- /dev/null +++ b/policy/lib/content_hash.rego @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock tool content-hash gate. The manifest tool catalog MAY declare a +# `content_hash` per AGT-MANIFEST §9. AGT-SNAPSHOT-1.0.md §2.5 lets the host +# attach `tool_call.content_hash` to the snapshot. This library denies the +# tool invocation when the declared hash and the observed hash disagree or +# when the manifest required a hash that the snapshot did not carry. + +package agt.content_hash + +import rego.v1 + +declared_hash := value if { + value := input.tool.content_hash + is_string(value) +} + +observed_hash := value if { + value := input.snapshot.tool_call.content_hash + is_string(value) +} + +declared_present if { + is_string(input.tool.content_hash) +} + +observed_present if { + is_string(input.snapshot.tool_call.content_hash) +} + +deny_if_mismatch := verdict if { + declared_present + not observed_present + verdict := { + "decision": "deny", + "reason": "tool_content_hash_mismatch", + "message": "manifest declared tool.content_hash but snapshot.tool_call.content_hash was missing", + } +} else := verdict if { + declared_present + observed_present + declared_hash != observed_hash + verdict := { + "decision": "deny", + "reason": "tool_content_hash_mismatch", + "message": sprintf("declared %v but observed %v", [declared_hash, observed_hash]), + } +} diff --git a/policy/lib/content_hash_test.rego b/policy/lib/content_hash_test.rego new file mode 100644 index 00000000..4e0b439a --- /dev/null +++ b/policy/lib/content_hash_test.rego @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.content_hash_test + +import data.agt.content_hash +import rego.v1 + +test_matching_hashes_emit_no_verdict if { + not content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": {"tool_call": {"content_hash": "sha256:abc"}}, + } +} + +test_missing_observed_hash_when_declared_denies if { + verdict := content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": {"tool_call": {}}, + } + verdict.decision == "deny" + verdict.reason == "tool_content_hash_mismatch" +} + +test_mismatch_denies if { + verdict := content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": "sha256:abc"}, + "snapshot": {"tool_call": {"content_hash": "sha256:def"}}, + } + verdict.decision == "deny" + verdict.reason == "tool_content_hash_mismatch" +} + +test_manifest_did_not_declare_emits_no_verdict if { + not content_hash.deny_if_mismatch with input as { + "tool": {}, + "snapshot": {"tool_call": {"content_hash": "sha256:abc"}}, + } +} + +test_neither_present_emits_no_verdict if { + not content_hash.deny_if_mismatch with input as {"tool": {}, "snapshot": {"tool_call": {}}} +} + +test_non_string_declared_hash_treated_as_undeclared if { + not content_hash.deny_if_mismatch with input as { + "tool": {"content_hash": 123}, + "snapshot": {"tool_call": {"content_hash": "sha256:abc"}}, + } +} diff --git a/policy/lib/data.json b/policy/lib/data.json new file mode 100644 index 00000000..2530d81e --- /dev/null +++ b/policy/lib/data.json @@ -0,0 +1,15 @@ +{ + "agt": { + "defaults": { + "config": { + "patterns": { + "patterns": [ + "(?i)rm\\s+-[a-z]*r[a-z]*f[a-z]*\\s+/(?:\\s|$)", + "(?i)rm\\s+-[a-z]*f[a-z]*r[a-z]*\\s+/(?:\\s|$)" + ], + "reason": "destructive_shell_command_blocked" + } + } + } + } +} diff --git a/policy/lib/drift.rego b/policy/lib/drift.rego new file mode 100644 index 00000000..7943728e --- /dev/null +++ b/policy/lib/drift.rego @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock drift gate. Hosts run a behaviour-drift detector outside the +# policy engine and attach its score under `input.annotations.drift_score` +# (a host annotator name agreed in the manifest). This library issues a +# warn verdict per SPECIFICATION.md §13.1 when the score crosses the +# configured threshold so a host can flag the run without blocking. + +package agt.drift + +import rego.v1 + +score := value if { + value := input.annotations.drift_score + is_number(value) +} + +drift_exceeds(threshold) if { + is_number(threshold) + value := score + value >= threshold +} + +warn_if_drift(threshold) := verdict if { + drift_exceeds(threshold) + verdict := { + "decision": "warn", + "reason": "drift_detected", + "message": sprintf("drift_score %v reached threshold %v", [score, threshold]), + } +} diff --git a/policy/lib/drift_test.rego b/policy/lib/drift_test.rego new file mode 100644 index 00000000..327cc334 --- /dev/null +++ b/policy/lib/drift_test.rego @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.drift_test + +import data.agt.drift +import rego.v1 + +test_score_reads_annotation if { + drift.score == 0.7 with input as {"annotations": {"drift_score": 0.7}} +} + +test_missing_score_does_not_match if { + not drift.warn_if_drift(0.5) with input as {"annotations": {}} + not drift.warn_if_drift(0.5) with input as {} +} + +test_score_below_threshold_does_not_warn if { + not drift.warn_if_drift(0.5) with input as {"annotations": {"drift_score": 0.2}} +} + +test_score_at_threshold_warns if { + verdict := drift.warn_if_drift(0.5) with input as {"annotations": {"drift_score": 0.5}} + verdict.decision == "warn" + verdict.reason == "drift_detected" +} + +test_score_above_threshold_warns if { + verdict := drift.warn_if_drift(0.4) with input as {"annotations": {"drift_score": 0.9}} + verdict.decision == "warn" + verdict.reason == "drift_detected" +} + +test_non_numeric_score_does_not_warn if { + not drift.warn_if_drift(0.5) with input as {"annotations": {"drift_score": "high"}} +} diff --git a/policy/lib/egress.rego b/policy/lib/egress.rego new file mode 100644 index 00000000..6feac017 --- /dev/null +++ b/policy/lib/egress.rego @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock egress gate. Hosts use `input.tool.security_labels` to record the +# domains a tool sink is permitted to reach. When the host annotates the +# snapshot with the destination of a tool call, this library denies any +# destination that the allowlist does not cover. Wildcards follow glob.match +# semantics so callers can list `*.example.com` style patterns. +# `rules.destination_paths` lets callers specify where to look for the +# destination in the policy input. + +package agt.egress + +import rego.v1 + +default_destination_paths := [ + ["snapshot", "tool_call", "args", "url"], + ["snapshot", "tool_call", "args", "endpoint"], + ["snapshot", "tool_call", "args", "host"], + ["snapshot", "tool_call", "args", "domain"], + ["annotations", "egress", "destination"], +] + +destination(rules) := value if { + paths := destination_paths(rules) + some path in paths + value := resolve(input, path) + is_string(value) +} + +destination_paths(rules) := paths if { + paths := rules.destination_paths + is_array(paths) + count(paths) > 0 +} else := default_destination_paths + +resolve(obj, path) := value if { + walk(obj, [path, value]) +} + +host_of(url) := value if { + is_string(url) + contains(url, "://") + after_scheme := split(url, "://")[1] + value := split(split(after_scheme, "/")[0], ":")[0] +} else := value if { + is_string(url) + not contains(url, "://") + value := split(split(url, "/")[0], ":")[0] +} + +allowlist(rules) := value if { + value := rules.allowlist + is_array(value) +} else := value if { + value := input.tool.security_labels + is_array(value) +} else := [] + +allowed(dest, patterns) if { + host := host_of(dest) + some pattern in patterns + glob.match(pattern, [".", "/"], host) +} + +deny_egress(rules) := verdict if { + dest := destination(rules) + patterns := allowlist(rules) + not allowed(dest, patterns) + verdict := { + "decision": "deny", + "reason": "egress_destination_not_allowed", + "message": sprintf("destination %v not in allowlist %v", [host_of(dest), patterns]), + } +} diff --git a/policy/lib/egress_test.rego b/policy/lib/egress_test.rego new file mode 100644 index 00000000..88930d74 --- /dev/null +++ b/policy/lib/egress_test.rego @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.egress_test + +import data.agt.egress +import rego.v1 + +input_with_dest(host) := { + "tool": {"security_labels": ["api.example.com", "*.trusted.com"]}, + "snapshot": {"tool_call": {"args": {"url": host}}}, +} + +test_host_of_extracts_hostname if { + egress.host_of("https://api.example.com/path") == "api.example.com" + egress.host_of("http://api.example.com:8080") == "api.example.com" + egress.host_of("api.example.com") == "api.example.com" + egress.host_of("api.example.com/v1/foo") == "api.example.com" +} + +test_exact_match_in_allowlist_emits_no_verdict if { + not egress.deny_egress({}) with input as input_with_dest("https://api.example.com/v1") +} + +test_glob_wildcard_matches if { + not egress.deny_egress({}) with input as input_with_dest("https://web.trusted.com/x") +} + +test_off_allowlist_denies if { + verdict := egress.deny_egress({}) with input as input_with_dest("https://attacker.example.org/x") + verdict.decision == "deny" + verdict.reason == "egress_destination_not_allowed" +} + +test_explicit_allowlist_overrides_tool_security_labels if { + verdict := egress.deny_egress({"allowlist": ["allowed.com"]}) with input as input_with_dest("https://api.example.com/v1") + verdict.decision == "deny" +} + +test_destination_paths_override if { + rules := {"destination_paths": [["annotations", "egress", "destination"]]} + host_input := { + "tool": {"security_labels": ["allowed.com"]}, + "annotations": {"egress": {"destination": "https://allowed.com/x"}}, + } + not egress.deny_egress(rules) with input as host_input +} + +test_missing_destination_emits_no_verdict if { + not egress.deny_egress({}) with input as {"tool": {"security_labels": ["x"]}, "snapshot": {}} +} + +test_missing_allowlist_treats_as_empty_and_denies if { + verdict := egress.deny_egress({}) with input as {"tool": {}, "snapshot": {"tool_call": {"args": {"url": "https://x.com/"}}}} + verdict.decision == "deny" +} diff --git a/policy/lib/ifc.rego b/policy/lib/ifc.rego new file mode 100644 index 00000000..d969c682 --- /dev/null +++ b/policy/lib/ifc.rego @@ -0,0 +1,113 @@ +package agent_control_specification.lib.ifc + +import rego.v1 + +default_lattice := {"dominates": { + "public": ["public"], + "internal": ["public", "internal"], + "confidential": ["public", "internal", "confidential"], + "secret": ["public", "internal", "confidential", "secret"], +}} + +dominates(clearance, label) if { + dominates_with_lattice(default_lattice, clearance, label) +} + +dominates_with_lattice(lattice, clearance, label) if { + is_string(clearance) + is_string(label) + dominance := object.get(lattice, "dominates", {}) + labels := object.get(dominance, clearance, []) + some dominated in labels + dominated == label +} + +max_sensitivity(labels) := label if { + label := max_sensitivity_with_lattice(default_lattice, labels) +} + +max_sensitivity_with_lattice(lattice, labels) := label if { + count(labels) > 0 + label := labels[_] + every other in labels { + dominates_with_lattice(lattice, label, other) + } +} + +flow_allowed(clearance, labels) if { + flow_allowed_with_lattice(default_lattice, clearance, labels) +} + +flow_allowed_with_lattice(lattice, clearance, labels) if { + is_string(clearance) + is_array(labels) + count(labels) > 0 + sensitivity := max_sensitivity_with_lattice(lattice, labels) + dominates_with_lattice(lattice, clearance, sensitivity) +} + +allow(clearance, labels) := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +allow_with_lattice(lattice, clearance, labels) := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +deny(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} + +deny_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} + +verdict(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed(clearance, labels) +} + +verdict_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow"} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} + +violation(clearance, labels) := { + "decision": "deny", + "reason": "ifc_clearance_violation", + "message": sprintf("IFC clearance violation for sink clearance %v and data labels %v.", [clearance, labels]), +} + +# Propagated labels describe the data flowing OUT of a sink. Under a join +# semilattice the label of the produced data is the least upper bound of the +# incoming source labels, which `max_sensitivity` computes. The host persists +# these with the produced data and re-supplies them as source labels later. +propagated_labels(labels) := [max_sensitivity(labels)] if { + count(labels) > 0 +} else := [] + +propagated_labels_with_lattice(lattice, labels) := [max_sensitivity_with_lattice(lattice, labels)] if { + count(labels) > 0 +} else := [] + +# Like `verdict`, but an allow also returns `result_labels` so the core can hand +# the propagated label back to the host for use on subsequent evaluations. +verdict_propagating(clearance, labels) := verdict if { + not flow_allowed(clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels(labels)} if { + flow_allowed(clearance, labels) +} + +verdict_propagating_with_lattice(lattice, clearance, labels) := verdict if { + not flow_allowed_with_lattice(lattice, clearance, labels) + verdict := violation(clearance, labels) +} else := {"decision": "allow", "result_labels": propagated_labels_with_lattice(lattice, labels)} if { + flow_allowed_with_lattice(lattice, clearance, labels) +} diff --git a/policy/lib/ifc_test.rego b/policy/lib/ifc_test.rego new file mode 100644 index 00000000..ad410aba --- /dev/null +++ b/policy/lib/ifc_test.rego @@ -0,0 +1,73 @@ +package agent_control_specification.lib.ifc_test + +import data.agent_control_specification.lib.ifc +import rego.v1 + +test_clearance_dominates_data_allows if { + ifc.flow_allowed("secret", ["confidential"]) + ifc.max_sensitivity(["confidential"]) == "confidential" + ifc.verdict("secret", ["confidential"]).decision == "allow" +} + +test_data_exceeds_clearance_denies if { + not ifc.flow_allowed("internal", ["confidential"]) + verdict := ifc.deny("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_incomparable_labels_deny_fail_closed if { + lattice := {"dominates": { + "public": ["public"], + "pii": ["public", "pii"], + "pci": ["public", "pci"], + }} + not ifc.flow_allowed_with_lattice(lattice, "pii", ["pci"]) + verdict := ifc.deny_with_lattice(lattice, "pii", ["pci"]) + verdict.reason == "ifc_clearance_violation" +} + +test_missing_and_empty_labels_deny_fail_closed if { + not ifc.flow_allowed("secret", []) + verdict := ifc.deny("secret", []) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" +} + +test_unrecognized_labels_deny_fail_closed if { + not ifc.flow_allowed("secret", ["unknown"]) + verdict := ifc.deny("secret", ["unknown"]) + verdict.decision == "deny" +} + +test_multi_label_inflow_uses_maximum_sensitivity if { + labels := ["public", "confidential", "internal"] + ifc.max_sensitivity(labels) == "confidential" + ifc.flow_allowed("secret", labels) + not ifc.flow_allowed("internal", labels) +} + +test_propagating_allow_returns_joined_label if { + verdict := ifc.verdict_propagating("secret", ["public", "confidential", "internal"]) + verdict.decision == "allow" + verdict.result_labels == ["confidential"] +} + +test_propagating_deny_omits_result_labels if { + verdict := ifc.verdict_propagating("internal", ["confidential"]) + verdict.decision == "deny" + verdict.reason == "ifc_clearance_violation" + not verdict.result_labels +} + +test_propagating_with_lattice_returns_joined_label if { + lattice := {"dominates": { + "public": ["public"], + "internal": ["public", "internal"], + "confidential": ["public", "internal", "confidential"], + "secret": ["public", "internal", "confidential", "secret"], + }} + verdict := ifc.verdict_propagating_with_lattice(lattice, "secret", ["internal", "public"]) + verdict.decision == "allow" + verdict.result_labels == ["internal"] +} diff --git a/policy/lib/patterns.rego b/policy/lib/patterns.rego new file mode 100644 index 00000000..2a515729 --- /dev/null +++ b/policy/lib/patterns.rego @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock pattern helpers. The PII regex set tracks the canonical Python +# source list in agent-os/src/agent_os/integrations/base.py::PII_PATTERNS. +# Helpers expose the first match span and an AGT deny verdict per +# SPECIFICATION.md §13.1 for callers that want a simple block on PII. + +package agt.patterns + +import rego.v1 + +# Per agent_os.integrations.base.PII_PATTERNS. Patterns are anchored with +# word boundaries where the source uses them; the secrets pattern is case +# insensitive via the inline (?i) flag accepted by the Go RE2 engine. + +pii_ssn := `\b\d{3}[\s.\-]?\d{2}[\s.\-]?\d{4}\b` + +pii_email := `\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b` + +pii_phone := `\b(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}\b` + +pii_credit_card := `\b(?:4\d{12}(?:\d{3})?|5[1-5]\d{14})\b` + +pii_secret := `(?i)\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+` + +pii_patterns := [ + pii_ssn, + pii_email, + pii_phone, + pii_credit_card, + pii_secret, +] + +matches_any(text, patterns) if { + is_string(text) + is_array(patterns) + some pattern in patterns + regex.match(pattern, text) +} + +# ``indexof(text, "")`` is undefined in OPA, so a pattern whose leftmost match +# is zero-length (``[0-9]*``, a bare ``*`` glob) would drop out of the scored +# comprehension and leave first_match undefined. matches_any still reports a +# match, so a caller that gates on matches_any and then reads a verdict from +# first_match would fall through to allow. +# +# OPA's regex builtins return the matched text, never its position, so a +# zero-length match has no recoverable offset: ``(?m)$`` matches at the end of +# the subject, not at 0. Report 0 as a deterministic placeholder. The span is +# diagnostic only -- it feeds the deny message and the earliest() tie-break, +# and nothing outside this file reads it -- so the decision is unaffected, but +# a deny whose only matching pattern is zero-width may name offset 0 and, where +# a longer match also exists, may name the zero-width pattern instead. +match_start(_, matched) := 0 if { + matched == "" +} + +match_start(text, matched) := start if { + matched != "" + start := indexof(text, matched) +} + +first_match(text, patterns) := match if { + is_string(text) + is_array(patterns) + scored := [hit | + some idx, pattern in patterns + found := regex.find_n(pattern, text, 1) + count(found) > 0 + span_start := match_start(text, found[0]) + span_start >= 0 + hit := { + "pattern": pattern, + "pattern_index": idx, + "match": found[0], + "span_start": span_start, + "span_end": span_start + count(found[0]), + } + ] + count(scored) > 0 + match := earliest(scored) +} + +earliest(hits) := winner if { + count(hits) > 0 + some i + winner := hits[i] + every other in hits { + not earlier_than(other, winner) + } +} + +earlier_than(a, b) if { + a.span_start < b.span_start +} else if { + a.span_start == b.span_start + a.pattern_index < b.pattern_index +} + +deny_if_pattern(text, patterns, reason) := verdict if { + hit := first_match(text, patterns) + verdict := { + "decision": "deny", + "reason": reason, + "message": sprintf("matched pattern %v at offset %v", [hit.pattern, hit.span_start]), + } +} diff --git a/policy/lib/patterns_test.rego b/policy/lib/patterns_test.rego new file mode 100644 index 00000000..40a75480 --- /dev/null +++ b/policy/lib/patterns_test.rego @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.patterns_test + +import data.agt.patterns +import rego.v1 + +test_ssn_regex_matches if { + patterns.matches_any("My ssn is 123-45-6789 please", [patterns.pii_ssn]) +} + +test_email_regex_matches if { + patterns.matches_any("ping me at alice@example.com tomorrow", [patterns.pii_email]) +} + +test_credit_card_regex_matches if { + patterns.matches_any("card 4111111111111111 expires soon", [patterns.pii_credit_card]) +} + +test_secret_regex_matches_case_insensitive if { + patterns.matches_any("api_key=abc123def", [patterns.pii_secret]) + patterns.matches_any("API_KEY = sk-deadbeef", [patterns.pii_secret]) +} + +test_phone_regex_matches if { + patterns.matches_any("call (415) 555-1212 now", [patterns.pii_phone]) +} + +test_no_match_returns_nothing if { + not patterns.matches_any("nothing sensitive here", patterns.pii_patterns) + not patterns.first_match("nothing sensitive here", patterns.pii_patterns) +} + +test_first_match_returns_match_metadata if { + hit := patterns.first_match("ssn 111-22-3333 in plain text", [patterns.pii_ssn]) + hit.pattern == patterns.pii_ssn + hit.match == "111-22-3333" + hit.span_start == 4 + hit.span_end == 15 +} + +test_first_match_picks_earliest_span if { + hit := patterns.first_match("alice@x.com then 111-22-3333", patterns.pii_patterns) + hit.match == "alice@x.com" + hit.span_start == 0 +} + +test_deny_if_pattern_emits_deny_verdict if { + verdict := patterns.deny_if_pattern("ssn 123-45-6789 detected", patterns.pii_patterns, "pii_detected") + verdict.decision == "deny" + verdict.reason == "pii_detected" +} + +test_deny_if_pattern_no_match_returns_nothing if { + not patterns.deny_if_pattern("benign text", patterns.pii_patterns, "pii_detected") +} + +test_non_string_input_does_not_match if { + not patterns.matches_any(123, patterns.pii_patterns) + not patterns.first_match({"x": 1}, patterns.pii_patterns) +} + +# A zero-length leftmost match must still produce a verdict. matches_any and +# first_match have to agree: a caller that gates on the former and reads a +# verdict from the latter would otherwise fall through to allow. +test_zero_length_leftmost_match_still_denies if { + patterns.matches_any("card 4111", ["[0-9]*"]) + verdict := patterns.deny_if_pattern("card 4111", ["[0-9]*"], "r") + verdict.decision == "deny" +} + +test_empty_text_with_zero_width_pattern_denies if { + patterns.matches_any("", ["x*"]) + patterns.deny_if_pattern("", ["x*"], "r").decision == "deny" +} + +test_match_start_of_empty_match_is_zero if { + patterns.first_match("card 4111", ["[0-9]*"]).span_start == 0 +} + +# A zero-width match reports offset 0 because OPA gives no position for it. +# Pinned so the placeholder is a recorded property rather than an accident. +test_zero_width_match_reports_placeholder_offset if { + hit := patterns.first_match("ab 12", ["(?m)$"]) + hit.match == "" + hit.span_start == 0 +} diff --git a/policy/lib/redact.rego b/policy/lib/redact.rego new file mode 100644 index 00000000..17583335 --- /dev/null +++ b/policy/lib/redact.rego @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock redaction transform. Combines `agt.patterns` regex matching with +# the AGT transform verdict shape (SPECIFICATION.md §14). The +# returned verdict carries `transform.path = "$policy_target"` and a fully +# replaced value, so the dispatcher can apply the substitution without +# additional logic on the host side. The substitution runs in Rego via +# `regex.replace` over a combined alternation, keeping it deterministic +# across SDKs and avoiding recursive Rego rules. + +package agt.redact + +import data.agt.patterns +import rego.v1 + +default_replacement := "[REDACTED]" + +combined_pattern(pats) := pattern if { + is_array(pats) + count(pats) > 0 + parts := [sprintf("(?:%s)", [p]) | some p in pats] + pattern := concat("|", parts) +} + +apply_patterns(text, pats, replacement) := result if { + is_string(text) + pattern := combined_pattern(pats) + result := regex.replace(text, pattern, replacement) +} + +effective_replacement(replacement) := replacement if { + is_string(replacement) +} else := default_replacement + +redact_text(text, pats, replacement) := verdict if { + patterns.matches_any(text, pats) + rep := effective_replacement(replacement) + new_value := apply_patterns(text, pats, rep) + verdict := { + "decision": "transform", + "reason": "redaction_applied", + "transform": { + "path": "$policy_target", + "value": new_value, + }, + } +} diff --git a/policy/lib/redact_test.rego b/policy/lib/redact_test.rego new file mode 100644 index 00000000..d5619b32 --- /dev/null +++ b/policy/lib/redact_test.rego @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +package agt.redact_test + +import data.agt.patterns +import data.agt.redact +import rego.v1 + +test_redact_text_returns_transform_verdict if { + verdict := redact.redact_text("ssn 111-22-3333 in plain text", [patterns.pii_ssn], "[REDACTED]") + verdict.decision == "transform" + verdict.reason == "redaction_applied" + verdict.transform.path == "$policy_target" + verdict.transform.value == "ssn [REDACTED] in plain text" +} + +test_redact_text_handles_multiple_patterns if { + text := "alice@x.com then ssn 111-22-3333 here" + verdict := redact.redact_text(text, patterns.pii_patterns, "[X]") + verdict.decision == "transform" + verdict.transform.value == "[X] then ssn [X] here" +} + +test_redact_text_no_match_returns_nothing if { + not redact.redact_text("benign", patterns.pii_patterns, "[X]") +} + +test_redact_text_default_replacement if { + verdict := redact.redact_text("ssn 111-22-3333", [patterns.pii_ssn], null) + verdict.transform.value == "ssn [REDACTED]" +} + +test_redact_text_path_rooted_at_policy_target if { + verdict := redact.redact_text("email a@b.com here", [patterns.pii_email], "[X]") + startswith(verdict.transform.path, "$policy_target") +} + +test_redact_text_handles_non_string_input if { + not redact.redact_text(123, patterns.pii_patterns, "[X]") +} + +test_combined_pattern_joins_with_alternation if { + pattern := redact.combined_pattern(["a", "b", "c"]) + pattern == "(?:a)|(?:b)|(?:c)" +} diff --git a/policy/lib/run_tests.sh b/policy/lib/run_tests.sh new file mode 100755 index 00000000..076aa215 --- /dev/null +++ b/policy/lib/run_tests.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# AGT stock Rego library test runner. Invokes `opa test` against every +# library file and its sibling _test.rego in this directory. Returns a +# non-zero exit code when any test fails so CI can gate on the result. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +OPA_BIN="${OPA_BIN:-opa}" +if ! command -v "$OPA_BIN" >/dev/null 2>&1; then + if [ -x "$HOME/.local/bin/opa" ]; then + OPA_BIN="$HOME/.local/bin/opa" + else + echo "error: opa executable not found on PATH" >&2 + exit 127 + fi +fi + +exec "$OPA_BIN" test . -v diff --git a/policy/manifest.yaml b/policy/manifest.yaml new file mode 100644 index 00000000..79109e79 --- /dev/null +++ b/policy/manifest.yaml @@ -0,0 +1,41 @@ +agent_control_specification_version: "0.3.1-beta" +metadata: + name: "acs-reference-implementation" +policies: + agt_stock: + type: rego + # Relative to this manifest's own directory (policy/), NOT the process + # cwd — confirmed empirically by tracing the opa subprocess AGT spawns. + # "policy/lib" here would join to ".../policy/policy/lib" and every + # evaluation would hard-fail with runtime_error:policy_invocation_failed + # (opa: no such file or directory) instead of allow/deny. No leading + # "./" either — see C2: OPA's bundle loader mis-derives the data mount + # path from a literal "/./ " segment and silently drops data.json. + bundle: lib + query: data.agt.defaults.verdict +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.command" + policy_target_kind: tool_args + tool_name_from: "$.tool_call.name" + policy: + id: agt_stock +tools: + run_shell: + type: Tool + id: run_shell + security_labels: [shell] + # Task 8 addition: an unregistered tool_call.name fails AGT's own + # evaluation closed with a generic runtime_error:tool_unknown deny -- + # discovered by Task 8's real host -> wire -> policy -> host test, which + # posts a real Claude Code PreToolUse envelope (tool.name "Bash", per + # Task 7's own fixture) through a live Guardian. "run_shell" above is + # AGT's own stock example name and stays for the existing bridge/guardian + # tests that fixture against it; "Bash" is Claude Code's actual tool name + # for shell execution and must be registered too for the real demo (Task + # 9) to evaluate real Claude Code payloads rather than fail closed on an + # unknown tool before the destructive-command pattern check ever runs. + Bash: + type: Tool + id: Bash + security_labels: [shell] diff --git a/scripts/verify-pin.sh b/scripts/verify-pin.sh new file mode 100755 index 00000000..e3dab003 --- /dev/null +++ b/scripts/verify-pin.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Verifies the vendored policy/lib bundle is byte-identical to AGT at the +# pinned ref recorded in agt.lock. Clones the pinned commit into a scratch +# temp dir, points UPSTREAM_BUNDLE at its policy-engine/policy/lib, and +# re-runs the pin test so the byte-identity assertion (self-skipped without +# that env var) actually executes. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +lock_file="agt.lock" +if [ ! -f "$lock_file" ]; then + echo "verify-pin: missing $lock_file" >&2 + exit 1 +fi + +agt_repo="$(jq -r '.agt_repo' "$lock_file")" +agt_ref="$(jq -r '.agt_ref' "$lock_file")" +bundle_path="$(jq -r '.bundle_path' "$lock_file")" + +if [ -z "$agt_repo" ] || [ -z "$agt_ref" ] || [ -z "$bundle_path" ]; then + echo "verify-pin: agt.lock is missing agt_repo/agt_ref/bundle_path" >&2 + exit 1 +fi + +if ! command -v trash >/dev/null 2>&1; then + echo "verify-pin: 'trash' is required for scratch-dir cleanup (rm -rf is not permitted in this repo) — install it and re-run" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/verify-pin.XXXXXX")" +cleanup() { + trash "$tmp_dir" +} +trap cleanup EXIT + +# No identifying information on outbound git traffic: no credential helper, +# no terminal credential prompt, and git's default HTTP User-Agent carries +# no contact field. +export GIT_TERMINAL_PROMPT=0 + +git -c credential.helper= -c credential.useHttpPath=false \ + init --quiet "$tmp_dir" +git -C "$tmp_dir" -c credential.helper= remote add origin "$agt_repo" +git -C "$tmp_dir" -c credential.helper= fetch --quiet --depth 1 origin "$agt_ref" +git -C "$tmp_dir" -c credential.helper= checkout --quiet FETCH_HEAD + +upstream_bundle="$tmp_dir/$bundle_path" +if [ ! -d "$upstream_bundle" ]; then + echo "verify-pin: expected upstream bundle at $upstream_bundle" >&2 + exit 1 +fi + +UPSTREAM_BUNDLE="$upstream_bundle" bun test test/pin.test.ts diff --git a/slices/v1/README.md b/slices/v1/README.md new file mode 100644 index 00000000..e82f94fb --- /dev/null +++ b/slices/v1/README.md @@ -0,0 +1,13 @@ +# V1: One host, one hook, a real AGT decision + +**Demo:** Ask Claude Code for a destructive shell command. AGT's stock policy denies it, and the reason lands in the transcript. + +**Master doc:** [`docs/shaping/acs-reference-impl-slices.md`](../../docs/shaping/acs-reference-impl-slices.md) §V1 — authoritative for this slice's scope. + +**Affordances:** U1, U2, N1-N5, N20, N21, N23, N24, N28, N30, N31, S1, S7-S11, S13 — defined in [Detail C](../../docs/shaping/acs-reference-impl-shaping.md#detail-c-affordances). + +## What this slice delivers + +A single Claude Code `PreToolUse` hook (`hosts/claude-code/acs-hook.ts`) that speaks ACS over the wire to a Guardian process (`packages/guardian`), which evaluates every `steps/toolCallRequest` through AGT's unforked stock policy engine (`packages/agt-bridge`, `policy/lib`) and returns a real decision. The demo above is not staged: `docs/demos/v1-runbook.md` walks through starting the Guardian, wiring the hook into a real `claude` session, and watching a destructive shell command get denied with the policy engine's own reasoning text in the transcript — then contrasts it with a harmless command running normally. + +Only `pre_tool_call` is wired; there is no session state, no envelope tap, and no second host in this slice (see the watch-for and parked-items rows in the slices doc §V1). The implementation plan this slice followed, task by task, is `docs/superpowers/plans/2026-08-09-v1-one-host-one-hook.md`. diff --git a/test/invariants.test.ts b/test/invariants.test.ts new file mode 100644 index 00000000..9a17ff13 --- /dev/null +++ b/test/invariants.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Glob } from "bun"; + +/** + * Strips comments before matching, so these gates assert what the package + * boundaries actually claim -- no AGT or host vocabulary *used in code* + * (imports, types, calls) -- rather than the much weaker and much more + * brittle claim "never mentioned anywhere, including prose". Doc comments + * routinely explain a module's boundary by naming the exact vocabulary it + * must stay clear of (see hosts/claude-code/acs-hook.ts: "must not reach + * into AGT -- it never imports `agt-bridge`..."). A gate that fires on + * that sentence is a gate the next person deletes rather than fixes, + * which is worse than no gate at all. + * + * Line comments are stripped to end-of-line unless the "//" is + * immediately preceded by ":" -- an `https://`-style URL that happens to + * sit inside a comment or string, not a line-comment delimiter. + */ +function stripComments(src: string): string { + const noBlockComments = src.replace(/\/\*[\s\S]*?\*\//g, ""); + return noBlockComments.replace(/(? !f.includes("/test/")) + .map((f) => ({ file: f, code: stripComments(readFileSync(`${dir}/${f}`, "utf8")) })); +} + +/** + * Asserts none of `terms` appears as a whole word (case-insensitive) in + * any non-test `.ts` file's code under `dir`. Whole-word matching, not + * bare substring: "opa" as a substring would false-positive on ordinary + * English words like "opaque"; a gate that fails on that is exactly the + * "annoyingly false" failure mode this suite exists to avoid. Each + * assertion diffs `{file, term, found}` against `{file, term, found: + * false}` on failure, so the failing file and the offending term are + * both in the test output -- no separate message plumbing needed. + */ +function assertNoVocabulary(dir: string, terms: string[]): void { + for (const { file, code } of readSourceFiles(dir)) { + for (const term of terms) { + const found = new RegExp(`\\b${term}\\b`, "i").test(code); + expect({ file, term, found }).toEqual({ file, term, found: false }); + } + } +} + +describe("architectural invariants", () => { + /** + * The claim the whole M×N argument rests on: a host implements ACS once + * (packages/host-adapter) and is governable by any conformant runtime. That + * collapse is only real if the adapter never leaks policy-runtime (AGT) + * vocabulary into its own code. + * + * Scope, deliberately: packages/host-adapter/src only. + * - hosts/claude-code/ is NOT scanned here. A host shim is + * host-specific by definition, and its own doc comment is allowed + * to name AGT in prose (acs-hook.ts's header does, explaining what + * it must NOT import). The invariant that actually matters for that + * file is an import-graph one ("never imports agt-bridge or + * guardian's server-side pieces"), not "never mentions the word" -- + * a different claim this suite doesn't make for host shims. + * - host-adapter/package.json's devDependency on `guardian` is a + * test-only wiring choice: packages/host-adapter/test/*.test.ts spin + * up a real Guardian for end-to-end coverage (see client.test.ts's + * and build-envelope.test.ts's own comments). This gate reads only + * `.ts` source under src/, never package.json and never test/, so + * that dependency is correctly out of scope. + */ + it("host adapter's source contains zero AGT-specific code", () => { + assertNoVocabulary("packages/host-adapter/src", [ + "agt", + "AgentControl", + "rego", + "opa", + "intervention_point", + "verdict", + ]); + }); + + /** + * The other half of that boundary: the adapter is promised to a second + * host *unchanged*, so it must not name the FIRST host's output fields + * either. Declaring Claude Code's wire shape here -- a mandatory + * `permissionDecision`, a returned `{ hookSpecificOutput }` -- would make + * one host's vocabulary the shared module's public API, and a second host + * would have to inherit it or fork the module. + * + * All four names now live in hosts/claude-code/: two as data in + * claude-code.hookmap.yaml's output paths, and the wrapper in acs-hook.ts, + * which is what wraps. Same scope note as the gate above -- non-test `.ts` + * under packages/host-adapter/src only, with comments stripped, so a doc + * comment may still explain the boundary it must not cross in code. + */ + it("the host adapter's source names no host output field", () => { + assertNoVocabulary("packages/host-adapter/src", [ + "permissionDecision", + "permissionDecisionReason", + "updatedInput", + "hookSpecificOutput", + ]); + }); + + /** + * What makes a second host cost zero AGT code: the bridge that knows AGT + * must never learn a specific host's wire shape, or adding a host would + * mean touching this package too. + */ + it("AGT bridge's source contains zero host-specific code", () => { + assertNoVocabulary("packages/agt-bridge/src", [ + "claude", + "opencode", + "hookSpecificOutput", + "permissionDecision", + "stdin", + ]); + }); +}); diff --git a/test/pin.test.ts b/test/pin.test.ts new file mode 100644 index 00000000..787d271b --- /dev/null +++ b/test/pin.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const lock = JSON.parse(readFileSync("agt.lock", "utf8")); + +describe("AGT pin", () => { + it("records a full 40-character commit ref", () => { + expect(lock.agt_ref).toMatch(/^[0-9a-f]{40}$/); + }); + + it("pins the SDK version the bridge installs", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + const dep = + pkg.dependencies?.["agent-control-specification"] ?? + pkg.devDependencies?.["agent-control-specification"]; + expect(dep).toBeDefined(); + expect(dep.replace(/^[^0-9]*/, "")).toBe(lock.sdk_version); + }); + + it("vendors the stock bundle with every stock module present", () => { + const files = readdirSync("policy/lib").filter((f) => f.endsWith(".rego")); + for (const mod of [ + "agt_default.rego", "agt_ifc.rego", "approval.rego", "budgets.rego", + "confidence.rego", "content_hash.rego", "drift.rego", "egress.rego", + "ifc.rego", "patterns.rego", "redact.rego", + ]) { + expect(files).toContain(mod); + } + }); + + it("adds nothing to the bundle except data.json", () => { + const extra = readdirSync("policy/lib").filter( + (f) => !f.endsWith(".rego") && f !== "run_tests.sh" && f !== "data.json", + ); + expect(extra).toEqual([]); + }); + + // UPSTREAM_BUNDLE is set by `bun run verify:pin`, which clones the pinned + // ref and needs network access to GitHub -- unavailable in the fast unit + // run. A bare early `return` here used to make this + // report green while asserting nothing, silently un-guarding the + // project's central "AGT runs unforked" claim. `it.skipIf` instead makes + // bun report this test as SKIPPED, not passed -- unmistakable in output -- + // without failing when the env var is legitimately absent (offline dev), + // and without deleting the assertion `bun run verify:pin` still enforces. + it.skipIf(!process.env.UPSTREAM_BUNDLE)( + "authors no Rego of our own — every .rego is byte-identical to upstream", + () => { + const upstream = process.env.UPSTREAM_BUNDLE; + if (!upstream) { + throw new Error("UPSTREAM_BUNDLE must be set to run this assertion — see `bun run verify:pin`"); + } + for (const f of readdirSync("policy/lib").filter((f) => f.endsWith(".rego"))) { + expect(readFileSync(join("policy/lib", f), "utf8")).toBe( + readFileSync(join(upstream, f), "utf8"), + ); + } + }, + ); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..359050a4 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "allowJs": false, + "noEmit": true, + "declaration": true, + "composite": false, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "types": ["bun-types"] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..f7912d56 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["packages/**/*.ts", "hosts/**/*.ts", "test/**/*.ts"], + "exclude": ["**/node_modules"] +}