diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e4cd84c..43c06194 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -58,3 +58,30 @@ updates: update-types: - "minor" - "patch" + + # Go modules of the Go reference Guardian, so module updates arrive without + # anyone touching the tree. The weekly run of reference-implementation-go.yml + # covers advisories for the Go toolchain, which Dependabot does not move. + - package-ecosystem: "gomod" + directories: + - "/reference-implementations/agt-go" + - "/reference-implementations/agt-go/examples/custom-engine" + schedule: + interval: "weekly" + day: "monday" + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + labels: + - "dependencies" + - "go" + groups: + # The two modules share dependencies, and make check fails while one + # go.mod is ahead of the other, so one pull request moves a dependency + # in both directories. + go-modules: + patterns: + - "*" + group-by: dependency-name diff --git a/.github/workflows/reference-implementation-go.yml b/.github/workflows/reference-implementation-go.yml new file mode 100644 index 00000000..908143ec --- /dev/null +++ b/.github/workflows/reference-implementation-go.yml @@ -0,0 +1,127 @@ +# Builds and tests the Go port of the reference implementation. +# +# Scoped by path, like reference-implementation.yml, and separate from it so a Go +# change does not pay for a Bun install. specification/ is in the path list because +# the module embeds a copy of the schemas and a test fails when the copy stops +# matching them. The AGT bundle and mapping are in it because the Go Guardian reads +# them from reference-implementations/agt/. The shared conformance package is +# included because make check runs it against the Go Guardian. +# +# Native platforms run the complete gate. The pure job also checks cgo is absent. +# +# No workflow expression appears inside a `run:` block. +name: Reference implementation (Go) + +on: + pull_request: + paths: + - "reference-implementations/agt-go/**" + - "reference-implementations/agt/policy/**" + - "reference-implementations/agt/mapping.yaml" + - "reference-implementations/agt/agt.lock" + - "reference-implementations/agt/packages/guardian/src/**" + - "reference-implementations/agt/packages/agt-bridge/src/**" + - "reference-implementations/agt/packages/agt-bridge/package.json" + - "reference-implementations/agt/packages/conformance/**" + - "reference-implementations/agt/package.json" + - "reference-implementations/agt/bun.lock" + - "specification/**" + - "docs/spec/**" + - ".github/workflows/reference-implementation-go.yml" + push: + branches: ["main", "integration"] + paths: + - "reference-implementations/agt-go/**" + - "reference-implementations/agt/policy/**" + - "reference-implementations/agt/mapping.yaml" + - "reference-implementations/agt/agt.lock" + - "reference-implementations/agt/packages/guardian/src/**" + - "reference-implementations/agt/packages/agt-bridge/src/**" + - "reference-implementations/agt/packages/agt-bridge/package.json" + - "reference-implementations/agt/packages/conformance/**" + - "reference-implementations/agt/package.json" + - "reference-implementations/agt/bun.lock" + - "specification/**" + - "docs/spec/**" + - ".github/workflows/reference-implementation-go.yml" + schedule: + # Weekly even when no Go file changed, because govulncheck compares the pinned Go + # toolchain and modules against advisories published after the pin, and Dependabot + # does not move the toolchain. + - cron: "37 6 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: reference-implementation-go-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + agt-go: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15-intel, macos-15] + runs-on: ${{ matrix.os }} + permissions: + contents: read + defaults: + run: + working-directory: reference-implementations/agt-go + shell: bash + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes with git, so the checkout's token has no reason to + # stay behind in .git/config. + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: reference-implementations/agt-go/go.mod + cache-dependency-path: reference-implementations/agt-go/go.sum + + - name: Install Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: "1.3.11" + + - name: Prepare the Go Guardian quality gate + run: make setup + + - name: Run the Go Guardian quality gate + run: make check + + - name: Check the Linux container + if: runner.os == 'Linux' + run: make image PLATFORMS=linux/$(go env GOHOSTARCH) IMAGE_OUTPUT=type=docker && make image-check + + agt-go-pure: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: reference-implementations/agt-go + env: + CGO_ENABLED: "0" + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: reference-implementations/agt-go/go.mod + cache-dependency-path: reference-implementations/agt-go/go.sum + + - name: Build without cgo + run: go build ./... + + - name: Test without cgo + run: go test ./... diff --git a/reference-implementations/agt-go/.codex/config.toml b/reference-implementations/agt-go/.codex/config.toml new file mode 100644 index 00000000..146af7eb --- /dev/null +++ b/reference-implementations/agt-go/.codex/config.toml @@ -0,0 +1,2 @@ +[features] +hooks = true diff --git a/reference-implementations/agt-go/.codex/hooks.json b/reference-implementations/agt-go/.codex/hooks.json new file mode 100644 index 00000000..1a7eca01 --- /dev/null +++ b/reference-implementations/agt-go/.codex/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": ".acs/bin/acs-codex-hook --hmac-key-id 2026-10 --hmac-secret-file .acs/hmac-secret --state-dir .acs/codex --audit-log .acs/codex-audit.jsonl", + "statusMessage": "Checking Bash with ACS" + } + ] + } + ] + } +} diff --git a/reference-implementations/agt-go/.gitignore b/reference-implementations/agt-go/.gitignore new file mode 100644 index 00000000..a236275e --- /dev/null +++ b/reference-implementations/agt-go/.gitignore @@ -0,0 +1,4 @@ +/examples/custom-engine/custom-engine +/cover.out +.acs/ +*.test diff --git a/reference-implementations/agt-go/.opencode/plugins/acs.ts b/reference-implementations/agt-go/.opencode/plugins/acs.ts new file mode 100644 index 00000000..421a10f2 --- /dev/null +++ b/reference-implementations/agt-go/.opencode/plugins/acs.ts @@ -0,0 +1,186 @@ +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" + +type Reply = { + decision: "allow" | "deny" | "modify" | "ask" | "defer" + reasoning?: string + updated_input?: unknown + updated_result?: unknown +} + +type BeforeEvent = { + readonly sessionID: string + readonly messageID: string + readonly id: string + readonly tool: string + input: unknown +} + +type ToolResult = { + content?: string | ReadonlyArray<{ type: string; [key: string]: unknown }> + metadata?: Readonly> + output?: unknown +} + +const isToolResult = (value: unknown): value is ToolResult => { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false + const candidate = value as { content?: unknown; metadata?: unknown } + if (candidate.content !== undefined && typeof candidate.content !== "string") { + if (!Array.isArray(candidate.content) || candidate.content.some((block) => block === null || typeof block !== "object" || typeof (block as { type?: unknown }).type !== "string")) return false + } + return candidate.metadata === undefined || (candidate.metadata !== null && typeof candidate.metadata === "object" && !Array.isArray(candidate.metadata)) +} + +type AfterEvent = { + readonly sessionID: string + readonly messageID: string + readonly id: string + readonly tool: string + readonly input: unknown +} & ( + | { readonly status: "completed"; result: ToolResult } + | { readonly status: "error"; error: { message: string; error?: unknown; metadata?: Record } } +) + +interface PluginContext { + tool: { + hook(name: "execute.before", callback: (event: BeforeEvent) => Promise): Promise + hook(name: "execute.after", callback: (event: AfterEvent) => Promise): Promise + } +} + +const projectRoot = fileURLToPath(new URL("../../", import.meta.url)) +const executable = fileURLToPath(new URL("../../.acs/bin/acs-opencode-hook", import.meta.url)) +const hmacKeyID = process.env.ACS_OPENCODE_HMAC_KEY_ID ?? "" +const hookArguments = [ + "--guardian-url", + process.env.ACS_GUARDIAN_URL || "http://127.0.0.1:8787/acs", + "--hmac-key-id", + hmacKeyID, + "--hmac-secret-file", + process.env.ACS_OPENCODE_HMAC_SECRET_FILE || ".acs/hmac-secret", + "--state-dir", + ".acs/opencode", + "--audit-log", + ".acs/opencode-audit.jsonl", +] + +// A missing key_id blocks every tool call here rather than failing setup: +// OpenCode (checked on 2.0.11) logs a plugin whose setup throws and runs tools without it. +const invoke = (event: unknown): Promise => + new Promise((resolve, reject) => { + if (hmacKeyID === "") { + reject(new Error("ACS_OPENCODE_HMAC_KEY_ID must name the key_id the deployment gave the HMAC secret")) + return + } + const child = spawn(executable, hookArguments, { + cwd: projectRoot, + stdio: ["pipe", "pipe", "pipe"], + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let size = 0 + child.stdout.on("data", (chunk: Buffer) => { + size += chunk.length + if (size > 1 << 20) { + child.kill("SIGKILL") + reject(new Error("ACS policy adapter returned too much data")) + return + } + stdout.push(chunk) + }) + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)) + child.on("error", reject) + child.on("close", (status) => { + if (status !== 0) { + reject(new Error(Buffer.concat(stderr).toString("utf8").trim() || `ACS policy adapter exited ${status}`)) + return + } + try { + resolve(JSON.parse(Buffer.concat(stdout).toString("utf8")) as Reply) + } catch { + reject(new Error("ACS policy adapter returned invalid JSON")) + } + }) + child.stdin.end(JSON.stringify(event)) + }) + +const blocks = (reply: Reply) => reply.decision !== "allow" && reply.decision !== "modify" + +const blockedResult = (reply: Reply) => ({ + content: reply.reasoning || `Guardian returned ${reply.decision}`, + metadata: { acs: { decision: reply.decision } }, +}) + +const blockedError = (reply: Reply) => ({ + message: reply.reasoning || `Guardian returned ${reply.decision}`, + metadata: { acs: { decision: reply.decision } }, +}) + +const modifiedError = (value: unknown): { message: string; error?: unknown; metadata?: Record } | undefined => { + if (value === null || typeof value !== "object") return undefined + const candidate = value as { message?: unknown; error?: unknown; metadata?: unknown } + if (typeof candidate.message !== "string") return undefined + if (candidate.metadata !== undefined && (candidate.metadata === null || typeof candidate.metadata !== "object" || Array.isArray(candidate.metadata))) return undefined + return { + message: candidate.message, + ...(candidate.error === undefined ? {} : { error: candidate.error }), + ...(candidate.metadata === undefined ? {} : { metadata: candidate.metadata as Record }), + } +} + +export const applyAfterReply = (event: AfterEvent, reply: Reply): void => { + if (event.status === "completed") { + if (blocks(reply)) { + event.result = blockedResult(reply) + } else if (reply.decision === "modify") { + event.result = !isToolResult(reply.updated_result) + ? blockedResult({ decision: "deny", reasoning: "The Guardian modification could not be applied" }) + : reply.updated_result + } + return + } + if (blocks(reply)) { + event.error = blockedError(reply) + return + } + if (reply.decision === "modify") { + event.error = modifiedError(reply.updated_result) ?? blockedError({ + decision: "deny", + reasoning: "The Guardian modification could not be applied", + }) + } +} + +export default { + id: "acs", + setup: async (ctx: PluginContext) => { + await ctx.tool.hook("execute.before", async (event) => { + const reply = await invoke({ + event: "execute.before", + session_id: event.sessionID, + message_id: event.messageID, + call_id: event.id, + tool: event.tool, + input: event.input, + }) + if (blocks(reply)) throw new Error(reply.reasoning || `Guardian returned ${reply.decision}`) + if (reply.decision === "modify") event.input = reply.updated_input + }) + + await ctx.tool.hook("execute.after", async (event) => { + const reply = await invoke({ + event: "execute.after", + session_id: event.sessionID, + message_id: event.messageID, + call_id: event.id, + tool: event.tool, + input: event.input, + status: event.status, + result: event.status === "completed" ? event.result : undefined, + error: event.status === "error" ? { message: event.error.message, error: event.error.error, metadata: event.error.metadata } : undefined, + }) + applyAfterReply(event, reply) + }) + }, +} diff --git a/reference-implementations/agt-go/Dockerfile b/reference-implementations/agt-go/Dockerfile new file mode 100644 index 00000000..70a14f5d --- /dev/null +++ b/reference-implementations/agt-go/Dockerfile @@ -0,0 +1,40 @@ +FROM --platform=$BUILDPLATFORM golang:1.27.3-bookworm AS go +FROM --platform=$BUILDPLATFORM rust:1.99.0-bookworm AS build +COPY --from=go /usr/local/go /usr/local/go +ENV PATH="/usr/local/go/bin:${PATH}" +RUN apt-get update && apt-get install -y --no-install-recommends \ + jq gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu libc6-dev-arm64-cross libc6-dev-amd64-cross && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /src/reference-implementations/agt-go +COPY reference-implementations/agt-go/ ./ +COPY reference-implementations/agt/policy/ ../agt/policy/ +COPY reference-implementations/agt/mapping.yaml ../agt/mapping.yaml +ARG TARGETARCH +ARG EVALUATOR=agt +RUN case "${TARGETARCH}" in \ + amd64) compiler=x86_64-linux-gnu-gcc ;; \ + arm64) compiler=aarch64-linux-gnu-gcc ;; \ + *) exit 1 ;; \ + esac && \ + CC=${compiler} make build GOOS=linux GOARCH=${TARGETARCH} EVALUATOR=${EVALUATOR} OUTPUT_DIR=/out && \ + if [ "${EVALUATOR}" = agt ]; then cp .acs/agt/linux-${TARGETARCH}/bin/opa /out/opa; fi && \ + mkdir /data + +FROM gcr.io/distroless/cc-debian13:nonroot +COPY --from=build --chown=65532:65532 /data/ /data/ +WORKDIR /app +COPY --from=build /out/ ./ +COPY reference-implementations/agt-go/guardian.yaml ./guardian.yaml +COPY reference-implementations/agt/policy/ ./deployment/policy/ +COPY reference-implementations/agt/mapping.yaml ./deployment/mapping.yaml +ARG EVALUATOR=agt +ENV ACS__SERVER__HOST=0.0.0.0 \ + ACS__POLICY__EVALUATOR=${EVALUATOR} \ + ACS__POLICY__DEPLOYMENT_DIR=/app/deployment \ + ACS__POLICY__OPA_PATH=/app/opa \ + ACS__SECURITY__HMAC_SECRET_FILE=/run/secrets/acs-hmac \ + ACS__AUDIT__ENVELOPE_LOG=/data/envelopes.jsonl \ + ACS__AUDIT__EVENT_LOG=/data/events.jsonl +USER 65532:65532 +EXPOSE 8787 +ENTRYPOINT ["/app/acs-guardian", "--config", "/app/guardian.yaml"] diff --git a/reference-implementations/agt-go/Dockerfile.dockerignore b/reference-implementations/agt-go/Dockerfile.dockerignore new file mode 100644 index 00000000..541841af --- /dev/null +++ b/reference-implementations/agt-go/Dockerfile.dockerignore @@ -0,0 +1,11 @@ +** +!reference-implementations/ +!reference-implementations/agt-go/ +!reference-implementations/agt-go/** +!reference-implementations/agt/ +!reference-implementations/agt/policy/ +!reference-implementations/agt/policy/** +!reference-implementations/agt/mapping.yaml +reference-implementations/agt-go/.acs/ +reference-implementations/agt-go/.git/ +reference-implementations/agt-go/**/node_modules/ diff --git a/reference-implementations/agt-go/Makefile b/reference-implementations/agt-go/Makefile new file mode 100644 index 00000000..8e888a3a --- /dev/null +++ b/reference-implementations/agt-go/Makefile @@ -0,0 +1,232 @@ +GO ?= go +GOOS ?= $(shell $(GO) env GOHOSTOS) +GOARCH ?= $(shell $(GO) env GOHOSTARCH) +EVALUATOR ?= agt +TARGET := $(GOOS)-$(GOARCH) +AGT_DIR := $(CURDIR)/.acs/agt/$(TARGET) +OUTPUT_DIR ?= .acs/bin/$(TARGET)/$(EVALUATOR) +PLATFORMS ?= linux/amd64,linux/arm64 +IMAGE ?= acs-guardian:local +IMAGE_PLATFORM ?= linux/$(shell $(GO) env GOHOSTARCH) +IMAGE_OUTPUT ?= type=oci,dest=.acs/acs-guardian.oci.tar + +ifneq ($(EVALUATOR),agt) +ifneq ($(EVALUATOR),go) +$(error EVALUATOR must be agt or go) +endif +endif +export GOOS GOARCH +EXE := $(if $(filter windows,$(GOOS)),.exe,) +export ACS_GUARDIAN_BIN := $(abspath $(OUTPUT_DIR))/acs-guardian$(EXE) +export ACS__POLICY__EVALUATOR := $(EVALUATOR) + +ifeq ($(GOOS),windows) +ifeq ($(origin CC),default) +CC := $(if $(filter windows,$(shell $(GO) env GOHOSTOS)),gcc,x86_64-w64-mingw32-gcc) +endif +export CC +RPATH := +else ifeq ($(GOOS),darwin) +RPATH := +else +RPATH := -Wl,-rpath,$(AGT_DIR)/lib -Wl,-rpath,$$ORIGIN +endif +ifeq ($(shell $(GO) env GOHOSTOS),windows) +export PATH := $(shell cygpath -u '$(AGT_DIR)/lib'):$(PATH) +else +export PATH := $(AGT_DIR)/lib:$(PATH) +endif + +AGT_GOFLAGS := $(GOFLAGS) +ifeq ($(GOOS),darwin) +AGT_GOFLAGS += -ldflags=-extldflags=-Wl,-rpath,@loader_path +endif +AGT_CGO := CGO_ENABLED=1 CGO_LDFLAGS='-L$(AGT_DIR)/lib $(RPATH)' GOFLAGS='$(AGT_GOFLAGS)' +BUILD_ENV := $(if $(filter agt,$(EVALUATOR)),$(AGT_CGO),CGO_ENABLED=0) +BUILD_TAGS := $(if $(filter agt,$(EVALUATOR)),-tags agteval,) + +check-start = $(if $(ACS_CHECK_PROGRESS),@printf '[check] RUN: %s\n' '$(1)') +check-pass = $(if $(ACS_CHECK_PROGRESS),@printf '[check] PASS: %s\n' '$(1)') + +.PHONY: all help setup agt-native load build dev-secret run test test-race host-target image image-check example-runtime codex-example opencode-example require-bun require-conformance-deps example-check conformance fmt files-check fmt-check vet lint vulncheck tidy-check coverage docs-check native-flags-check native-flags check check-all + +all: build + +help: + @printf '%s\n' \ + 'setup install dependencies required by the quality gate' \ + 'agt-native build the AGT runtime library and fetch opa, as agt-native.lock pins' \ + 'build compile every package and the Guardian with AGT' \ + 'run build and run the selected evaluator on this host' \ + 'test test the selected evaluator on this host' \ + 'image build Linux images with Docker Buildx, without publishing' \ + 'image-check run the local image through shared conformance probes' \ + 'EVALUATOR=agt|go select the implementation; default: agt' \ + 'GOOS, GOARCH select the build target; default: this host' \ + 'test-race run every test with the Go evaluator and the race detector' \ + 'codex-example start the Guardian and a governed Codex session' \ + 'opencode-example start the Guardian and a governed OpenCode session' \ + 'example-check test the custom engine and host adapters' \ + 'conformance run the shared ACS harness against this Guardian' \ + 'docs-check validate documentation links and named tests' \ + 'load measure the selected evaluator under concurrent clients (docs/load.md)' \ + 'check run the complete local quality gate' + +setup: host-target require-bun agt-native + $(GO) mod download + cd ../agt && bun install --frozen-lockfile + +agt-native: + $(call check-start,AGT runtime) + scripts/build-agt-native.sh + $(call check-pass,AGT runtime) + +build: $(if $(filter agt,$(EVALUATOR)),agt-native,) + $(call check-start,Build ($(EVALUATOR))) + mkdir -p $(OUTPUT_DIR) + $(BUILD_ENV) $(GO) build $(BUILD_TAGS) ./... + $(BUILD_ENV) $(GO) build $(BUILD_TAGS) -o $(ACS_GUARDIAN_BIN) ./cmd/guardian +ifeq ($(EVALUATOR),agt) + cp $(AGT_DIR)/lib/* $(OUTPUT_DIR)/ +endif + $(call check-pass,Build ($(EVALUATOR))) + +dev-secret: + mkdir -p .acs + @test -f .acs/hmac-secret || (umask 077; set -C; openssl rand -base64 32 > .acs/hmac-secret) || test -f .acs/hmac-secret + chmod 600 .acs/hmac-secret + +run: example-runtime + ACS__POLICY__EVALUATOR=$(EVALUATOR) ACS__POLICY__OPA_PATH=$(AGT_DIR)/bin exec $(ACS_GUARDIAN_BIN) --config guardian.yaml + +test: host-target $(if $(filter agt,$(EVALUATOR)),agt-native,) + $(call check-start,Tests ($(EVALUATOR))) + $(BUILD_ENV) $(GO) test -count=1 $(if $(filter agt,$(EVALUATOR)),-race,) $(BUILD_TAGS) ./... + $(call check-pass,Tests ($(EVALUATOR))) + +test-race: host-target + $(call check-start,Go race tests) + CGO_ENABLED=1 $(GO) test -count=1 -race ./... + $(call check-pass,Go race tests) + +host-target: + $(call check-start,Host target) + @test '$(TARGET)' = "$$($(GO) env GOHOSTOS)-$$($(GO) env GOHOSTARCH)" || { printf '%s\n' 'run and test require the host GOOS and GOARCH'; exit 1; } + $(call check-pass,Host target) + +image: + mkdir -p .acs + docker buildx build --platform $(PLATFORMS) --build-arg EVALUATOR=$(EVALUATOR) --tag $(IMAGE) --output $(IMAGE_OUTPUT) --file Dockerfile ../.. + +image-check: host-target require-bun require-conformance-deps + IMAGE=$(IMAGE) IMAGE_PLATFORM=$(IMAGE_PLATFORM) ACS_OPA_PATH='$(AGT_DIR)/bin/opa$(EXE)' scripts/check-image.sh + +example-runtime: host-target dev-secret build + +require-bun: + @command -v bun >/dev/null || { printf '%s\n' 'bun is required on PATH'; exit 1; } + +require-conformance-deps: + @test -d ../agt/node_modules || { printf '%s\n' 'shared conformance dependencies are missing; run make setup'; exit 1; } + +codex-example: example-runtime + @command -v codex >/dev/null || { printf '%s\n' 'codex is required on PATH'; exit 1; } + $(GO) build -o .acs/bin/acs-codex-hook ./examples/codex/cmd/acs-codex-hook + scripts/run-host-example.sh codex --dangerously-bypass-hook-trust -s workspace-write --config sandbox_workspace_write.network_access=true + +opencode-example: example-runtime + @command -v opencode >/dev/null || { printf '%s\n' 'opencode is required on PATH'; exit 1; } + $(GO) build -o .acs/bin/acs-opencode-hook ./examples/opencode/cmd/acs-opencode-hook + ACS_OPENCODE_HMAC_KEY_ID=2026-10 scripts/run-host-example.sh opencode + +example-check: example-runtime require-bun + $(call check-start,Examples) + cd examples/custom-engine && $(GO) test ./... + $(GO) test ./examples/codex/cmd/acs-codex-hook + $(GO) test ./examples/opencode/cmd/acs-opencode-hook + $(GO) build -o .acs/bin/acs-opencode-hook ./examples/opencode/cmd/acs-opencode-hook + ACS_HOST_EXAMPLE_PORT=0 ACS_OPENCODE_HMAC_KEY_ID=2026-10 scripts/run-host-example.sh bun test examples/opencode/plugin.test.mjs + bash -n scripts/run-host-example.sh + $(call check-pass,Examples) + +conformance: example-runtime require-bun require-conformance-deps + $(call check-start,ACS conformance) + ACS_OPA_PATH='$(AGT_DIR)/bin/opa$(EXE)' scripts/check-common-conformance.sh + $(call check-pass,ACS conformance) + +fmt: + $(GO) fmt ./... + +files-check: + $(call check-start,Tracked files) + scripts/check-tracked-files.sh + $(call check-pass,Tracked files) + +fmt-check: + $(call check-start,Formatting) + @unformatted="$$(gofmt -l $$(find . -type f -name '*.go' -not -path './vendor/*' -not -path './.acs/*'))"; \ + test -z "$$unformatted" || { printf '%s\n' "$$unformatted"; exit 1; } + $(call check-pass,Formatting) + +vet: + $(call check-start,Go vet) + $(GO) vet ./... + $(AGT_CGO) $(GO) vet -tags agteval ./... + $(GO) vet -tags load ./agtbridge/ + $(AGT_CGO) $(GO) vet -tags 'agteval load' ./agtbridge/ + cd examples/custom-engine && $(GO) vet ./... + $(call check-pass,Go vet) + +lint: + $(call check-start,Linter) + $(GO) tool staticcheck ./... + $(AGT_CGO) $(GO) tool staticcheck -tags agteval ./... + checker="$$($(GO) tool -n staticcheck)" && cd examples/custom-engine && "$$checker" ./... + $(call check-pass,Linter) + +vulncheck: + $(call check-start,Vulnerability scan) + $(GO) tool govulncheck ./... + $(AGT_CGO) $(GO) tool govulncheck -tags agteval ./... + scanner="$$($(GO) tool -n govulncheck)" && cd examples/custom-engine && "$$scanner" ./... + $(call check-pass,Vulnerability scan) + +tidy-check: + $(call check-start,Module dependencies) + $(GO) mod tidy -diff + cd examples/custom-engine && $(GO) mod tidy -diff + $(call check-pass,Module dependencies) + +coverage: $(if $(filter agt,$(EVALUATOR)),agt-native,) + $(call check-start,Coverage ($(EVALUATOR))) + $(BUILD_ENV) scripts/check-coverage.sh 80 $(BUILD_TAGS) + $(call check-pass,Coverage ($(EVALUATOR))) + +load: $(if $(filter agt,$(EVALUATOR)),agt-native,) + $(BUILD_ENV) $(GO) test -count=1 -tags '$(if $(filter agt,$(EVALUATOR)),agteval )load' -run '^TestLoad$$' -v -timeout 30m ./agtbridge/ + +docs-check: + $(call check-start,Documentation) + $(GO) run ./cmd/docs-check + $(call check-pass,Documentation) + +native-flags-check: + $(call check-start,Native flags (five platforms)) + @set -e; platforms="$$(jq -er '.opa_sha256 | keys[]' agt-native.lock)"; for platform in $$platforms; do \ + $(MAKE) native-flags GOOS=$${platform%-*} GOARCH=$${platform##*-}; \ + done + $(call check-pass,Native flags (five platforms)) + +native-flags: + $(AGT_CGO) $(GO) build -n -tags agteval -o .acs/native-flags-guardian$(EXE) ./cmd/guardian + +check: + @bash scripts/check.sh '$(MAKE)' + +check-all: host-target files-check fmt-check tidy-check native-flags-check vet lint vulncheck build test-race test example-check conformance coverage docs-check + $(MAKE) build EVALUATOR=go + $(MAKE) test EVALUATOR=go +ifneq ($(EVALUATOR),agt) + $(MAKE) build EVALUATOR=agt + $(MAKE) test EVALUATOR=agt +endif diff --git a/reference-implementations/agt-go/README.md b/reference-implementations/agt-go/README.md new file mode 100644 index 00000000..ba6902f6 --- /dev/null +++ b/reference-implementations/agt-go/README.md @@ -0,0 +1,233 @@ +# Go Guardian reference implementation + +This module implements an ACS v0.1.0 Guardian in Go. The default build uses the +Agent Governance Toolkit (AGT) runtime in the Guardian process. +Both reference implementations use the same checked-in [AGT policies](../agt/README.md). +An optional build uses a Go reproduction of the runtime without cgo. + +Both evaluators match 49 sessions recorded from the TypeScript reference (`agtbridge.TestRecordedCases`). +The comparison covers dispositions, reason codes, policy references, reasoning, and modifications. +`agtbridge.TestEvaluatorsAgree` also compares the evaluators at each step. +These tests establish parity for the recorded behavior. +[Conformance](docs/conformance.md) maps each ACS-Core item to its tests. + +## Start here + +### In this README + +| Goal | Section | +| --- | --- | +| Run the standalone Guardian | [Quick start](#quick-start) | +| Use the Guardian as a Go library | [Use it as a Go library](#use-it-as-a-go-library) | +| Run the quality gate | [Verification](#verification) | +| Know what is out of scope | [Scope and limitations](#scope-and-limitations) | + +### Other documents + +| Goal | Document | +| --- | --- | +| Understand how the Guardian works | [Architecture](docs/architecture.md) | +| Review the code | [Architecture: read the code in this order](docs/architecture.md#read-the-code-in-this-order) | +| Configure a deployment | [Configuration](docs/configuration.md) | +| Plug in your own engine, store, signer or audit log | [Extend the Guardian](docs/extending.md) | +| Try a governed Codex or OpenCode session | [Codex example](examples/codex/README.md), [OpenCode example](examples/opencode/README.md) | +| Check the ACS-Core claims | [Conformance](docs/conformance.md) | +| See what is signed and hashed, byte by byte | [Canonical form](docs/canonical-form.md) | +| Compare with the TypeScript reference | [Differences](docs/differences.md), [TypeScript test parity](docs/test-parity.md) | +| See which public findings are covered | [Regression matrix](docs/regression-matrix.md) | +| Select a platform or build a container | [Build and package](docs/building.md) | +| Size `limits.max_engine_calls` for a host | [Load](docs/load.md) | + +## Quick start + +The AGT build supports Linux amd64 and arm64 and both Mac architectures. +It also builds for Windows amd64, but that build is not tested on Windows. +The build requires Go 1.27.3 or newer and a C compiler. +Install Make, OpenSSL, Git, curl, jq, and [rustup](https://rustup.rs). +The quality gate also requires Bun 1.3.11 or newer. + +The AGT evaluator is the default. The first build compiles AGT's runtime library +and downloads `opa` into `.acs/agt/`. [`agt-native.lock`](agt-native.lock) pins +the source commit, Rust toolchain and OPA executable, and the hash of AGT's `ffi.rs` +that `agtbridge/agteval/acs.h` is written from; a test requires the same AGT commit +as [`../agt/agt.lock`](../agt/agt.lock), which pins the policy bundle. +The build refuses tracked changes and untracked files in the AGT source checkout. +Later builds reuse the compiled library. CI defines a complete check for each native platform. +See [Build and package](docs/building.md) for toolchains and verification limits. + +From this directory: + +```bash +make run +``` + +The target creates `.acs/hmac-secret` for local development: 32 random bytes in base64, under the `key_id` `2026-10` that `guardian.yaml` names. A file left by an older checkout holds 64 hex characters, which also decode as base64 but to other bytes; delete it so the target writes a new one. +The target starts the Guardian from [`guardian.yaml`](guardian.yaml), with `policy.evaluator: agt`. +The Guardian listens on `127.0.0.1:8787` and loads the checked-in deployment from `../agt`. +Sessions stay in memory. Raw ACS envelopes are written under `.acs/`. +`make run` stays in the foreground. Press `Ctrl+C` to stop the Guardian and +release the port. + +Readiness returns HTTP 200 with an empty body: + +```bash +curl --fail http://127.0.0.1:8787/readyz +``` + +Run every test with AGT, including the signed allow-and-deny demonstration: + +```bash +make test +``` + +`cmd/guardian.TestServesDecisions` starts the built command. +The checked-in policy allows `ls -la` and denies `echo rm -rf /`. + +Without a C toolchain or Rust, run `make run EVALUATOR=go`. +The target builds without cgo and selects `policy.evaluator: go`. +The Go evaluator runs AGT's policies without the OPA executable. + +### Select the build and evaluator + +| Command | Result | +| --- | --- | +| `make` or `make build` | Build every package with AGT and write the host binary. | +| `make run` | Build with AGT and start the reference configuration. | +| `make build EVALUATOR=go` | Build every package and the Guardian without cgo. | +| `make run EVALUATOR=go` | Build without cgo and start with the Go evaluator. | +| `make test` | Run every test with AGT and the race detector. | +| `make test EVALUATOR=go` | Run every test without cgo. | +| `make build GOOS=windows GOARCH=amd64` | Build a Windows binary with AGT and a compatible native toolchain. | +| `make image` | Build Linux amd64 and arm64 images into a local OCI archive. | + +The build tag `agteval` includes the AGT evaluator. That build needs cgo and +links AGT's runtime library. The Go build omits the tag and uses `CGO_ENABLED=0`. + +At startup, `policy.evaluator` selects `agt` or `go`. The environment variable +`ACS__POLICY__EVALUATOR` overrides that setting. The reference YAML selects `agt`. +A build without AGT refuses `agt`; it never substitutes the Go evaluator. +Selecting `go` in an AGT build still requires the linked AGT library. +Use `make build EVALUATOR=go` to remove that dependency. +`go` reproduces AGT's runtime in Go for builds without cgo. It is not the reference +evaluator: the expectations for reference implementations ([#256](https://github.com/GenAI-Security-Project/agent-control-standard/pull/256)) ask for AGT's own +runtime, which `agt` is. + +## Use it as a Go library + +```go +evaluator, err := agteval.New(agteval.Config{ + ManifestPath: "/opt/acs/policy/policy/manifest.yaml", + OPAPath: "/usr/local/bin/opa", + OPATimeout: 4 * time.Second, // shorter than the 5s default decision timeout +}) +engine, err := agtbridge.New(os.DirFS("/opt/acs/policy"), "mapping.yaml", + evaluator, agtbridge.Options{}) +signer, err := guardian.NewHMACSigner( + guardian.HMACKey{ID: "2026-10", Secret: secret}, // guardian.ReadHMACSecretFile decodes a base64 file +) +g, err := guardian.New(guardian.Config{ + Engine: engine, + Signer: signer, +}) +http.Handle("/acs", g) +``` + +- `agtbridge/agteval` requires cgo and the `agteval` build tag. + Build every evaluator before the Guardian serves. + `agteval.New` temporarily sets OPA configuration in the process environment. + Keep `OPATimeout` shorter than the Guardian's `DecisionTimeout`, with room for the + `opa` process to start: the library does not compare the two, and an agent that + stops waiting first applies its failure posture before AGT's deny arrives. + `goeval.New(ctx, os.DirFS("/opt/acs/policy"), "policy/manifest.yaml")` builds the Go evaluator. + Importing `guardian` or `agtbridge` alone adds no native dependency. +- Under `agteval` each decision runs one `opa` process. Set `guardian.Config.MaxEngineCalls` + for the host: the library default, 256, is sized for in-process engines; + `guardian.yaml` ships 32 ([Load](docs/load.md)). +- `guardian.Config` requires `Engine` and `Signer`. A nil `Store` selects + `guardian.MemorySessionContextStore`, and a nil `AuditLog` records nothing. +- Mount the Guardian as an `http.Handler` at your own route, or call + `Guardian.Handle(ctx, body)` from any transport. +- Put request, tenant, or endpoint context in `ctx`. + The Guardian passes `ctx` to all four interfaces without reading its values. +- To plug in your own engine, store, signer or audit log, see + [extend the Guardian](docs/extending.md). +- [`examples/custom-engine`](examples/custom-engine/) is its own Go module. Until it + receives a version tag, use it from a repository checkout or an explicit commit version. + +## Verification + +After a fresh clone, install the dependencies used by the quality gate: + +```bash +make setup +``` + +`make setup` builds AGT's runtime library and downloads the pinned OPA executable. +The target downloads Go modules and installs the locked TypeScript conformance dependencies. +Codex and OpenCode are not required by the quality gate. + +Then run: + +```bash +make check +``` + +The command prints each check's name at the start and adds `PASS` when the check completes. +CI and redirected output use separate start and result lines. +The final result includes the path to a dated `.log` file in `.acs/checks/`. +If a check fails, the command prints the last 80 log lines and returns a nonzero exit code. +The ACS report in the log also describes standard limitations. Its cross symbols do not indicate failed tests. + +`make check` is the complete quality gate: + +- Check formatting and both modules' dependency files. +- Check Go's cgo flag rules for all five native targets. +- Run `go vet` and Staticcheck with and without the `agteval` tag. +- Run `govulncheck` with and without the `agteval` tag. +- Build every package with AGT and without cgo. +- Run every Go test without cgo. +- Run every Go test with the race detector, with and without AGT. +- Compare both evaluators on the recorded cases. +- Check the custom-engine module and OpenCode plugin callbacks. +- Run the shared ACS conformance checks against a live Guardian using AGT. +- Enforce the coverage floor and check documentation. + +Staticcheck and govulncheck use the dependencies pinned in `go.mod`. +The custom-engine module uses the same tool binaries for its checks. + +`make docs-check` checks local file links and qualified Go test names in every +Markdown file in this module. It also requires a test for each conformance row +that claims implemented behavior. + +The gate exercises signed Codex and OpenCode adapter sessions with simulated +host events. It does not launch either external CLI or a model; the +[Codex](examples/codex/README.md) and [OpenCode](examples/opencode/README.md) +examples describe the interactive runs. + +Run `make help` for the public targets. `scripts/record-ts-cases.sh` refreshes +the TypeScript recordings and requires Bun. + +## Scope and limitations + +- The Guardian implements all five ACS dispositions; the checked-in AGT policies emit + only ALLOW, DENY and MODIFY ([dispositions](docs/conformance.md#dispositions)). +- The default store is bounded but in memory. A restart loses its sessions. + For durable or multi-replica use, write your own `SessionContextStore` + ([extend the Guardian](docs/extending.md)). +- JSONL audit files contain raw ACS envelopes, including tool arguments. Protect + them as sensitive data. +- The checked-in AGT policies are a protocol demonstration, not a complete egress + control. Their measured limits are listed in + [differences](docs/differences.md#measured-policy-limits). +- Every request except `system/ping` must be signed. The signature row in + [conformance](docs/conformance.md) says which answers are signed. +- The module includes no Inspector or Claude Code example. + +## License + +Apache License 2.0; see [`../../LICENSING.md`](../../LICENSING.md). The AGT +policy files retain their MIT license. +The build compiles AGT's runtime library from MIT-licensed source. +This repository does not include a compiled AGT library. +The RFC 8785 vectors in `internal/jcs/testdata/` use Apache License 2.0. +The vectors come from the RFC author's reference implementation. diff --git a/reference-implementations/agt-go/acs/decision.go b/reference-implementations/agt-go/acs/decision.go new file mode 100644 index 00000000..2e4a1770 --- /dev/null +++ b/reference-implementations/agt-go/acs/decision.go @@ -0,0 +1,151 @@ +package acs + +import "encoding/json" + +// Disposition is one of the five decisions of §6. +type Disposition string + +// The five dispositions, lowercase as they appear on the wire. +const ( + Allow Disposition = "allow" + Deny Disposition = "deny" + Modify Disposition = "modify" + Ask Disposition = "ask" + Defer Disposition = "defer" +) + +// Decision is a disposition with the fields of §6.1 that explain and +// qualify it. Reasoning is required for every disposition except Allow; +// Modifications, AskDetails and DeferDetails are required for Modify, Ask +// and Defer respectively. +type Decision struct { + Disposition Disposition `json:"decision"` + Reasoning string `json:"reasoning,omitzero"` + ReasonCodes []string `json:"reason_codes,omitzero"` + // PolicyReferences is nil when no rule is cited and empty when the + // decision states that no rule fired. + PolicyReferences []PolicyReference `json:"policy_references,omitzero"` + PolicyData json.RawMessage `json:"policy_data,omitzero"` + CitedProvenanceIDs []string `json:"cited_provenance_ids,omitzero"` + Modifications *Modifications `json:"modifications,omitzero"` + AskDetails *AskDetails `json:"ask_details,omitzero"` + DeferDetails *DeferDetails `json:"defer_details,omitzero"` + Metadata *EvaluationMetadata `json:"metadata,omitzero"` +} + +// PolicyReference names a policy or rule that contributed to a decision. +type PolicyReference struct { + PolicyID string `json:"policy_id,omitzero"` + PolicyVersion string `json:"policy_version,omitzero"` + PolicyName string `json:"policy_name,omitzero"` + RuleID string `json:"rule_id,omitzero"` +} + +// EvaluationMetadata describes how a decision was reached (§6.1). +type EvaluationMetadata struct { + Evaluator string `json:"evaluator,omitzero"` + EvaluatorVersion string `json:"evaluator_version,omitzero"` + EvaluationDurationMS *int64 `json:"evaluation_duration_ms,omitzero"` + ModelID string `json:"model_id,omitzero"` + Confidence *float64 `json:"confidence,omitzero"` +} + +// Modifications is the payload of a Modify decision (modifications.json): +// either ModifiedContent alone, or Redactions and ParameterOverrides on +// disjoint targets (§6.3). +type Modifications struct { + ModifiedContent *string `json:"modified_content,omitzero"` + Redactions []Redaction `json:"redactions,omitzero"` + ParameterOverrides map[string]json.RawMessage `json:"parameter_overrides,omitzero"` +} + +// Redaction replaces the field a JSON pointer addresses. +type Redaction struct { + Path string `json:"path"` + Replacement *string `json:"replacement,omitzero"` +} + +// ApproverType is the kind of approver an Ask decision routes to (§9). +type ApproverType string + +// The approver types of §9. +const ( + ApproverHuman ApproverType = "human" + ApproverAgent ApproverType = "agent" + ApproverService ApproverType = "service" +) + +// AskDetails is the payload of an Ask decision (ask-details.json). +type AskDetails struct { + Approver Approver `json:"approver"` + Question string `json:"question"` + Context *string `json:"context,omitzero"` + Options []string `json:"options,omitzero"` + TimeoutSeconds int64 `json:"timeout_seconds"` + // TimeoutDisposition is allow or deny; absent means deny. + TimeoutDisposition Disposition `json:"timeout_disposition,omitzero"` + IntentExtension *IntentExtension `json:"intent_extension,omitzero"` +} + +// Approver identifies who resolves an Ask decision. +type Approver struct { + Type ApproverType `json:"type"` + ID string `json:"id"` + Endpoint *string `json:"endpoint,omitzero"` + Auth *ApproverAuth `json:"auth,omitzero"` +} + +// ApproverAuth describes how a non-human approver authenticates. +type ApproverAuth struct { + Method *string `json:"method,omitzero"` +} + +// IntentExtensionScope says how long an approved intent extension lasts +// (§9.1). +type IntentExtensionScope string + +// The two intent extension scopes of §9.1. +const ( + ScopeThisRequest IntentExtensionScope = "this_request" + ScopeSession IntentExtensionScope = "session" +) + +// IntentExtension carries the capabilities an approver's grant adds to +// Intent.parsed (§9.1). +type IntentExtension struct { + Capabilities []Capability `json:"capabilities"` + Scope IntentExtensionScope `json:"scope"` + Provenance *Provenance `json:"provenance,omitzero"` +} + +// DeferReason is why a Defer decision could not reach a verdict. +type DeferReason string + +// The Defer reasons of §6. +const ( + DeferInsufficientContext DeferReason = "insufficient_context" + DeferConflictingPolicies DeferReason = "conflicting_policies" + DeferLowConfidence DeferReason = "low_confidence" + DeferPendingDependency DeferReason = "pending_dependency" +) + +// ResolutionMethod is how a Defer decision is expected to resolve. +type ResolutionMethod string + +// The resolution methods of defer-details.json. +const ( + ResolveAdditionalContext ResolutionMethod = "additional_context" + ResolveHumanApproval ResolutionMethod = "human_approval" + ResolveTimeout ResolutionMethod = "timeout" +) + +// DeferDetails is the payload of a Defer decision (defer-details.json). +type DeferDetails struct { + Reason DeferReason `json:"reason"` + ResolutionMethod ResolutionMethod `json:"resolution_method"` + ResolutionTimeoutMS int64 `json:"resolution_timeout_ms"` + // TimeoutDecision is deny or ask. §6 requires it on every Defer, so the + // Guardian fills the default, deny, when an engine leaves it empty. + TimeoutDecision Disposition `json:"timeout_decision"` + RequiredContext []string `json:"required_context,omitzero"` +} diff --git a/reference-implementations/agt-go/acs/doc.go b/reference-implementations/agt-go/acs/doc.go new file mode 100644 index 00000000..ffc181a1 --- /dev/null +++ b/reference-implementations/agt-go/acs/doc.go @@ -0,0 +1,13 @@ +// Package acs declares the wire shapes of the Agent Control Standard v0.1.0: +// the request and response envelopes, the handshake, the decision and its +// disposition-specific details, the SessionContext objects, the hook payloads +// the Guardian reads, the method names and the error codes. +// +// Types follow the v0.1.0 schemas and use their field names. Where another +// normative requirement needs a member that a schema omits, the documented +// extension is called out on the field and in docs/conformance.md. Optional +// fields follow one rule: an optional scalar is a pointer or a string whose +// empty value carries no meaning, a slice or map is nil when absent and +// non-nil when present, and raw JSON is nil when absent. This package defines +// wire shapes; schema validation is performed by guardian. +package acs diff --git a/reference-implementations/agt-go/acs/envelope.go b/reference-implementations/agt-go/acs/envelope.go new file mode 100644 index 00000000..365a7104 --- /dev/null +++ b/reference-implementations/agt-go/acs/envelope.go @@ -0,0 +1,105 @@ +package acs + +import "encoding/json" + +// Version is the ACS specification version this module implements. +const Version = "0.1.0" + +// JSONRPCVersion is the value of the jsonrpc member of every envelope. +const JSONRPCVersion = "2.0" + +// Request is the request envelope an Observed Agent sends +// (request-envelope.json). +type Request struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + // ID is the JSON-RPC correlation identifier, a string or a number, kept + // as its JSON text so the response echoes it byte for byte. + ID json.RawMessage `json:"id"` + Params Params `json:"params"` +} + +// Params is the ACS part of a request envelope (request-envelope.json, +// AcsParams). +type Params struct { + ACSVersion string `json:"acs_version"` + RequestID string `json:"request_id"` + Timestamp string `json:"timestamp"` + Nonce *string `json:"nonce,omitzero"` + TenantID *string `json:"tenant_id,omitzero"` + Metadata Metadata `json:"metadata"` + // Payload is the hook-specific payload, whose schema depends on the + // method. + Payload json.RawMessage `json:"payload"` + Signature *Signature `json:"signature,omitzero"` +} + +// Metadata identifies the agent and the session a request belongs to. +type Metadata struct { + AgentID string `json:"agent_id"` + AgentName *string `json:"agent_name,omitzero"` + SessionID string `json:"session_id"` + TurnID *string `json:"turn_id,omitzero"` + ParentTurnID *string `json:"parent_turn_id,omitzero"` + SessionState *SessionState `json:"session_state,omitzero"` + Environment *string `json:"environment,omitzero"` + Platform *string `json:"platform,omitzero"` + PlatformVersion *string `json:"platform_version,omitzero"` + UserContext *UserContext `json:"user_context,omitzero"` +} + +// SessionState carries the Observed Agent's view of the session, which the +// Guardian cross-checks against its own. +type SessionState struct { + // ChainHash is the latest chain head the Observed Agent knows of. + ChainHash *string `json:"chain_hash,omitzero"` +} + +// UserContext describes the user on whose behalf the agent acts. +type UserContext struct { + UserID *string `json:"user_id,omitzero"` + Roles []string `json:"roles,omitzero"` + AuthenticationMethod *string `json:"authentication_method,omitzero"` +} + +// Signature is the signature envelope of §10: an algorithm from the +// registry, the base64 signature value and the identifier of the key. +type Signature struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` + KeyID string `json:"key_id"` +} + +// SignatureAlgorithmHMACSHA256 is the ACS-Core baseline signature algorithm. +const SignatureAlgorithmHMACSHA256 = "HMAC-SHA256" + +// Response is the response envelope a Guardian sends +// (response-envelope.json). Exactly one of Result and Error is set. +type Response struct { + JSONRPC string `json:"jsonrpc"` + // ID echoes the request's identifier, or is the JSON null when the + // identifier could not be determined. + ID json.RawMessage `json:"id"` + // Result is a Result for every method except handshake/hello, whose + // result is a ServerHello. + Result json.RawMessage `json:"result,omitzero"` + Error *Error `json:"error,omitzero"` +} + +// ResultTypeFinal is the only Result type v0.1 emits. +const ResultTypeFinal = "final" + +// Result is the result of every method other than handshake/hello +// (response-envelope.json, AcsResult). +type Result struct { + Type string `json:"type"` + ACSVersion string `json:"acs_version"` + RequestID string `json:"request_id"` + Decision + // Payload is method-specific response data, used by system/ping. + Payload json.RawMessage `json:"payload,omitzero"` + // ChainHash is the SessionContext head after this step's ContextEntry + // was appended (§8.6). + ChainHash string `json:"chain_hash,omitzero"` + Signature *Signature `json:"signature,omitzero"` +} diff --git a/reference-implementations/agt-go/acs/errors.go b/reference-implementations/agt-go/acs/errors.go new file mode 100644 index 00000000..abcb85c4 --- /dev/null +++ b/reference-implementations/agt-go/acs/errors.go @@ -0,0 +1,50 @@ +package acs + +// ErrorCode is a JSON-RPC error code: the JSON-RPC 2.0 codes and the ACS +// registry of §17.1. +type ErrorCode int + +// The JSON-RPC 2.0 error codes ACS uses (§17). +const ( + ParseError ErrorCode = -32700 + InvalidRequest ErrorCode = -32600 + MethodNotFound ErrorCode = -32601 + InvalidParams ErrorCode = -32602 + InternalError ErrorCode = -32603 +) + +// The ACS error code registry (§17.1). +const ( + SessionRefused ErrorCode = -32000 + UnsupportedVersion ErrorCode = -32001 + ProvenanceRequired ErrorCode = -32002 + CapabilityNotNegotiated ErrorCode = -32003 + SignatureInvalid ErrorCode = -32004 + ReplayDetected ErrorCode = -32005 + TimestampOutOfWindow ErrorCode = -32006 + ChainMismatch ErrorCode = -32007 +) + +// Error is the error member of a response envelope. +type Error struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Data *ErrorData `json:"data,omitzero"` + // Signature signs an error that answers an authenticated request. + // response-envelope.json names no member for it; the error object + // admits one, and the member takes AcsResult's name and shape. + Signature *Signature `json:"signature,omitzero"` +} + +// ErrorData is the data object §17.1 describes: a machine-readable reason, +// a human-readable message, and the fields specific to some codes. +type ErrorData struct { + Reason string `json:"reason"` + Message string `json:"message"` + // SupportedVersions accompanies UnsupportedVersion. + SupportedVersions []string `json:"supported_versions,omitzero"` + // Method accompanies CapabilityNotNegotiated. + Method string `json:"method,omitzero"` + // SkewWindowMS accompanies TimestampOutOfWindow. + SkewWindowMS *int64 `json:"skew_window_ms,omitzero"` +} diff --git a/reference-implementations/agt-go/acs/handshake.go b/reference-implementations/agt-go/acs/handshake.go new file mode 100644 index 00000000..dc6c9ed5 --- /dev/null +++ b/reference-implementations/agt-go/acs/handshake.go @@ -0,0 +1,88 @@ +package acs + +// Transports of handshake.json. +const ( + TransportHTTP = "http" + TransportHTTPS = "https" + TransportStdio = "stdio" +) + +// ProvenanceProducer values of the ClientHello (§7). +const ( + ProvenanceDeterministic = "deterministic" + ProvenanceNone = "none" +) + +// The conformance profile names of docs/spec/conformance.md. +const ( + ProfileCore = "acs-core" + ProfileTrace = "acs-trace" + ProfileInspect = "acs-inspect" + ProfileInspectDynamic = "acs-inspect-dynamic" + ProfileProvenance = "acs-provenance" + ProfileCrypto = "acs-crypto" + ProfileAudit = "acs-audit" +) + +// FailurePosture is the on_decision_failure posture of §6.4. +type FailurePosture string + +// The two failure postures of §6.4. +const ( + FailureProceed FailurePosture = "proceed" + FailureDeny FailurePosture = "deny" +) + +// ClientHello is the Observed Agent's half of the handshake +// (handshake.json, ClientHello). +type ClientHello struct { + ACSVersionsSupported []string `json:"acs_versions_supported"` + MethodsImplemented []string `json:"methods_implemented"` + TransportsSupported []string `json:"transports_supported"` + MaxPayloadSizeBytes *int64 `json:"max_payload_size_bytes,omitzero"` + ProvenanceProducer string `json:"provenance_producer"` + WrappedProtocols []WrappedProtocol `json:"wrapped_protocols,omitzero"` + ProfilesSupported []string `json:"profiles_supported,omitzero"` +} + +// WrappedProtocol is a sub-protocol an Observed Agent tunnels over ACS, +// pinned to a version. +type WrappedProtocol struct { + Protocol string `json:"protocol"` + Version string `json:"version"` +} + +// ServerHello is the Guardian's half of the handshake (handshake.json, +// ServerHello). +type ServerHello struct { + NegotiatedVersion string `json:"negotiated_version"` + MethodsEvaluated []string `json:"methods_evaluated"` + SelectedTransport string `json:"selected_transport"` + SignatureAlgorithmsSupported []string `json:"signature_algorithms_supported"` + TimeoutConfig TimeoutConfig `json:"timeout_config"` + SkewWindowMS int64 `json:"skew_window_ms"` + OnDecisionFailure FailurePosture `json:"on_decision_failure"` + ApproverTypesSupported []ApproverType `json:"approver_types_supported,omitzero"` + PolicyRequiresProvenance bool `json:"policy_requires_provenance"` + AgBOMSerializationsSupported []string `json:"agbom_serializations_supported,omitzero"` + TraceEmission *TraceEmission `json:"trace_emission,omitzero"` + ProfilesAccepted []string `json:"profiles_accepted"` + // Signature signs the handshake response. §10 and ACS-Core require a + // signature on every response, and handshake.json gives the ServerHello + // no member for it, so it sits where Result carries its own. + Signature *Signature `json:"signature,omitzero"` +} + +// TraceEmission declares which standard trace formats a deployment emits. +type TraceEmission struct { + OTelEnabled bool `json:"otel_enabled,omitzero"` + OCSFEnabled bool `json:"ocsf_enabled,omitzero"` + OTelCollectorEndpoint *string `json:"otel_collector_endpoint,omitzero"` +} + +// TimeoutConfig is the negotiated decision timeout, by default and per +// method. +type TimeoutConfig struct { + DefaultMS int64 `json:"default_ms"` + PerMethodMS map[string]int64 `json:"per_method_ms,omitzero"` +} diff --git a/reference-implementations/agt-go/acs/hooks.go b/reference-implementations/agt-go/acs/hooks.go new file mode 100644 index 00000000..c3e0c6d0 --- /dev/null +++ b/reference-implementations/agt-go/acs/hooks.go @@ -0,0 +1,138 @@ +package acs + +import "encoding/json" + +// SessionStartPayload is the payload of steps/sessionStart +// (hooks/session-start.json). +type SessionStartPayload struct { + UserIdentity json.RawMessage `json:"user_identity,omitzero"` + PolicyMode *string `json:"policy_mode,omitzero"` + Intent *Intent `json:"intent,omitzero"` + PlatformContext json.RawMessage `json:"platform_context,omitzero"` +} + +// AgentTriggerPayload is the payload of steps/agentTrigger +// (hooks/agent-trigger.json). +type AgentTriggerPayload struct { + TriggerType string `json:"trigger_type"` + TriggerSource json.RawMessage `json:"trigger_source,omitzero"` + Intent *Intent `json:"intent,omitzero"` +} + +// SessionEndPayload is the payload of steps/sessionEnd +// (hooks/session-end.json). +type SessionEndPayload struct { + Reason string `json:"reason"` + Summary json.RawMessage `json:"summary,omitzero"` + FinalChainHash *string `json:"final_chain_hash,omitzero"` +} + +// SystemPingPayload is the payload of system/ping (hooks/system-ping.json). +type SystemPingPayload struct { + Echo *string `json:"echo,omitzero"` +} + +// PingStatusOK is the status system/ping reports (§13). +const PingStatusOK = "ok" + +// SystemPingResponse is the payload of a system/ping result (§13). +type SystemPingResponse struct { + Status string `json:"status"` + Echo *string `json:"echo,omitzero"` + ServerTimestamp string `json:"server_timestamp"` +} + +// Tool identifies the tool a tool hook concerns. +type Tool struct { + Name string `json:"name"` + Version *string `json:"version,omitzero"` + Provider *string `json:"provider,omitzero"` +} + +// ToolArgument is one argument of a tool call and its optional provenance. +type ToolArgument struct { + Value json.RawMessage `json:"value"` + Provenance *Provenance `json:"provenance,omitzero"` +} + +// ToolCallIntent is the natural-language framing of a tool call. +type ToolCallIntent struct { + Description *string `json:"description,omitzero"` + Goal *string `json:"goal,omitzero"` +} + +// ToolCallRequestPayload is the payload of steps/toolCallRequest +// (hooks/tool-call-request.json). +type ToolCallRequestPayload struct { + Tool Tool `json:"tool"` + Operation *string `json:"operation,omitzero"` + Capability *string `json:"capability,omitzero"` + Arguments map[string]ToolArgument `json:"arguments"` + RawCommand *string `json:"raw_command,omitzero"` + Intent *ToolCallIntent `json:"intent,omitzero"` +} + +// ToolOutput is one output of a tool call and its optional provenance. +type ToolOutput struct { + Value json.RawMessage `json:"value"` + Provenance *Provenance `json:"provenance,omitzero"` +} + +// ToolCallResultPayload is the payload of steps/toolCallResult +// (hooks/tool-call-result.json). +type ToolCallResultPayload struct { + Tool Tool `json:"tool"` + Operation *string `json:"operation,omitzero"` + RequestIDRef *string `json:"request_id_ref,omitzero"` + ExitStatus string `json:"exit_status"` + Outputs []ToolOutput `json:"outputs"` + DurationMS *int64 `json:"duration_ms,omitzero"` +} + +// Digest is an integrity digest over a skill's complete loadable artifact. +type Digest struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` +} + +// SkillDefinition is the loadable artifact of a registered skill; the +// Guardian reads only its digest. +type SkillDefinition struct { + Digest Digest `json:"digest"` +} + +// SkillRegisterPayload is steps/skillRegister's payload +// (hooks/skill-register.json), its optional descriptors left to the engine. +type SkillRegisterPayload struct { + SkillID string `json:"skill_id"` + Definition SkillDefinition `json:"definition"` + DeclaredCapabilities json.RawMessage `json:"declared_capabilities"` +} + +// SkillLoadPayload is steps/skillLoad's payload (hooks/skill-load.json), +// its optional members left to the engine. +type SkillLoadPayload struct { + SkillID string `json:"skill_id"` + Digest Digest `json:"digest"` + LoadTrigger string `json:"load_trigger"` + LoadPath json.RawMessage `json:"load_path"` +} + +// CompactSummary is a postCompact summary and its lineage. +type CompactSummary struct { + Value string `json:"value"` + Provenance Provenance `json:"provenance"` +} + +// PostCompactPayload is steps/postCompact's payload +// (hooks/post-compact.json). +type PostCompactPayload struct { + Summary CompactSummary `json:"summary"` + EntriesCompacted []string `json:"entries_compacted"` + PreCompactChainHash string `json:"pre_compact_chain_hash"` + PostCompactChainHash string `json:"post_compact_chain_hash"` + LineageDepthAfter *int64 `json:"lineage_depth_after,omitzero"` +} + +// OriginAgentGenerated is the origin a postCompact summary must carry. +const OriginAgentGenerated = "agent_generated" diff --git a/reference-implementations/agt-go/acs/methods.go b/reference-implementations/agt-go/acs/methods.go new file mode 100644 index 00000000..98bac9c2 --- /dev/null +++ b/reference-implementations/agt-go/acs/methods.go @@ -0,0 +1,42 @@ +package acs + +// Methods outside the steps/* namespace. +const ( + MethodHandshakeHello = "handshake/hello" + MethodSystemPing = "system/ping" + MethodAgBOMSnapshot = "agbom/snapshot" + MethodAgBOMChanged = "agbom/changed" +) + +// The native steps/* hooks of §5. +const ( + StepSessionStart = "steps/sessionStart" + StepAgentTrigger = "steps/agentTrigger" + StepTurnStart = "steps/turnStart" + StepUserMessage = "steps/userMessage" + StepAgentResponse = "steps/agentResponse" + StepKnowledgeRetrieval = "steps/knowledgeRetrieval" + StepMemoryContextRetrieval = "steps/memoryContextRetrieval" + StepMemoryStore = "steps/memoryStore" + StepToolCallRequest = "steps/toolCallRequest" + StepToolCallResult = "steps/toolCallResult" + StepPreCompact = "steps/preCompact" + StepPostCompact = "steps/postCompact" + StepSubagentStart = "steps/subagentStart" + StepSubagentStop = "steps/subagentStop" + StepSkillRegister = "steps/skillRegister" + StepSkillLoad = "steps/skillLoad" + StepSkillUnload = "steps/skillUnload" + StepTurnEnd = "steps/turnEnd" + StepSessionEnd = "steps/sessionEnd" +) + +// Method namespace prefixes of §3. +const ( + StepsPrefix = "steps/" + ProtocolsPrefix = "protocols/" +) + +// StepTypeIntentExtension is the step_type of the ContextEntry that records +// an approved intent extension (§9.1). +const StepTypeIntentExtension = "intent_extension" diff --git a/reference-implementations/agt-go/acs/session_context.go b/reference-implementations/agt-go/acs/session_context.go new file mode 100644 index 00000000..8613a63f --- /dev/null +++ b/reference-implementations/agt-go/acs/session_context.go @@ -0,0 +1,64 @@ +package acs + +// ContextEntry is one entry of a SessionContext's append-only chain +// (context-entry.json, §8.1). +type ContextEntry struct { + EntryID string `json:"entry_id"` + StepID string `json:"step_id"` + StepType string `json:"step_type"` + // RequestHash is the lowercase-hex SHA-256 of the RFC 8785 + // canonicalization of the originating request's params. + RequestHash string `json:"request_hash,omitzero"` + ProvenanceSummary *ProvenanceSummary `json:"provenance_summary,omitzero"` + Timestamp string `json:"timestamp,omitzero"` + // TurnID is the envelope's metadata.turn_id, which turnStart requires + // on every per-step entry of the turn. context-entry.json names no + // member for it; the member takes the envelope field's name. + TurnID string `json:"turn_id,omitzero"` + // Approver and IntentExtension record an intent_extension entry's + // grant (§9.1), which context-entry.json names no member for; the + // members take ask-details.json's names. + Approver *Approver `json:"approver,omitzero"` + IntentExtension *IntentExtension `json:"intent_extension,omitzero"` + // PreviousHash is null for the first entry of a session. + PreviousHash *string `json:"previous_hash"` + EntryHash string `json:"entry_hash"` +} + +// ProvenanceSummary condenses provenance facts for an entry or a session +// (provenance-summary.json, §8.3). +type ProvenanceSummary struct { + OriginsSeen []string `json:"origins_seen,omitzero"` + EntryCount *int64 `json:"entry_count,omitzero"` + EntryCountByOrigin map[string]int64 `json:"entry_count_by_origin,omitzero"` + EarliestStepIDByOrigin map[string]string `json:"earliest_step_id_by_origin,omitzero"` + MaxLineageDepth *int64 `json:"max_lineage_depth,omitzero"` +} + +// Provenance is the lineage label on a data-bearing field (provenance.json, +// §7). +type Provenance struct { + ProvenanceID string `json:"provenance_id"` + Origin string `json:"origin"` + SourceID *string `json:"source_id,omitzero"` + DerivedFrom []string `json:"derived_from,omitzero"` +} + +// Intent is the session's declared purpose and capability set (§8.4). +type Intent struct { + Raw *string `json:"raw,omitzero"` + Parsed []Capability `json:"parsed,omitzero"` + ParserProvenance *Provenance `json:"parser_provenance,omitzero"` + ScopeMode *string `json:"scope_mode,omitzero"` +} + +// ScopeModeStrict is the Intent scope mode under which a Guardian refuses +// intent extensions the deployment policy forbids (§9.1). +const ScopeModeStrict = "strict" + +// Capability is one entry of Intent.parsed. +type Capability struct { + Tool string `json:"tool,omitzero"` + Operation string `json:"operation,omitzero"` + Resource string `json:"resource,omitzero"` +} diff --git a/reference-implementations/agt-go/agt-native.lock b/reference-implementations/agt-go/agt-native.lock new file mode 100644 index 00000000..3faedeb2 --- /dev/null +++ b/reference-implementations/agt-go/agt-native.lock @@ -0,0 +1,18 @@ +{ + "agt_repo": "https://github.com/microsoft/agent-governance-toolkit", + "agt_ref": "81955d48025c6b11deb3fc9dabf89f74f4145775", + "workspace_path": "policy-engine", + "crate": "agent_control_specification_core", + "crate_version": "0.3.1-beta.0", + "cargo_features": ["default-dispatchers"], + "rust_toolchain": "1.99.0", + "ffi_rs_sha256": "5aa7e1b0061d501805f6c54eeef1805f6a35ea4fb978edd4889d721937c8d8b2", + "opa_version": "0.70.0", + "opa_sha256": { + "linux-amd64": "00d114b94fdb1606a48cccdfc73c9ccdc62c38721150131ae578d5ff3df5c084", + "linux-arm64": "48061407a2d7b0b59440fc3a257e7bb251e9ec62f6ce7b1e45c142263ae24413", + "darwin-amd64": "7827172827c6c7763fd36dd72052c318a6beb18f7b907c5e67d847bb557af1a1", + "darwin-arm64": "267608fe41dba91fb23e2a69a439cb2a39710b3a069a828c744bd99bb3f94508", + "windows-amd64": "19d00eea60477a8f983c20af690fedcc8da52aef81922e570b27538ca88305a4" + } +} diff --git a/reference-implementations/agt-go/agtbridge/agteval/acs.h b/reference-implementations/agt-go/agtbridge/agteval/acs.h new file mode 100644 index 00000000..5f9b41cd --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/agteval/acs.h @@ -0,0 +1,40 @@ +// The part of AGT's C ABI (policy-engine/core/src/ffi.rs at the commit +// agt-native.lock pins, MIT licensed; ffi_rs_sha256 there is that file's hash) the AGT evaluator calls. AGT ships no +// C header, so these declarations are written from that file. Strings are NUL-terminated +// UTF-8; a string AGT returns is released with acs_free_string, and a string +// the host returns from a callback is released with the host's free callback. + +#ifndef AGTEVAL_ACS_H +#define AGTEVAL_ACS_H + +#include + +#ifdef _WIN32 +#define ACS_API __declspec(dllimport) +#else +#define ACS_API +#endif + +typedef struct AcsBuilder AcsBuilder; +typedef struct AcsRuntime AcsRuntime; + +typedef void (*AcsFreeResultCallback)(char *ptr, void *user_data); +typedef char *(*AcsAnnotatorCallback)(const char *annotator_name, + const char *annotator_json, + const char *preliminary_policy_input_json, + void *user_data); + +ACS_API AcsBuilder *acs_builder_from_path(const char *path, char **err); +ACS_API int32_t acs_builder_register_annotator_dispatcher(AcsBuilder *b, + AcsAnnotatorCallback cb, + AcsFreeResultCallback free_result, + void *user_data, char **err); +ACS_API int32_t acs_builder_enable_default_policy_dispatcher(AcsBuilder *b, char **err); +ACS_API void acs_builder_free(AcsBuilder *b); +ACS_API AcsRuntime *acs_builder_build(AcsBuilder *b, char **err); +ACS_API char *acs_runtime_evaluate(const AcsRuntime *r, const char *request_json, char **err); +ACS_API char *acs_runtime_policy_labels(const AcsRuntime *r, char **err); +ACS_API void acs_runtime_free(AcsRuntime *r); +ACS_API void acs_free_string(char *s); + +#endif diff --git a/reference-implementations/agt-go/agtbridge/agteval/agteval.go b/reference-implementations/agt-go/agtbridge/agteval/agteval.go new file mode 100644 index 00000000..fc331dd5 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/agteval/agteval.go @@ -0,0 +1,342 @@ +//go:build agteval + +// Package agteval is the AGT evaluator: it executes AGT's policy for +// agtbridge by calling AGT's own runtime library, the Rust crate +// agent_control_specification_core, in the Guardian's process through its +// C ABI. It compiles only with the agteval build tag and cgo, so a program +// that imports guardian or agtbridge links no native library unless it +// imports this package. +// +// One runtime handle is built at startup and serves every call: AGT's +// evaluate takes it read-only and keeps no state between calls, so session +// state stays in agtbridge's policy state and the snapshot. The handle is +// never freed, since a call the Guardian stopped waiting for may still be +// running in it. At the pinned AGT version Rego runs in the opa program, one +// process per decision. +package agteval + +/* +#cgo LDFLAGS: -lagent_control_specification_core +#cgo linux LDFLAGS: -ldl +#cgo darwin,amd64 LDFLAGS: -Wl,-rpath,${SRCDIR}/../../.acs/agt/darwin-amd64/lib +#cgo darwin,arm64 LDFLAGS: -Wl,-rpath,${SRCDIR}/../../.acs/agt/darwin-arm64/lib +#include +#include "acs.h" + +int32_t agteval_register_annotator(AcsBuilder *b, char **err); +char *agteval_library_path(void); +*/ +import "C" + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "sync" + "sync/atomic" + "time" + "unsafe" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" +) + +// BuildRecord is the file scripts/build-agt-native.sh writes beside the +// library it builds: the AGT commit it built and the library's SHA-256. +const BuildRecord = "agt-native.json" + +// The environment AGT's default policy dispatcher reads its opa executable +// and timeout from when the runtime is built (policy-engine/core/src/opa.rs). +const ( + opaPathEnv = "ACS_OPA_PATH" + opaTimeoutEnv = "ACS_OPA_TIMEOUT_MS" +) + +// annotationFailed is the string a host annotator returns to make AGT fail +// the annotation closed (policy-engine/core/src/constants.rs). +const annotationFailed = "runtime_error:annotation_failed" + +// Config is what the AGT evaluator needs beyond the library. +type Config struct { + // ManifestPath is the manifest's file path; AGT resolves its bundle + // relative to the manifest's directory. + ManifestPath string + // OPAPath is the opa executable AGT runs for each Rego decision. + OPAPath string + // OPATimeout bounds each opa run, in whole milliseconds; AGT denies a + // decision whose run exceeds it. + OPATimeout time.Duration +} + +// Evaluator is the AGT evaluator. +type Evaluator struct { + runtime *C.AcsRuntime + points map[string]bool + version string + calls atomic.Uint64 +} + +var _ agtbridge.Evaluator = (*Evaluator)(nil) + +// buildLock serializes builds, since each publishes its opa settings +// through the process environment for the moment AGT reads them. +var buildLock sync.Mutex + +// New verifies the loaded library against its build record and builds one +// runtime from the manifest, with this Guardian's egress annotator and +// AGT's own opa policy dispatcher. AGT reads the opa settings only from the +// process environment, so New sets them for the moment of the build: build +// every evaluator before any of them serves, since another evaluator's opa +// launch would read the environment while New changes it. +func New(c Config) (*Evaluator, error) { + if c.ManifestPath == "" { + return nil, errors.New("agteval: the manifest path is empty") + } + if c.OPATimeout <= 0 || c.OPATimeout%time.Millisecond != 0 { + return nil, errors.New("agteval: the opa timeout must be a positive whole number of milliseconds") + } + if info, err := os.Stat(c.OPAPath); err == nil && info.IsDir() { + name := "opa" + if runtime.GOOS == "windows" { + name += ".exe" + } + c.OPAPath = filepath.Join(c.OPAPath, name) + } + path, err := exec.LookPath(c.OPAPath) + if err != nil { + return nil, fmt.Errorf("agteval: %q is not an opa executable (make agt-native fetches the pinned one)", c.OPAPath) + } + c.OPAPath = path + version, err := libraryVersion() + if err != nil { + return nil, err + } + runtime, err := build(c) + if err != nil { + return nil, err + } + points, err := governedPoints(runtime) + if err != nil { + C.acs_runtime_free(runtime) + return nil, err + } + return &Evaluator{runtime: runtime, points: points, version: version}, nil +} + +func build(c Config) (*C.AcsRuntime, error) { + buildLock.Lock() + defer buildLock.Unlock() + restore, err := setenv(map[string]string{ + opaPathEnv: c.OPAPath, + opaTimeoutEnv: strconv.FormatInt(c.OPATimeout.Milliseconds(), 10), + }) + if err != nil { + return nil, fmt.Errorf("agteval: %w", err) + } + defer restore() + path := C.CString(c.ManifestPath) + defer C.free(unsafe.Pointer(path)) + var cErr *C.char + builder := C.acs_builder_from_path(path, &cErr) + if builder == nil { + return nil, abiError("load the manifest "+c.ManifestPath, cErr) + } + if C.agteval_register_annotator(builder, &cErr) != 0 { + C.acs_builder_free(builder) + return nil, abiError("register the annotator dispatcher", cErr) + } + if C.acs_builder_enable_default_policy_dispatcher(builder, &cErr) != 0 { + C.acs_builder_free(builder) + return nil, abiError("enable the opa policy dispatcher", cErr) + } + runtime := C.acs_builder_build(builder, &cErr) + if runtime == nil { + return nil, abiError("build the runtime from "+c.ManifestPath, cErr) + } + return runtime, nil +} + +// setenv sets each variable and returns what puts the previous values back. +func setenv(values map[string]string) (func(), error) { + type previous struct { + value string + set bool + } + saved := map[string]previous{} + restore := func() { + for name, p := range saved { + if p.set { + _ = os.Setenv(name, p.value) + } else { + _ = os.Unsetenv(name) + } + } + } + for name, value := range values { + old, set := os.LookupEnv(name) + saved[name] = previous{old, set} + if err := os.Setenv(name, value); err != nil { + restore() + return nil, err + } + } + return restore, nil +} + +// governedPoints reads the intervention points the manifest configures, +// and refuses one that wires an annotator this Guardian does not dispatch. +func governedPoints(runtime *C.AcsRuntime) (map[string]bool, error) { + var cErr *C.char + out := C.acs_runtime_policy_labels(runtime, &cErr) + if out == nil { + return nil, abiError("read the manifest's intervention points", cErr) + } + defer C.acs_free_string(out) + var labels map[string]struct { + Annotators []string `json:"annotators"` + } + if err := jsonv2.Unmarshal([]byte(C.GoString(out)), &labels); err != nil { + return nil, fmt.Errorf("agteval: read the manifest's intervention points: %w", err) + } + points := map[string]bool{} + for point, label := range labels { + for _, annotator := range label.Annotators { + if annotator != agtbridge.EgressAnnotator { + return nil, fmt.Errorf("agteval: intervention point %s wires annotator %q, for which this Guardian has no dispatcher", point, annotator) + } + } + points[point] = true + } + return points, nil +} + +// libraryVersion finds the file the loader mapped for AGT's runtime +// library and returns the AGT commit its build record names. +func libraryVersion() (string, error) { + cPath := C.agteval_library_path() + if cPath == nil { + return "", errors.New("agteval: the loaded AGT runtime library cannot be located") + } + defer C.free(unsafe.Pointer(cPath)) + return verifyBuildRecord(C.GoString(cPath)) +} + +// verifyBuildRecord checks a library against the build record beside it +// and returns the AGT commit the record names. +func verifyBuildRecord(library string) (string, error) { + recordPath := filepath.Join(filepath.Dir(library), BuildRecord) + raw, err := os.ReadFile(recordPath) + if err != nil { + return "", fmt.Errorf("agteval: the AGT runtime library %s has no build record (make agt-native): %w", library, err) + } + var record struct { + AGTRef string `json:"agt_ref"` + SHA256 string `json:"sha256"` + } + if err := jsonv2.Unmarshal(raw, &record); err != nil || record.AGTRef == "" || record.SHA256 == "" { + return "", fmt.Errorf("agteval: the build record %s does not name an AGT commit and a SHA-256 (make agt-native)", recordPath) + } + f, err := os.Open(library) + if err != nil { + return "", fmt.Errorf("agteval: %w", err) + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("agteval: %w", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != record.SHA256 { + return "", fmt.Errorf("agteval: the AGT runtime library %s has SHA-256 %s, and its build record names %s (make agt-native)", library, got, record.SHA256) + } + return record.AGTRef, nil +} + +// Governs implements agtbridge.Evaluator. +func (e *Evaluator) Governs(point string) bool { return e.points[point] } + +// AGTVersion implements agtbridge.Evaluator: the AGT commit the loaded +// library was built from. +func (e *Evaluator) AGTVersion() string { return e.version } + +// Calls is the number of evaluations made in AGT's runtime library. +func (e *Evaluator) Calls() uint64 { return e.calls.Load() } + +type request struct { + InterventionPoint string `json:"intervention_point"` + Mode string `json:"mode"` + Snapshot map[string]any `json:"snapshot"` +} + +// Evaluate implements agtbridge.Evaluator. A call into AGT cannot be +// interrupted: when ctx ends during it, the call runs to completion and its +// verdict is discarded, while the Guardian keeps the call's engine slot. +func (e *Evaluator) Evaluate(ctx context.Context, point string, snapshot map[string]any) (agtbridge.Verdict, error) { + if err := ctx.Err(); err != nil { + return agtbridge.Verdict{}, err + } + body, err := jsonv2.Marshal(request{InterventionPoint: point, Mode: "enforce", Snapshot: snapshot}) + if err != nil { + return agtbridge.Verdict{}, fmt.Errorf("agteval: encode the request: %w", err) + } + cRequest := C.CString(string(body)) + defer C.free(unsafe.Pointer(cRequest)) + var cErr *C.char + e.calls.Add(1) + out := C.acs_runtime_evaluate(e.runtime, cRequest, &cErr) + if out == nil { + return agtbridge.Verdict{}, abiError("evaluate", cErr) + } + defer C.acs_free_string(out) + if err := ctx.Err(); err != nil { + return agtbridge.Verdict{}, err + } + var result struct { + Verdict *agtbridge.Verdict `json:"verdict"` + } + if err := jsonv2.Unmarshal([]byte(C.GoString(out)), &result); err != nil || result.Verdict == nil { + return agtbridge.Verdict{}, fmt.Errorf("agteval: AGT answered without a verdict: %v", err) + } + return *result.Verdict, nil +} + +func abiError(what string, cErr *C.char) error { + if cErr == nil { + return fmt.Errorf("agteval: %s failed without an error message", what) + } + defer C.acs_free_string(cErr) + return fmt.Errorf("agteval: %s: %s", what, C.GoString(cErr)) +} + +// agtevalAnnotate is the host annotator AGT calls for each annotation of a +// step. It dispatches the egress annotator, the one this Guardian supplies, +// and fails any other closed. A panic must not unwind into AGT's frames. +// +//export agtevalAnnotate +func agtevalAnnotate(name, preliminary *C.char) (out *C.char) { + defer func() { + if recover() != nil { + out = C.CString(annotationFailed) + } + }() + if C.GoString(name) != agtbridge.EgressAnnotator { + return C.CString(annotationFailed) + } + var input map[string]any + if err := jsonv2.Unmarshal([]byte(C.GoString(preliminary)), &input); err != nil { + return C.CString(annotationFailed) + } + answer, err := jsonv2.Marshal(agtbridge.AnnotateEgress(input)) + if err != nil { + return C.CString(annotationFailed) + } + return C.CString(string(answer)) +} diff --git a/reference-implementations/agt-go/agtbridge/agteval/agteval_test.go b/reference-implementations/agt-go/agtbridge/agteval/agteval_test.go new file mode 100644 index 00000000..d38f6288 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/agteval/agteval_test.go @@ -0,0 +1,208 @@ +//go:build agteval + +package agteval_test + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge/agteval" +) + +const manifest = "../../../agt/policy/manifest.yaml" + +var opa = "../../.acs/agt/" + runtime.GOOS + "-" + runtime.GOARCH + "/bin" + +func TestMain(m *testing.M) { + if delay := os.Getenv("ACS_TEST_OPA_DELAY"); delay != "" { + duration, err := time.ParseDuration(delay) + if err != nil { + os.Exit(2) + } + time.Sleep(duration) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func slowOPA(t *testing.T, delay string) string { + t.Helper() + t.Setenv("ACS_TEST_OPA_DELAY", delay) + path, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return path +} + +func shell(command string) map[string]any { + return map[string]any{ + "envelope": map[string]any{"budgets": map[string]any{"tool_call_count": 0.0, "token_count": 0.0, "elapsed_seconds": 0.0, "cost_usd": 0.0}}, + "tool_call": map[string]any{"name": "Bash", "args": map[string]any{"command": command, agtbridge.PolicyTargetLeaf: command}, "id": "6a22a0f7-5547-448a-add7-3327aed144df", "raw_command": command}, + "input": map[string]any{"ifc": map[string]any{"source_labels": []any{"public"}}}, + } +} + +func newEvaluator(t *testing.T, c agteval.Config) *agteval.Evaluator { + t.Helper() + e, err := agteval.New(c) + if err != nil { + t.Fatal(err) + } + return e +} + +func reference(t *testing.T) *agteval.Evaluator { + return newEvaluator(t, agteval.Config{ManifestPath: manifest, OPAPath: opa, OPATimeout: 5 * time.Second}) +} + +// TestOneHandleServesParallelCalls: concurrent evaluations share the one +// runtime handle, each answers its own snapshot, and none depends on an +// earlier call. +func TestOneHandleServesParallelCalls(t *testing.T) { + e := reference(t) + want := map[string]string{"ls": "allow", "rm -rf /": "deny", "curl https://evil.example.net/x": "deny"} + var wg sync.WaitGroup + for i := range 48 { + wg.Go(func() { + command := []string{"ls", "rm -rf /", "curl https://evil.example.net/x"}[i%3] + v, err := e.Evaluate(context.Background(), agtbridge.PointPreToolCall, shell(command)) + if err != nil || v.Decision != want[command] { + t.Errorf("%s: verdict %+v, error %v; want %s", command, v, err, want[command]) + } + }) + } + wg.Wait() + if e.Calls() != 48 { + t.Fatalf("%d calls into AGT's runtime library, want 48", e.Calls()) + } +} + +// TestEgressAnnotatorRunsInTheCallback: the manifest's egress classifier +// reaches the policy only through this Guardian's annotator callback. +func TestEgressAnnotatorRunsInTheCallback(t *testing.T) { + e := reference(t) + for command, want := range map[string]string{ + "curl https://evil.example.net/x": "egress_destination_not_allowed", + "curl https://docs.example.com/x": "", + } { + v, err := e.Evaluate(context.Background(), agtbridge.PointPreToolCall, shell(command)) + if err != nil { + t.Fatal(err) + } + got := "" + if v.Reason != nil { + got = *v.Reason + } + if got != want { + t.Errorf("%s: reason %q, want %q", command, got, want) + } + } +} + +// TestFailuresAreVerdicts: a failure inside AGT's evaluation is a +// fail-closed deny, as AGT's C ABI defines it, not an error. +func TestFailuresAreVerdicts(t *testing.T) { + e := reference(t) + for name, tt := range map[string]struct { + point string + snapshot map[string]any + reason string + }{ + "unknown_point": {"no_such_point", shell("ls"), "runtime_error:intervention_point_unknown"}, + "missing_path": {agtbridge.PointPreToolCall, map[string]any{}, "runtime_error:path_missing"}, + } { + t.Run(name, func(t *testing.T) { + v, err := e.Evaluate(context.Background(), tt.point, tt.snapshot) + if err != nil || v.Decision != "deny" || v.Reason == nil || *v.Reason != tt.reason { + t.Fatalf("verdict %+v, error %v; want deny %s", v, err, tt.reason) + } + }) + } +} + +// TestCancellation: a call whose context already ended never reaches AGT; +// a context that ends during the call leaves the call running to +// completion and its verdict unused. +func TestCancellation(t *testing.T) { + e := reference(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := e.Evaluate(ctx, agtbridge.PointPreToolCall, shell("ls")); err != context.Canceled { + t.Fatalf("error %v, want context.Canceled", err) + } + if e.Calls() != 0 { + t.Fatal("a call whose context had ended reached AGT") + } + + slow := slowOPA(t, "1s") + running := newEvaluator(t, agteval.Config{ManifestPath: manifest, OPAPath: slow, OPATimeout: 5 * time.Second}) + ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + start := time.Now() + if _, err := running.Evaluate(ctx, agtbridge.PointPreToolCall, shell("ls")); err != context.DeadlineExceeded { + t.Fatalf("error %v, want context.DeadlineExceeded", err) + } + if running.Calls() != 1 || time.Since(start) < time.Second { + t.Fatalf("calls %d after %s; want the one call run to completion", running.Calls(), time.Since(start)) + } +} + +// TestOPATimeoutReachesAGT: policy.opa_timeout is the bound AGT puts on +// each opa run; past it, AGT denies. +func TestOPATimeoutReachesAGT(t *testing.T) { + slow := slowOPA(t, "5s") + e := newEvaluator(t, agteval.Config{ManifestPath: manifest, OPAPath: slow, OPATimeout: 50 * time.Millisecond}) + start := time.Now() + v, err := e.Evaluate(context.Background(), agtbridge.PointPreToolCall, shell("ls")) + if err != nil || v.Decision != "deny" || v.Reason == nil || *v.Reason != "runtime_error:policy_invocation_failed" { + t.Fatalf("verdict %+v, error %v; want deny policy_invocation_failed", v, err) + } + if elapsed := time.Since(start); elapsed > 4*time.Second { + t.Fatalf("the evaluation took %s, past the 50ms opa timeout", elapsed) + } + if _, set := os.LookupEnv("ACS_OPA_PATH"); set { + t.Fatal("ACS_OPA_PATH stayed in the process environment after the build") + } +} + +func TestNewRefuses(t *testing.T) { + dir := t.TempDir() + undispatched := filepath.Join(dir, "manifest.yaml") + if err := os.WriteFile(undispatched, []byte(`agent_control_specification_version: "0.3.1-beta" +policies: + p: {type: rego, bundle: lib, query: data.p.verdict} +annotators: + other: {type: classifier} +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.acs_policy_target" + annotations: + other: {from: "$.tool_call.raw_command"} + policy: {id: p} +`), 0o600); err != nil { + t.Fatal(err) + } + for name, tt := range map[string]struct { + config agteval.Config + want string + }{ + "missing_manifest": {agteval.Config{ManifestPath: filepath.Join(dir, "absent.yaml"), OPAPath: opa, OPATimeout: time.Second}, "from_path failed"}, + "undispatched_annotator": {agteval.Config{ManifestPath: undispatched, OPAPath: opa, OPATimeout: time.Second}, `annotator "other"`}, + "missing_opa": {agteval.Config{ManifestPath: manifest, OPAPath: filepath.Join(dir, "opa"), OPATimeout: time.Second}, "is not an opa executable"}, + "fractional_milliseconds": {agteval.Config{ManifestPath: manifest, OPAPath: opa, OPATimeout: 1500 * time.Microsecond}, "whole number of milliseconds"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := agteval.New(tt.config); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error %v, want %q", err, tt.want) + } + }) + } +} diff --git a/reference-implementations/agt-go/agtbridge/agteval/record_internal_test.go b/reference-implementations/agt-go/agtbridge/agteval/record_internal_test.go new file mode 100644 index 00000000..a92c0f0a --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/agteval/record_internal_test.go @@ -0,0 +1,44 @@ +//go:build agteval + +package agteval + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestVerifyBuildRecord(t *testing.T) { + const content = "library" + const contentSHA256 = "b718f1354f7247312eca086d9a024afe5fa717ddea5adeddd6f12bcf945b2e8c" + for name, tt := range map[string]struct { + record string + want string + }{ + "matches": {`{"agt_ref":"abc","sha256":"` + contentSHA256 + `"}`, ""}, + "no_record": {"", "has no build record"}, + "no_commit": {`{"sha256":"` + contentSHA256 + `"}`, "does not name an AGT commit"}, + "other_bytes": {`{"agt_ref":"abc","sha256":"00"}`, "and its build record names 00"}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + library := filepath.Join(dir, "libagt.so") + if err := os.WriteFile(library, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if tt.record != "" { + if err := os.WriteFile(filepath.Join(dir, BuildRecord), []byte(tt.record), 0o600); err != nil { + t.Fatal(err) + } + } + ref, err := verifyBuildRecord(library) + switch { + case tt.want == "" && (err != nil || ref != "abc"): + t.Fatalf("ref %q, error %v; want abc", ref, err) + case tt.want != "" && (err == nil || !strings.Contains(err.Error(), tt.want)): + t.Fatalf("error %v, want %q", err, tt.want) + } + }) + } +} diff --git a/reference-implementations/agt-go/agtbridge/agteval/shim.c b/reference-implementations/agt-go/agtbridge/agteval/shim.c new file mode 100644 index 00000000..98bc203e --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/agteval/shim.c @@ -0,0 +1,68 @@ +//go:build agteval + +#define _GNU_SOURCE +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +#include "acs.h" +#include "_cgo_export.h" + +static char *annotate(const char *annotator_name, const char *annotator_json, + const char *preliminary_policy_input_json, void *user_data) { + (void)annotator_json; + (void)user_data; + return agtevalAnnotate((char *)annotator_name, (char *)preliminary_policy_input_json); +} + +static void free_result(char *ptr, void *user_data) { + (void)user_data; + free(ptr); +} + +int32_t agteval_register_annotator(AcsBuilder *b, char **err) { + return acs_builder_register_annotator_dispatcher(b, annotate, free_result, NULL, err); +} + +// The path of the file the dynamic loader mapped for AGT's runtime library. +char *agteval_library_path(void) { +#ifdef _WIN32 + HMODULE module; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCWSTR)(void *)acs_runtime_evaluate, &module)) { + return NULL; + } + DWORD capacity = MAX_PATH; + wchar_t *wide = NULL; + for (;;) { + wchar_t *next = realloc(wide, capacity * sizeof(wchar_t)); + if (next == NULL) { free(wide); return NULL; } + wide = next; + DWORD length = GetModuleFileNameW(module, wide, capacity); + if (length == 0) { free(wide); return NULL; } + if (length < capacity) break; + if (capacity > UINT32_MAX / 2 / sizeof(wchar_t)) { free(wide); return NULL; } + capacity *= 2; + } + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide, -1, NULL, 0, NULL, NULL); + char *path = size > 0 ? malloc(size) : NULL; + if (path != NULL && !WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide, -1, path, size, NULL, NULL)) { + free(path); + path = NULL; + } + free(wide); + return path; +#else + Dl_info info; + if (dladdr((void *)acs_runtime_evaluate, &info) == 0 || info.dli_fname == NULL) { + return NULL; + } + return strdup(info.dli_fname); +#endif +} diff --git a/reference-implementations/agt-go/agtbridge/bridge.go b/reference-implementations/agt-go/agtbridge/bridge.go new file mode 100644 index 00000000..2830722a --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/bridge.go @@ -0,0 +1,264 @@ +// Package agtbridge is a guardian.PolicyEngine over Microsoft's Agent +// Governance Toolkit (AGT), the engine of the TypeScript reference +// implementation beside this module. +// +// The package holds one projection and evaluates through one of two +// evaluators. The projection is what the TypeScript Guardian does around +// AGT: it keeps the session's policy state, assembles AGT's snapshot at the +// pre_tool_call and post_tool_call intervention points, dispatches the +// egress annotator the reference's manifest declares, turns each verdict +// into an ACS decision through the reference's mapping.yaml, and handles +// Ask, tool aliases and wrapped MCP calls. The evaluator executes AGT's +// policy for a snapshot: the AGT evaluator (package agteval) calls AGT's own +// runtime library, and the Go evaluator (package goeval) reproduces that +// runtime in Go for a build without cgo. +// +// agtbridge and its evaluators are the only packages that know AGT exists. +package agtbridge + +import ( + "context" + "errors" + "fmt" + "io/fs" + "maps" + "slices" + "strings" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +// reasonMCPCallPending is the reason code of a wrapped tools/call denied +// because its MCP id is already awaiting a response. +const reasonMCPCallPending = "mcp_call_pending" + +// DefaultAskTimeout is the ask_details.timeout_seconds of an Ask when +// Options.AskTimeout is zero. +const DefaultAskTimeout = 5 * time.Minute + +// Options are what a deployment chooses beyond the manifest and the mapping. +type Options struct { + // Approver is who resolves AGT's escalate verdict, sent as an ACS Ask. + // AGT's manifest names no approver for ACS, so without one an escalate + // is answered DENY with reason code approver_unavailable. + Approver *acs.Approver + // AskTimeout is an Ask's timeout; zero means DefaultAskTimeout. + AskTimeout time.Duration + // ToolAliases maps host tool names to manifest tool names before policy + // evaluation. The ACS request and audit chain retain the host's name. + ToolAliases map[string]string +} + +// Engine is AGT behind guardian.PolicyEngine. +type Engine struct { + evaluator Evaluator + mapping *mapping + opts Options +} + +var _ guardian.PolicyEngine = (*Engine)(nil) + +// New builds the engine over an evaluator and the mapping at mappingPath in +// fsys. It refuses a mapping that sends an ACS method to an intervention +// point the evaluator governs and this bridge assembles no snapshot for. +func New(fsys fs.FS, mappingPath string, evaluator Evaluator, opts Options) (*Engine, error) { + switch { + case opts.AskTimeout != 0 && opts.AskTimeout < time.Second: + return nil, errors.New("agtbridge: AskTimeout is at least one second, the smallest timeout_seconds ask-details.json admits") + case opts.AskTimeout%time.Second != 0: + return nil, errors.New("agtbridge: AskTimeout must be a whole number of seconds") + case opts.Approver != nil && (opts.Approver.Type == "" || opts.Approver.ID == ""): + return nil, errors.New("agtbridge: an Approver needs a type and an id") + } + for source, target := range opts.ToolAliases { + if strings.TrimSpace(source) == "" || strings.TrimSpace(target) == "" { + return nil, errors.New("agtbridge: ToolAliases must not contain empty names") + } + } + if opts.AskTimeout == 0 { + opts.AskTimeout = DefaultAskTimeout + } + if opts.Approver != nil { + approver := *opts.Approver + if approver.Endpoint != nil { + endpoint := *approver.Endpoint + approver.Endpoint = &endpoint + } + if approver.Auth != nil { + auth := *approver.Auth + if auth.Method != nil { + method := *auth.Method + auth.Method = &method + } + approver.Auth = &auth + } + opts.Approver = &approver + } + opts.ToolAliases = cloneMap(opts.ToolAliases) + src, err := fs.ReadFile(fsys, mappingPath) + if err != nil { + return nil, fmt.Errorf("agtbridge: %w", err) + } + mp, err := parseMapping(src) + if err != nil { + return nil, fmt.Errorf("agtbridge: %w", err) + } + for _, point := range slices.Sorted(maps.Keys(mp.InterventionPoints)) { + row := mp.InterventionPoints[point] + if row.ACSMethod != nil && evaluator.Governs(point) && point != PointPreToolCall && point != PointPostToolCall { + return nil, fmt.Errorf("agtbridge: mapping.yaml sends %s to intervention point %q, which the manifest configures and whose snapshot this bridge does not assemble", *row.ACSMethod, point) + } + } + return &Engine{evaluator: evaluator, mapping: mp, opts: opts}, nil +} + +// Policy implements guardian.PolicyEngine. +func (e *Engine) Policy() guardian.PolicyDescription { + var d guardian.PolicyDescription + if e.opts.Approver != nil { + d.ApproverTypes = []acs.ApproverType{e.opts.Approver.Type} + } + return d +} + +// Decide implements guardian.PolicyEngine. A step whose method maps to no +// intervention point the manifest configures is not governed by AGT, and is +// allowed. +func (e *Engine) Decide(ctx context.Context, in guardian.PolicyInput) (guardian.PolicyDecision, error) { + state, err := readState(in.Session.PolicyState) + if err != nil { + return guardian.PolicyDecision{}, err + } + before := len(state.MCPCalls) + p, err := project(in.Request, &state) + if errors.Is(err, errMCPCallPending) { + return guardian.PolicyDecision{Decision: acs.Decision{ + Disposition: acs.Deny, + Reasoning: "Another tools/call with this MCP id is still awaiting its response, so their results could not be told apart.", + ReasonCodes: []string{reasonMCPCallPending}, + }}, nil + } + if err != nil { + return guardian.PolicyDecision{}, err + } + req := p.req + point, ok := e.mapping.point(req.Method) + if !ok || !e.evaluator.Governs(point) { + pd := guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}} + p.settle(&state, acs.Allow) + if len(state.MCPCalls) != before || p.outputs != nil { + pd.State, err = jsonv2.Marshal(state) + } + return pd, err + } + var head struct { + Tool acs.Tool `json:"tool"` + } + if err := jsonv2.Unmarshal(req.Params.Payload, &head); err != nil { + return guardian.PolicyDecision{}, err + } + policyToolName := head.Tool.Name + if alias := e.opts.ToolAliases[policyToolName]; alias != "" { + policyToolName = alias + } + argument, err := e.mapping.policyTargetArgument(point, policyToolName) + if err != nil { + return guardian.PolicyDecision{}, err + } + var snapshot map[string]any + switch point { + case PointPreToolCall: + snapshot, err = preToolCallSnapshot(req, state.IFCLabels, argument, policyToolName) + case PointPostToolCall: + snapshot, err = postToolCallSnapshot(req, state.IFCLabels, policyToolName) + } + if err != nil { + return guardian.PolicyDecision{}, err + } + v, err := e.evaluator.Evaluate(ctx, point, snapshot) + if err != nil { + return guardian.PolicyDecision{}, err + } + d, err := e.mapping.decision(v, point, argument) + if err != nil { + return guardian.PolicyDecision{}, err + } + if err := p.readdress(d.Modifications); err != nil { + return guardian.PolicyDecision{}, err + } + pd := guardian.PolicyDecision{Decision: d} + // AGT omits an empty result_labels, so labels arrive only to replace the + // session's; absent, they leave it unchanged, as the TypeScript + // Guardian's persistIfcLabels does. + labelled := len(v.ResultLabels) > 0 + if labelled { + state.IFCLabels = v.ResultLabels + } + p.settle(&state, d.Disposition) + if labelled || len(state.MCPCalls) != before || p.outputs != nil { + if pd.State, err = jsonv2.Marshal(state); err != nil { + return guardian.PolicyDecision{}, err + } + } + if d.Disposition == acs.Ask { + pd.Decision = e.ask(d) + } + return pd, nil +} + +func cloneMap(source map[string]string) map[string]string { + if source == nil { + return nil + } + copy := make(map[string]string, len(source)) + for key, value := range source { + copy[key] = value + } + return copy +} + +// policyState is what the engine keeps with a session: the +// information-flow labels AGT's last verdict returned as result_labels, +// which the next step supplies back as source labels, since AGT's core +// stores nothing; and the tool name of each wrapped MCP tools/call awaiting +// its response, by MCP id. +type policyState struct { + IFCLabels []string `json:"ifc_labels"` + MCPCalls map[string]string `json:"mcp_calls,omitzero"` +} + +// initialLabels are the labels a session starts with, the TypeScript +// reference's (packages/guardian/src/session-context.ts). +var initialLabels = []string{"public"} + +func readState(raw []byte) (policyState, error) { + if raw == nil { + return policyState{IFCLabels: slices.Clone(initialLabels)}, nil + } + var s policyState + if err := jsonv2.Unmarshal(raw, &s); err != nil { + return s, fmt.Errorf("the session's policy state cannot be read: %w", err) + } + return s, nil +} + +// ask completes an Ask with its details, or answers DENY when no approver is +// configured: an Ask nobody can resolve is a denial with its reason stated. +func (e *Engine) ask(d acs.Decision) acs.Decision { + if e.opts.Approver == nil { + d.Disposition = acs.Deny + d.Reasoning = strings.TrimSpace(d.Reasoning + " No approver is configured for this Guardian, so the step is denied.") + d.ReasonCodes = append(slices.Clip(d.ReasonCodes), "approver_unavailable") + return d + } + d.AskDetails = &acs.AskDetails{ + Approver: *e.opts.Approver, + Question: d.Reasoning, + TimeoutSeconds: int64(e.opts.AskTimeout / time.Second), + } + return d +} diff --git a/reference-implementations/agt-go/agtbridge/bridge_test.go b/reference-implementations/agt-go/agtbridge/bridge_test.go new file mode 100644 index 00000000..d188c32c --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/bridge_test.go @@ -0,0 +1,586 @@ +package agtbridge_test + +import ( + "context" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge/goeval" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" +) + +// agtTree is the TypeScript reference tree, whose manifest, bundle and +// mapping this engine reads unchanged. +const agtTree = "../../agt" + +func newEngine(t *testing.T, opts agtbridge.Options) *agtbridge.Engine { + t.Helper() + e, err := goEngine(os.DirFS(agtTree), "policy/manifest.yaml", "mapping.yaml", opts) + if err != nil { + t.Fatal(err) + } + return e +} + +func goEvaluator(t testing.TB) *goeval.Evaluator { + t.Helper() + e, err := goeval.New(context.Background(), os.DirFS(agtTree), "policy/manifest.yaml") + if err != nil { + t.Fatal(err) + } + return e +} + +func engineOver(t testing.TB, evaluator agtbridge.Evaluator) *agtbridge.Engine { + t.Helper() + e, err := agtbridge.New(os.DirFS(agtTree), "mapping.yaml", evaluator, agtbridge.Options{}) + if err != nil { + t.Fatal(err) + } + return e +} + +// goEngine builds the engine over the Go evaluator. +func goEngine(fsys fs.FS, manifestPath, mappingPath string, opts agtbridge.Options) (*agtbridge.Engine, error) { + evaluator, err := goeval.New(context.Background(), fsys, manifestPath) + if err != nil { + return nil, err + } + return agtbridge.New(fsys, mappingPath, evaluator, opts) +} + +// testSigner is the one HMAC key every Guardian in these tests signs with. +func testSigner(t testing.TB) guardian.Signer { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k", Secret: []byte("0123456789abcdef0123456789abcdef")}) + if err != nil { + t.Fatal(err) + } + return signer +} + +// pointsEvaluator governs the given points and must never be asked. +type pointsEvaluator []string + +func (p pointsEvaluator) Governs(point string) bool { return slices.Contains(p, point) } + +func (pointsEvaluator) Evaluate(context.Context, string, map[string]any) (agtbridge.Verdict, error) { + panic("pointsEvaluator evaluates nothing") +} + +func (pointsEvaluator) AGTVersion() string { return "" } + +func input(t *testing.T, method string, payload any) guardian.PolicyInput { + t.Helper() + b, err := jsonv2.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return guardian.PolicyInput{ + Request: acs.Request{Method: method, Params: acs.Params{RequestID: "6a22a0f7-5547-448a-add7-3327aed144df", Payload: b}}, + Session: guardian.SessionContext{SessionID: "s1"}, + } +} + +func toolCall(tool, argument, value string, raw *string) map[string]any { + p := toolCallValue(tool, argument, value) + if raw != nil { + p["raw_command"] = *raw + } + return p +} + +// toolCallValue is a tool call whose one argument holds value, of any JSON type. +func toolCallValue(tool, argument string, value any) map[string]any { + return map[string]any{"tool": map[string]any{"name": tool}, "arguments": map[string]any{argument: map[string]any{"value": value}}} +} + +func shell(command string) map[string]any { return toolCall("Bash", "command", command, &command) } + +// A policy target that is not a string skips every stock rule guarded by +// is_string, so the Guardian must refuse the call before AGT reads it. +func TestNonStringPolicyTargetIsDenied(t *testing.T) { + targets := []struct { + name string + value any + kind string + }{ + {"object", map[string]any{"cmd": "rm -rf /"}, "object"}, + {"array", []any{"rm -rf /"}, "array"}, + } + e := newEngine(t, agtbridge.Options{}) + for _, tt := range targets { + want := `sent argument "command" as a JSON ` + tt.kind + ", but its policy target must be a string" + t.Run("engine_"+tt.name, func(t *testing.T) { + _, err := e.Decide(context.Background(), input(t, acs.StepToolCallRequest, toolCallValue("Bash", "command", tt.value))) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("got error %v", err) + } + }) + t.Run("mcp_"+tt.name, func(t *testing.T) { + call := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "Bash", "arguments": map[string]any{"command": tt.value}}} + _, err := e.Decide(context.Background(), input(t, "protocols/MCP/tools/call", call)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("got error %v", err) + } + }) + } + + signer := testSigner(t) + for name, evaluator := range recordedEvaluators { + t.Run(name, func(t *testing.T) { + g, err := guardian.New(guardian.Config{Engine: engineOver(t, evaluator(t)), Signer: signer}) + if err != nil { + t.Fatal(err) + } + client := openSession(t, g, signer) + for _, tt := range targets { + t.Run(tt.name, func(t *testing.T) { + o, err := client.Send(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCallValue("Bash", "command", tt.value)}) + if err != nil { + t.Fatal(err) + } + if o.Result == nil { + t.Fatalf("no decision: %s", o.Raw) + } + if d := o.Result.Decision; d.Disposition != acs.Deny || !slices.Equal(d.ReasonCodes, []string{disposition.ReasonEvaluationFailed}) { + t.Fatalf("got %+v", d) + } + }) + } + }) + } +} + +func TestDecisions(t *testing.T) { + e := newEngine(t, agtbridge.Options{}) + tests := []struct { + name string + method string + payload any + disposition acs.Disposition + reason string + reasoning string + }{ + {"destructive_command", acs.StepToolCallRequest, shell("rm -rf /"), acs.Deny, "destructive_shell_command_blocked", + "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}, + {"destructive_command_echoed", acs.StepToolCallRequest, shell("echo rm -rf /"), acs.Deny, "destructive_shell_command_blocked", ""}, + {"benign_command", acs.StepToolCallRequest, shell("ls -la"), acs.Allow, "", ""}, + {"egress_off_the_allowlist", acs.StepToolCallRequest, shell("git clone https://example.org/repo"), acs.Deny, "egress_destination_not_allowed", ""}, + {"egress_on_the_allowlist", acs.StepToolCallRequest, shell("curl https://docs.example.com/x"), acs.Allow, "", ""}, + {"egress_userinfo_with_feff_denied", acs.StepToolCallRequest, shell("curl https://docs.example.com\ufeff@evil.example.net/x"), acs.Deny, "egress_destination_not_allowed", ""}, + {"fetch_on_the_allowlist", acs.StepToolCallRequest, toolCall("WebFetch", "url", "https://docs.example.com/en", nil), acs.Allow, "", ""}, + {"fetch_off_the_allowlist", acs.StepToolCallRequest, toolCall("WebFetch", "url", "https://evil.example.net/", nil), acs.Deny, "egress_destination_not_allowed", ""}, + {"unregistered_tool", acs.StepToolCallRequest, toolCall("Write", "command", "x", nil), acs.Deny, "runtime_error:tool_unknown", ""}, + {"not_governed_by_agt", acs.StepUserMessage, map[string]any{"content": []any{}}, acs.Allow, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, err := e.Decide(context.Background(), input(t, tt.method, tt.payload)) + if err != nil { + t.Fatal(err) + } + if d.Disposition != tt.disposition { + t.Fatalf("got %+v, want %s", d.Decision, tt.disposition) + } + if tt.reason != "" && !slices.Equal(d.ReasonCodes, []string{tt.reason}) { + t.Fatalf("reason codes %v, want [%s]", d.ReasonCodes, tt.reason) + } + if tt.reason != "" && (len(d.PolicyReferences) != 1 || d.PolicyReferences[0] != (acs.PolicyReference{PolicyID: "agt_stock", RuleID: tt.reason})) { + t.Fatalf("policy references %+v", d.PolicyReferences) + } + if tt.reasoning != "" && d.Reasoning != tt.reasoning { + t.Fatalf("reasoning %q\nwant %q", d.Reasoning, tt.reasoning) + } + }) + } +} + +// verdictEvaluator answers every pre_tool_call with one fixed verdict. +type verdictEvaluator agtbridge.Verdict + +func (verdictEvaluator) Governs(point string) bool { return point == "pre_tool_call" } + +func (v verdictEvaluator) Evaluate(context.Context, string, map[string]any) (agtbridge.Verdict, error) { + return agtbridge.Verdict(v), nil +} + +func (verdictEvaluator) AGTVersion() string { return "" } + +// response-envelope.json requires a non-empty rule_id, and Trace reads "" in +// its policy arrays as absent, so an empty AGT reason names no rule and no code. +func TestEmptyVerdictReasonNamesNoRule(t *testing.T) { + empty := "" + for _, decision := range []string{"allow", "deny"} { + t.Run(decision, func(t *testing.T) { + e := engineOver(t, verdictEvaluator{Decision: decision, Reason: &empty}) + d, err := e.Decide(context.Background(), input(t, acs.StepToolCallRequest, shell("ls"))) + if err != nil { + t.Fatal(err) + } + if d.PolicyReferences != nil || d.ReasonCodes != nil || d.Reasoning != "" { + t.Fatalf("got %+v", d.Decision) + } + }) + } + signer := testSigner(t) + // A deny names no rule and so has no reasoning, which §6 requires: the + // Guardian refuses it as unfit rather than sending it. + wire := []struct { + decision string + disposition acs.Disposition + reasonCodes []string + }{ + {"allow", acs.Allow, nil}, + {"deny", acs.Deny, []string{disposition.ReasonEvaluationFailed}}, + } + for _, tt := range wire { + t.Run("guardian_"+tt.decision, func(t *testing.T) { + g, err := guardian.New(guardian.Config{Engine: engineOver(t, verdictEvaluator{Decision: tt.decision, Reason: &empty}), Signer: signer}) + if err != nil { + t.Fatal(err) + } + o, err := openSession(t, g, signer).Send(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: shell("ls")}) + if err != nil { + t.Fatal(err) + } + if o.Result == nil { + t.Fatalf("no decision: %s", o.Raw) + } + d := o.Result.Decision + if d.Disposition != tt.disposition || !slices.Equal(d.ReasonCodes, tt.reasonCodes) || len(d.PolicyReferences) != 0 { + t.Fatalf("got %s", o.Raw) + } + }) + } + t.Run("warn", func(t *testing.T) { + e := engineOver(t, verdictEvaluator{Decision: "warn", Reason: &empty}) + _, err := e.Decide(context.Background(), input(t, acs.StepToolCallRequest, shell("ls"))) + if err == nil || !strings.Contains(err.Error(), "verdict.reason was empty") { + t.Fatalf("got error %v", err) + } + }) +} + +func TestToolAliasesArePolicyInputOnly(t *testing.T) { + aliases := map[string]string{"shell": "bash"} + e := newEngine(t, agtbridge.Options{ToolAliases: aliases}) + aliases["shell"] = "unknown" + + for _, tt := range []struct { + command string + want acs.Disposition + }{ + {"printf safe", acs.Allow}, + {"echo rm -rf /", acs.Deny}, + } { + d, err := e.Decide(context.Background(), input(t, acs.StepToolCallRequest, toolCall("shell", "command", tt.command, &tt.command))) + if err != nil { + t.Fatal(err) + } + if d.Disposition != tt.want { + t.Fatalf("%q: got %+v, want %s", tt.command, d.Decision, tt.want) + } + } + + withoutAlias := newEngine(t, agtbridge.Options{}) + d, err := withoutAlias.Decide(context.Background(), input(t, acs.StepToolCallRequest, toolCall("shell", "command", "printf safe", nil))) + if err != nil { + t.Fatal(err) + } + if d.Disposition != acs.Deny || !slices.Contains(d.ReasonCodes, "runtime_error:tool_unknown") { + t.Fatalf("unregistered host tool got %+v", d.Decision) + } +} + +func TestResultRedaction(t *testing.T) { + e := newEngine(t, agtbridge.Options{}) + secret := "token ghp_abcdef123456 here" + d, err := e.Decide(context.Background(), input(t, acs.StepToolCallResult, map[string]any{ + "tool": map[string]any{"name": "Bash"}, "exit_status": "success", "outputs": []any{map[string]any{"value": secret}}, + })) + if err != nil { + t.Fatal(err) + } + if d.Disposition != acs.Modify || d.Modifications == nil || len(d.Modifications.Redactions) != 1 { + t.Fatalf("got %+v", d.Decision) + } + r := d.Modifications.Redactions[0] + if r.Path != "/outputs/0/value" || r.Replacement == nil || strings.Contains(*r.Replacement, "ghp_") || !strings.Contains(*r.Replacement, "[REDACTED]") { + t.Fatalf("redaction %s -> %v", r.Path, *r.Replacement) + } +} + +func TestNewRefusesAnUnsupportedManifest(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(dir+"/manifest.yaml", []byte("agent_control_specification_version: \"0.3.1-beta\"\nextends: [\"https://example.com/m.yaml\"]\nintervention_points: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + mapping, err := os.ReadFile(agtTree + "/mapping.yaml") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir+"/mapping.yaml", mapping, 0o600); err != nil { + t.Fatal(err) + } + if _, err := goEngine(os.DirFS(dir), "manifest.yaml", "mapping.yaml", agtbridge.Options{}); err == nil { + t.Fatal("accepted a manifest with extends") + } +} + +// TestEscalate drives AGT's escalate verdict through a policy that answers +// it, since the checked-in policy configuration never escalates. +func TestEscalate(t *testing.T) { + dir := t.TempDir() + mustWrite(t, dir, "policy/manifest.yaml", `agent_control_specification_version: "0.3.1-beta" +policies: + escalating: {type: rego, bundle: lib, query: data.escalating.verdict} +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.acs_policy_target" + tool_name_from: "$.tool_call.name" + policy: {id: escalating} +tools: + Bash: {type: Tool} +`) + mustWrite(t, dir, "policy/lib/escalating.rego", "package escalating\n\nverdict := {\"decision\": \"escalate\", \"reason\": \"approval_required\", \"message\": \"needs a human\"}\n") + mapping, err := os.ReadFile(agtTree + "/mapping.yaml") + if err != nil { + t.Fatal(err) + } + mustWrite(t, dir, "mapping.yaml", string(mapping)) + + endpoint := "https://approver.example.com" + authMethod := "mTLS" + for _, tt := range []struct { + name string + approver *acs.Approver + want acs.Disposition + }{ + {"no_approver", nil, acs.Deny}, + {"approver", &acs.Approver{Type: acs.ApproverService, ID: "security-service", Endpoint: &endpoint, Auth: &acs.ApproverAuth{Method: &authMethod}}, acs.Ask}, + } { + t.Run(tt.name, func(t *testing.T) { + e, err := goEngine(os.DirFS(dir), "policy/manifest.yaml", "mapping.yaml", agtbridge.Options{Approver: tt.approver}) + if err != nil { + t.Fatal(err) + } + if tt.approver != nil { + tt.approver.ID = "mutated" + *tt.approver.Endpoint = "https://mutated.example.com" + *tt.approver.Auth.Method = "mutated" + } + d, err := e.Decide(context.Background(), input(t, acs.StepToolCallRequest, shell("deploy"))) + if err != nil { + t.Fatal(err) + } + if d.Disposition != tt.want { + t.Fatalf("got %+v", d.Decision) + } + if tt.approver == nil { + if !slices.Contains(d.ReasonCodes, "approver_unavailable") || len(e.Policy().ApproverTypes) != 0 { + t.Fatalf("got %+v", d.Decision) + } + return + } + if d.AskDetails == nil || d.AskDetails.Approver.ID != "security-service" || d.AskDetails.Approver.Endpoint == nil || + *d.AskDetails.Approver.Endpoint != "https://approver.example.com" || d.AskDetails.Approver.Auth == nil || + d.AskDetails.Approver.Auth.Method == nil || *d.AskDetails.Approver.Auth.Method != "mTLS" || + d.AskDetails.TimeoutSeconds != 300 || d.AskDetails.Question != d.Reasoning { + t.Fatalf("ask details %+v", d.AskDetails) + } + if !slices.Equal(e.Policy().ApproverTypes, []acs.ApproverType{acs.ApproverService}) { + t.Fatalf("approver types %v", e.Policy().ApproverTypes) + } + }) + } +} + +func TestAskTimeoutUsesWholeSeconds(t *testing.T) { + for timeout, accepted := range map[time.Duration]bool{500 * time.Millisecond: false, 1500 * time.Millisecond: false, 2 * time.Second: true} { + _, err := agtbridge.New(os.DirFS(agtTree), "mapping.yaml", pointsEvaluator{agtbridge.PointPreToolCall}, agtbridge.Options{AskTimeout: timeout}) + if (err == nil) != accepted { + t.Errorf("AskTimeout %s: error %v, want accepted=%v", timeout, err, accepted) + } + } +} + +// TestNewRefusesAPointWithoutASnapshot: a manifest may configure an +// intervention point mapping.yaml sends an ACS method to, and that this +// bridge assembles no snapshot for; the engine refuses it at start rather +// than evaluating an empty snapshot. +func TestNewRefusesAPointWithoutASnapshot(t *testing.T) { + if _, err := agtbridge.New(os.DirFS(agtTree), "mapping.yaml", pointsEvaluator{agtbridge.PointPreToolCall, "input"}, agtbridge.Options{}); err == nil || !strings.Contains(err.Error(), `"input"`) { + t.Fatalf("error %v, want a refusal of input", err) + } + if _, err := agtbridge.New(os.DirFS(agtTree), "mapping.yaml", pointsEvaluator{agtbridge.PointPreToolCall, agtbridge.PointPostToolCall, "pre_model_call"}, agtbridge.Options{}); err != nil { + t.Fatalf("a governed point no ACS method reaches was refused: %v", err) + } +} + +// TestLabelsRoundTrip: the labels a verdict returns are the source labels of +// the session's next step, and warn arrives as allow with a policy +// reference. +func TestLabelsRoundTrip(t *testing.T) { + dir := t.TempDir() + mustWrite(t, dir, "policy/manifest.yaml", `agent_control_specification_version: "0.3.1-beta" +policies: + labels: {type: rego, bundle: lib, query: data.labels.verdict} +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.acs_policy_target" + tool_name_from: "$.tool_call.name" + policy: {id: labels} +tools: + Bash: {type: Tool} +`) + mustWrite(t, dir, "policy/lib/labels.rego", `package labels + +import rego.v1 + +source := input.snapshot.input.ifc.source_labels + +verdict := {"decision": "warn", "reason": sprintf("labels_%d", [count(source)]), "result_labels": array.concat(source, ["seen"])} +`) + mapping, err := os.ReadFile(agtTree + "/mapping.yaml") + if err != nil { + t.Fatal(err) + } + mustWrite(t, dir, "mapping.yaml", string(mapping)) + e, err := goEngine(os.DirFS(dir), "policy/manifest.yaml", "mapping.yaml", agtbridge.Options{}) + if err != nil { + t.Fatal(err) + } + // The engine keeps nothing itself: the labels travel as the session's + // policy state, which the Guardian stores with the chain. + var state json.RawMessage + for _, want := range []string{"labels_1", "labels_2", "labels_3"} { + in := input(t, acs.StepToolCallRequest, shell("ls")) + in.Session.PolicyState = state + d, err := e.Decide(context.Background(), in) + if err != nil { + t.Fatal(err) + } + if d.Disposition != acs.Allow || !slices.Equal(d.ReasonCodes, []string{want}) || len(d.PolicyReferences) != 1 { + t.Fatalf("got %+v, want allow with %s", d.Decision, want) + } + state = d.State + } + if d, _ := e.Decide(context.Background(), input(t, acs.StepToolCallRequest, shell("ls"))); !slices.Equal(d.ReasonCodes, []string{"labels_1"}) { + t.Fatalf("a session with no policy state saw %v", d.ReasonCodes) + } +} + +// TestEmptyLabelsLeaveSessionState: AGT omits an empty result_labels, so it +// reaches the engine as no labels and the session keeps its own. +func TestEmptyLabelsLeaveSessionState(t *testing.T) { + dir := t.TempDir() + mustWrite(t, dir, "policy/manifest.yaml", `agent_control_specification_version: "0.3.1-beta" +policies: + labels: {type: rego, bundle: lib, query: data.labels.verdict} +intervention_points: + pre_tool_call: + policy_target: "$.tool_call.args.acs_policy_target" + tool_name_from: "$.tool_call.name" + policy: {id: labels} +tools: + Bash: {type: Tool} +`) + mustWrite(t, dir, "policy/lib/labels.rego", `package labels + +import rego.v1 + +verdict := {"decision": "allow", "result_labels": []} +`) + mapping, err := os.ReadFile(agtTree + "/mapping.yaml") + if err != nil { + t.Fatal(err) + } + mustWrite(t, dir, "mapping.yaml", string(mapping)) + e, err := goEngine(os.DirFS(dir), "policy/manifest.yaml", "mapping.yaml", agtbridge.Options{}) + if err != nil { + t.Fatal(err) + } + in := input(t, acs.StepToolCallRequest, shell("ls")) + in.Session.PolicyState = json.RawMessage(`{"ifc_labels":["secret"]}`) + d, err := e.Decide(context.Background(), in) + if err != nil { + t.Fatal(err) + } + if d.State != nil { + t.Fatalf("state %s, want the session's labels unchanged", d.State) + } +} + +func mustWrite(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// A tool called through wrapped MCP meets the same AGT gates as one called +// directly, and a redaction of its result is addressed into the MCP message. +func TestWrappedMCPToolCalls(t *testing.T) { + e := newEngine(t, agtbridge.Options{}) + var state json.RawMessage + decide := func(method string, payload any) guardian.PolicyDecision { + t.Helper() + in := input(t, method, payload) + in.Session.PolicyState = state + d, err := e.Decide(context.Background(), in) + if err != nil { + t.Fatal(err) + } + if d.State != nil { + state = d.State + } + return d + } + call := func(id int, tool, command string) map[string]any { + return map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": map[string]any{"name": tool, "arguments": map[string]any{"command": command}}} + } + for _, m := range []string{"protocols/MCP/tools/call", "wrapped:mcp-2025-06-18/tools/call"} { + if d := decide(m, call(1, "Bash", "rm -rf /")); d.Disposition != acs.Deny || !slices.Contains(d.ReasonCodes, "destructive_shell_command_blocked") { + t.Fatalf("%s: a destructive command through MCP got %+v", m, d.Decision) + } + } + if d := decide("protocols/MCP/tools/call", call(2, "Write", "x")); d.Disposition != acs.Deny || !slices.Contains(d.ReasonCodes, "runtime_error:tool_unknown") { + t.Fatalf("an unregistered tool through MCP got %+v", d.Decision) + } + if d := decide("protocols/MCP/tools/call", call(3, "Bash", "ls")); d.Disposition != acs.Allow { + t.Fatalf("a benign command through MCP got %+v", d.Decision) + } + response := map[string]any{"jsonrpc": "2.0", "id": 3, "result": map[string]any{"content": []any{ + map[string]any{"type": "image", "data": "", "mimeType": "image/png"}, + map[string]any{"type": "text", "text": "token ghp_abcdef123456 here"}, + }}} + d := decide("protocols/MCP/tools/call", response) + if d.Disposition != acs.Modify || len(d.Modifications.Redactions) != 1 || d.Modifications.Redactions[0].Path != "/result/content/1/text" { + t.Fatalf("a secret in an MCP result got %+v", d.Decision) + } + // Answered once, the call is no longer pending. + in := input(t, "protocols/MCP/tools/call", response) + in.Session.PolicyState = state + if _, err := e.Decide(context.Background(), in); err == nil { + t.Fatal("a second response to the same MCP id was decided") + } + if d := decide("protocols/MCP/tools/list", map[string]any{"jsonrpc": "2.0", "id": 4, "method": "tools/list"}); d.Disposition != acs.Allow { + t.Fatalf("an MCP method AGT has no gate for got %+v", d.Decision) + } +} diff --git a/reference-implementations/agt-go/agtbridge/egress.go b/reference-implementations/agt-go/agtbridge/egress.go new file mode 100644 index 00000000..55646341 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/egress.go @@ -0,0 +1,198 @@ +package agtbridge + +import ( + "math" + "regexp" + "strconv" + "strings" + + "golang.org/x/net/idna" +) + +// EgressAnnotator is the one annotator the checked-in manifest declares, and +// the only one this bridge dispatches, as the TypeScript reference's Guardian +// does (packages/guardian/src/annotate-egress.ts). Both evaluators hand it +// to AGT as the host's annotator dispatcher. +const EgressAnnotator = "egress" + +// argumentsAGTAlreadyReads are the tool arguments egress.rego reads a +// destination from itself; when one holds a string, the annotator stands +// down so it cannot contradict AGT's own reading. +var argumentsAGTAlreadyReads = []string{"url", "endpoint", "host", "domain"} + +// asciiWhitespace is the only whitespace that ends a URL. A Unicode space +// such as U+00A0 or U+FEFF stays inside the URL, so unambiguousAuthority +// rejects it before the host ends (#268). +const asciiWhitespace = `\t\n\v\f\r ` + +var ( + destinationInCommand = regexp.MustCompile(`\bhttps?://[^` + asciiWhitespace + "'\"`;|&()<>]+") + unambiguousAuthority = regexp.MustCompile(`^https?://[A-Za-z0-9.\-]+(?::[0-9]+)?(?:[/?#]|$)`) +) + +// ambiguousDestination is the destination the annotator reports when it +// found a URL it cannot read unambiguously; no allowlist covers it, so AGT +// denies. +const ambiguousDestination = "https://unresolved.invalid" + +// AnnotateEgress returns the destination a shell command reaches, as +// {destination: origin}, or {} when the command names none or AGT reads +// the destination from an argument itself. It reads the command out of the +// preliminary policy input, as AGT hands a dispatcher the whole document. +func AnnotateEgress(preliminary map[string]any) map[string]any { + snapshot, _ := preliminary["snapshot"].(map[string]any) + toolCall, _ := snapshot["tool_call"].(map[string]any) + if toolCall == nil { + return map[string]any{} + } + args, _ := toolCall["args"].(map[string]any) + for _, name := range argumentsAGTAlreadyReads { + if _, ok := args[name].(string); ok { + return map[string]any{} + } + } + command, ok := toolCall["raw_command"].(string) + if !ok { + return map[string]any{} + } + found := destinationInCommand.FindString(command) + if found == "" { + return map[string]any{} + } + if !unambiguousAuthority.MatchString(found) { + return map[string]any{"destination": ambiguousDestination} + } + origin, ok := whatwgOrigin(found) + if !ok { + return map[string]any{"destination": ambiguousDestination} + } + return map[string]any{"destination": origin} +} + +// whatwgOrigin is new URL(u).origin from the WHATWG URL Standard, for the +// URLs unambiguousAuthority admits: an http or https scheme and a host of +// ASCII letters, digits, dots and hyphens with an optional port. It reports +// false where the URL parser throws. +func whatwgOrigin(u string) (string, bool) { + scheme, rest, _ := strings.Cut(u, "://") + end := strings.IndexAny(rest, "/?#") + if end < 0 { + end = len(rest) + } + authority := rest[:end] + host, port, hasPort := strings.Cut(authority, ":") + h, ok := whatwgHost(host) + if !ok { + return "", false + } + origin := scheme + "://" + h + if hasPort && port != "" { + n, err := strconv.ParseUint(port, 10, 64) + if err != nil || n > 65535 { + return "", false + } + if !(scheme == "http" && n == 80) && !(scheme == "https" && n == 443) { + origin += ":" + strconv.FormatUint(n, 10) + } + } + return origin, true +} + +// whatwgProfile is the URL Standard's "domain to ASCII" with beStrict false: +// UTS #46 non-transitional processing without the DNS length and hyphen +// checks. +var whatwgProfile = idna.New( + idna.MapForLookup(), + idna.Transitional(false), + idna.StrictDomainName(false), + idna.VerifyDNSLength(false), + idna.CheckHyphens(false), + idna.BidiRule(), + idna.CheckJoiners(true), +) + +// whatwgHost parses a host as the URL Standard's host parser does for a +// special scheme: domain to ASCII, then IPv4 when the host ends in a number. +func whatwgHost(host string) (string, bool) { + if host == "" { + return "", false + } + ascii, err := whatwgProfile.ToASCII(host) + if err != nil || ascii == "" { + return "", false + } + if !endsInANumber(ascii) { + return ascii, true + } + return parseIPv4(ascii) +} + +func endsInANumber(host string) bool { + parts := strings.Split(host, ".") + if parts[len(parts)-1] == "" { + if len(parts) == 1 { + return false + } + parts = parts[:len(parts)-1] + } + last := parts[len(parts)-1] + if last != "" && strings.Trim(last, "0123456789") == "" { + return true + } + _, ok := parseIPv4Number(last) + return ok +} + +// parseIPv4 is the URL Standard's IPv4 parser. +func parseIPv4(host string) (string, bool) { + parts := strings.Split(host, ".") + if parts[len(parts)-1] == "" && len(parts) > 1 { + parts = parts[:len(parts)-1] + } + if len(parts) > 4 { + return "", false + } + numbers := make([]uint64, len(parts)) + for i, p := range parts { + n, ok := parseIPv4Number(p) + if !ok { + return "", false + } + numbers[i] = n + } + for _, n := range numbers[:len(numbers)-1] { + if n > 255 { + return "", false + } + } + last := numbers[len(numbers)-1] + if last >= uint64(math.Pow(256, float64(5-len(numbers)))) { + return "", false + } + ipv4 := last + for i, n := range numbers[:len(numbers)-1] { + ipv4 += n << (8 * (3 - i)) + } + return strconv.FormatUint(ipv4>>24, 10) + "." + strconv.FormatUint(ipv4>>16&255, 10) + "." + + strconv.FormatUint(ipv4>>8&255, 10) + "." + strconv.FormatUint(ipv4&255, 10), true +} + +// parseIPv4Number parses one part: 0x hexadecimal, a leading 0 octal, +// otherwise decimal. +func parseIPv4Number(s string) (uint64, bool) { + if s == "" { + return 0, false + } + base := 10 + switch { + case len(s) >= 2 && (s[:2] == "0x" || s[:2] == "0X"): + s, base = s[2:], 16 + case len(s) >= 2 && s[0] == '0': + s, base = s[1:], 8 + } + if s == "" { + return 0, true + } + n, err := strconv.ParseUint(s, base, 64) + return n, err == nil +} diff --git a/reference-implementations/agt-go/agtbridge/egress_internal_test.go b/reference-implementations/agt-go/agtbridge/egress_internal_test.go new file mode 100644 index 00000000..692a17e1 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/egress_internal_test.go @@ -0,0 +1,46 @@ +package agtbridge + +import "testing" + +func TestWhatwgOrigin(t *testing.T) { + tests := map[string]string{ + "https://docs.example.com/x": "https://docs.example.com", + "http://DOCS.example.com:80/": "http://docs.example.com", + "https://docs.example.com:443": "https://docs.example.com", + "https://docs.example.com:0443/": "https://docs.example.com", + "https://docs.example.com:8443/": "https://docs.example.com:8443", + "http://0x7f.1/": "http://127.0.0.1", + "http://0177.0.0.1/": "http://127.0.0.1", + "http://2130706433/": "http://127.0.0.1", + "http://1.2.3/": "http://1.2.0.3", + "https://docs.example.com./": "https://docs.example.com.", + } + for in, want := range tests { + if got, ok := whatwgOrigin(in); !ok || got != want { + t.Errorf("%s: got %q, %v; want %q", in, got, ok, want) + } + } + for _, in := range []string{"https://docs.example.com:70000/", "http://256.1.1.1.1/", "http://1.2.3.4.5/", "https://xn--zz.example.net/", "http://999.1.1.1/"} { + if got, ok := whatwgOrigin(in); ok { + t.Errorf("%s: parsed as %q, want a failure", in, got) + } + } +} + +func TestAnnotateEgressReadsOnlyASCIIWhitespaceAsURLEnd(t *testing.T) { + annotate := func(command string) string { + preliminary := map[string]any{"snapshot": map[string]any{"tool_call": map[string]any{"raw_command": command, "args": map[string]any{}}}} + return AnnotateEgress(preliminary)["destination"].(string) + } + tests := map[string]string{ + "curl https://docs.example.com/x": "https://docs.example.com", + "curl https://docs.example.com\tx": "https://docs.example.com", + "curl https://docs.example.com\u00a0@evil.invalid/x": ambiguousDestination, + "curl https://docs.example.com\ufeff@evil.invalid/x": ambiguousDestination, + } + for command, want := range tests { + if got := annotate(command); got != want { + t.Errorf("%q: destination %q, want %q", command, got, want) + } + } +} diff --git a/reference-implementations/agt-go/agtbridge/evaluator.go b/reference-implementations/agt-go/agtbridge/evaluator.go new file mode 100644 index 00000000..4a1b4290 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/evaluator.go @@ -0,0 +1,48 @@ +package agtbridge + +import "context" + +// The two intervention points whose snapshot this bridge assembles, the +// ones the TypeScript reference assembles. +const ( + PointPreToolCall = "pre_tool_call" + PointPostToolCall = "post_tool_call" +) + +// Evaluator executes AGT's policy for the projection. Two exist: the Go +// evaluator (package goeval) and the AGT evaluator, which calls AGT's own +// runtime library (package agteval, build tag agteval). +type Evaluator interface { + // Governs reports whether the manifest configures the intervention + // point. + Governs(point string) bool + + // Evaluate is AGT's evaluate_intervention_point in enforce mode for a + // snapshot this bridge assembled. A failure inside AGT's evaluation is + // a fail-closed deny verdict, never an error. An error means the + // evaluation could not run, or ctx ended before it returned; the + // Guardian answers either as an engine failure. + Evaluate(ctx context.Context, point string, snapshot map[string]any) (Verdict, error) + + // AGTVersion is the AGT commit whose runtime the evaluator executes. + AGTVersion() string +} + +// Verdict is AGT's verdict (policy-engine/core/src/verdict.rs) as its C ABI +// serializes it. AGT omits an empty result_labels, so ResultLabels is +// either absent or non-empty; evidence is not carried onto the wire, as the +// TypeScript reference does not carry it. +type Verdict struct { + Decision string `json:"decision"` + Reason *string `json:"reason,omitempty"` + Message *string `json:"message,omitempty"` + Transform *Transform `json:"transform,omitempty"` + ResultLabels []string `json:"result_labels,omitempty"` +} + +// Transform is a transform verdict's single replacement inside the policy +// target. +type Transform struct { + Path string `json:"path"` + Value any `json:"value"` +} diff --git a/reference-implementations/agt-go/agtbridge/evaluators_test.go b/reference-implementations/agt-go/agtbridge/evaluators_test.go new file mode 100644 index 00000000..4329838e --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/evaluators_test.go @@ -0,0 +1,135 @@ +//go:build agteval + +package agtbridge_test + +import ( + "context" + "fmt" + "runtime" + "slices" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge/agteval" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +// knownDifference is a recorded step the two evaluators decide differently +// while they execute the given pair of AGT versions, with the reason. +// docs/differences.md describes each entry. +type knownDifference struct { + GoVersion, AGTVersion string + Case string + Step int + Reason string +} + +// knownDifferences is empty while both evaluators execute AGT 81955d4: any +// difference is a defect in one of them until it is explained here. +var knownDifferences []knownDifference + +func init() { + recordedEvaluators["agt"] = func(t testing.TB) agtbridge.Evaluator { return agtEvaluator(t) } +} + +func agtEvaluator(t testing.TB) *agteval.Evaluator { + t.Helper() + e, err := agteval.New(agteval.Config{ManifestPath: agtTree + "/policy/manifest.yaml", OPAPath: "../.acs/agt/" + runtime.GOOS + "-" + runtime.GOARCH + "/bin", OPATimeout: 5 * time.Second}) + if err != nil { + t.Fatal(err) + } + return e +} + +// TestEvaluatorsAgree runs every recorded case through a Guardian over each +// evaluator and fails on a step they decide differently that +// knownDifferences does not list for their pair of AGT versions, and on a +// listed difference that no longer occurs. +func TestEvaluatorsAgree(t *testing.T) { + signer := testSigner(t) + native := agtEvaluator(t) + reproduced := goEvaluator(t) + guardians := map[agtbridge.Evaluator]*guardian.Guardian{} + for _, evaluator := range []agtbridge.Evaluator{native, reproduced} { + g, err := guardian.New(guardian.Config{Engine: engineOver(t, evaluator), Signer: signer}) + if err != nil { + t.Fatal(err) + } + guardians[evaluator] = g + } + listed := map[string]knownDifference{} + for _, d := range knownDifferences { + if d.GoVersion == reproduced.AGTVersion() && d.AGTVersion == native.AGTVersion() { + listed[fmt.Sprintf("%s/%d", d.Case, d.Step)] = d + } + } + seen := map[string]bool{} + before := native.Calls() + for _, c := range readRecording(t).Cases { + t.Run(c.Name, func(t *testing.T) { + agtDecisions := replay(t, guardians[native], signer, c) + goDecisions := replay(t, guardians[reproduced], signer, c) + for i := range c.Steps { + key := fmt.Sprintf("%s/%d", c.Name, i) + d := difference(agtDecisions[i], goDecisions[i]) + if d == "" { + continue + } + seen[key] = true + if _, ok := listed[key]; !ok { + t.Errorf("step %d: the Go evaluator (AGT %s) differs from the AGT evaluator (AGT %s): %s", i, reproduced.AGTVersion(), native.AGTVersion(), d) + } + } + }) + } + for key := range listed { + if !seen[key] { + t.Errorf("knownDifferences lists %s, where the evaluators now agree", key) + } + } + if native.Calls() == before { + t.Fatal("no recorded case reached AGT's runtime library") + } +} + +// BenchmarkEvaluators reports each evaluator's median decision time over +// the recorded tool-call steps that decide without an engine failure, +// through the engine. +func BenchmarkEvaluators(b *testing.B) { + reproduced := goEvaluator(b) + screen := engineOver(b, reproduced) + var inputs []guardian.PolicyInput + for _, c := range readRecording(b).Cases { + for _, step := range c.Steps { + if step.Method != acs.StepToolCallRequest && step.Method != acs.StepToolCallResult { + continue + } + in := guardian.PolicyInput{Request: acs.Request{ + Method: step.Method, + Params: acs.Params{RequestID: "6a22a0f7-5547-448a-add7-3327aed144df", Payload: step.Payload}, + }} + if _, err := screen.Decide(context.Background(), in); err == nil { + inputs = append(inputs, in) + } + } + } + for name, evaluator := range map[string]agtbridge.Evaluator{"agt": agtEvaluator(b), "go": reproduced} { + b.Run(name, func(b *testing.B) { + engine := engineOver(b, evaluator) + var durations []time.Duration + i := 0 + for b.Loop() { + start := time.Now() + if _, err := engine.Decide(context.Background(), inputs[i%len(inputs)]); err != nil { + b.Fatal(err) + } + durations = append(durations, time.Since(start)) + i++ + } + slices.Sort(durations) + b.ReportMetric(float64(durations[len(durations)/2].Microseconds()), "median-µs/decision") + }) + } +} diff --git a/reference-implementations/agt-go/agtbridge/goeval/dialects_internal_test.go b/reference-implementations/agt-go/agtbridge/goeval/dialects_internal_test.go new file mode 100644 index 00000000..fe5cdb1e --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/goeval/dialects_internal_test.go @@ -0,0 +1,84 @@ +package goeval + +import ( + "os" + "strconv" + "strings" + "testing" + + "go.yaml.in/yaml/v3" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" +) + +// TestPathDialectsAgree checks the two places the reference's manifest and +// mapping.yaml name one leaf in two notations: the request gate's policy +// target is the leaf the snapshot writes, and the result gate's policy target +// and its redaction pointer address the same field. Every tool mapping.yaml +// names an argument for is one the manifest registers. +func TestPathDialectsAgree(t *testing.T) { + src, err := os.ReadFile("../../../agt/policy/manifest.yaml") + if err != nil { + t.Fatal(err) + } + m, err := parseManifest(src) + if err != nil { + t.Fatal(err) + } + points, err := m.compile() + if err != nil { + t.Fatal(err) + } + src, err = os.ReadFile("../../../agt/mapping.yaml") + if err != nil { + t.Fatal(err) + } + var mp struct { + InterventionPoints map[string]struct { + PolicyTargetArgument *struct { + ByTool map[string]string `yaml:"by_tool"` + } `yaml:"policy_target_argument"` + Modifications *struct { + Into string `yaml:"into"` + IntoPath string `yaml:"into_path"` + } `yaml:"modifications"` + } `yaml:"intervention_points"` + } + if err := yaml.Unmarshal(src, &mp); err != nil { + t.Fatal(err) + } + if got := points[agtbridge.PointPreToolCall].targetRaw; got != "$.tool_call.args."+agtbridge.PolicyTargetLeaf { + t.Errorf("pre_tool_call targets %s, not the leaf the snapshot writes", got) + } + for name, row := range mp.InterventionPoints { + if row.Modifications == nil || row.Modifications.Into != "redactions" { + continue + } + p := points[name] + if len(p.target.segments) < 2 || p.target.segments[0].field != "tool_result" { + t.Fatalf("%s: target %s is not in the tool result", name, p.targetRaw) + } + var pointer strings.Builder + for _, s := range p.target.segments[1:] { + pointer.WriteByte('/') + if s.isIdx { + pointer.WriteString(strconv.Itoa(s.index)) + } else { + pointer.WriteString(s.field) + } + } + if pointer.String() != row.Modifications.IntoPath { + t.Errorf("%s: the manifest targets %s and mapping.yaml redacts %s", name, pointer.String(), row.Modifications.IntoPath) + } + } + for _, row := range mp.InterventionPoints { + if row.PolicyTargetArgument == nil { + continue + } + for tool := range row.PolicyTargetArgument.ByTool { + if _, ok := m.Tools[tool]; !ok { + t.Errorf("mapping.yaml names tool %s, which the manifest does not register", tool) + } + } + } +} diff --git a/reference-implementations/agt-go/agtbridge/goeval/goeval.go b/reference-implementations/agt-go/agtbridge/goeval/goeval.go new file mode 100644 index 00000000..2ca69b9a --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/goeval/goeval.go @@ -0,0 +1,70 @@ +// Package goeval is the Go evaluator: it executes AGT's policy for agtbridge +// without AGT's runtime library, so the Guardian builds and runs without cgo. +// +// It evaluates AGT's policy bundle, unchanged, with OPA's Go library in +// process, and reproduces in Go the part of AGT's runtime +// (policy-engine/core at AGTVersion) that the reference's manifest uses: +// assembling the policy input from a snapshot at the pre_tool_call and +// post_tool_call intervention points, the annotator dispatch, and +// normalising the Rego result into a verdict, including AGT's fail-closed +// runtime_error denials. A manifest feature outside that part is refused +// when the evaluator is built. AGT's runtime library, which the AGT +// evaluator calls, is the authority; the comparison test in agtbridge +// reports every case where the two disagree. +package goeval + +import ( + "context" + "fmt" + "io/fs" + "path" + + "github.com/open-policy-agent/opa/v1/rego" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" +) + +// AGTVersion is the AGT commit whose runtime this package reproduces, the +// commit the TypeScript reference's agt.lock pins. +const AGTVersion = "81955d48025c6b11deb3fc9dabf89f74f4145775" + +// Evaluator is the Go evaluator. +type Evaluator struct { + manifest *manifest + points map[string]*compiledPoint + queries map[string]rego.PreparedEvalQuery // by intervention point +} + +var _ agtbridge.Evaluator = (*Evaluator)(nil) + +// New builds the evaluator from the manifest at manifestPath in fsys, +// compiling the manifest's Rego bundle once. The bundle is found relative +// to the manifest's directory, as AGT resolves it. +func New(ctx context.Context, fsys fs.FS, manifestPath string) (*Evaluator, error) { + src, err := fs.ReadFile(fsys, manifestPath) + if err != nil { + return nil, fmt.Errorf("goeval: %w", err) + } + m, err := parseManifest(src) + if err != nil { + return nil, fmt.Errorf("goeval: %s: %w", manifestPath, err) + } + points, err := m.compile() + if err != nil { + return nil, fmt.Errorf("goeval: %s: %w", manifestPath, err) + } + e := &Evaluator{manifest: m, points: points, queries: map[string]rego.PreparedEvalQuery{}} + if err := e.prepare(ctx, fsys, path.Dir(manifestPath)); err != nil { + return nil, fmt.Errorf("goeval: %s: %w", manifestPath, err) + } + return e, nil +} + +// Governs implements agtbridge.Evaluator. +func (e *Evaluator) Governs(point string) bool { + _, ok := e.points[point] + return ok +} + +// AGTVersion implements agtbridge.Evaluator. +func (e *Evaluator) AGTVersion() string { return AGTVersion } diff --git a/reference-implementations/agt-go/agtbridge/goeval/goeval_test.go b/reference-implementations/agt-go/agtbridge/goeval/goeval_test.go new file mode 100644 index 00000000..a08ddcf9 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/goeval/goeval_test.go @@ -0,0 +1,29 @@ +package goeval_test + +import ( + "os" + "testing" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge/goeval" +) + +// TestAGTVersionIsTheTypeScriptPin: the Go evaluator reproduces the AGT +// commit whose policy bundle the TypeScript reference vendors, so moving +// that pin fails here until the reproduction is checked against it. +func TestAGTVersionIsTheTypeScriptPin(t *testing.T) { + raw, err := os.ReadFile("../../../agt/agt.lock") + if err != nil { + t.Fatal(err) + } + var lock struct { + AGTRef string `json:"agt_ref"` + } + if err := jsonv2.Unmarshal(raw, &lock); err != nil { + t.Fatal(err) + } + if goeval.AGTVersion != lock.AGTRef { + t.Fatalf("goeval reproduces AGT %s, and agt.lock pins %s", goeval.AGTVersion, lock.AGTRef) + } +} diff --git a/reference-implementations/agt-go/agtbridge/goeval/manifest.go b/reference-implementations/agt-go/agtbridge/goeval/manifest.go new file mode 100644 index 00000000..313af5d0 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/goeval/manifest.go @@ -0,0 +1,243 @@ +package goeval + +import ( + "bytes" + "errors" + "fmt" + "slices" + "strings" + + jsonv2 "github.com/go-json-experiment/json" + "go.yaml.in/yaml/v3" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" +) + +// supportedManifestVersions is AGT's manifest_version::SUPPORTED at the +// agt.lock commit (policy-engine/core/src/constants.rs). +var supportedManifestVersions = []string{"0.3.1-beta", "0.3.1-beta-agt", "0.3.0-alpha", "0.3.0-alpha-agt"} + +// knownPoints are AGT's eight intervention points. +var knownPoints = []string{"agent_startup", "input", "pre_model_call", "post_model_call", agtbridge.PointPreToolCall, agtbridge.PointPostToolCall, "output", "agent_shutdown"} + +// manifest is the part of AGT's manifest grammar +// (policy-engine/core/src/manifest.rs) the Go evaluator reproduces. Every other +// feature is refused when the manifest loads. +type manifest struct { + Version string `yaml:"agent_control_specification_version"` + Metadata any `yaml:"metadata"` + Extends []any `yaml:"extends"` + Policies map[string]regoPolicy `yaml:"policies"` + InterventionPoints map[string]pointConfig `yaml:"intervention_points"` + Tools map[string]map[string]any `yaml:"tools"` + Annotators map[string]map[string]any `yaml:"annotators"` + Approval any `yaml:"approval"` +} + +type regoPolicy struct { + Type string `yaml:"type"` + Query string `yaml:"query"` + Bundle string `yaml:"bundle"` + Other map[string]any `yaml:",inline"` +} + +type pointConfig struct { + PolicyTarget string `yaml:"policy_target"` + PolicyTargetKind *string `yaml:"policy_target_kind"` + ToolNameFrom *string `yaml:"tool_name_from"` + Annotations map[string]map[string]any `yaml:"annotations"` + Policy struct { + ID string `yaml:"id"` + Query string `yaml:"query"` + Other map[string]any `yaml:",inline"` + } `yaml:"policy"` +} + +// compiledPoint is an intervention point with its paths parsed. +type compiledPoint struct { + name string + target jsonPath + targetRaw string + targetKind *string + toolNameFrom *jsonPath + annotations []compiledAnnotation // sorted by annotator name, as AGT iterates them + policyID string + query string +} + +type compiledAnnotation struct { + name string + from jsonPath + // config is what the dispatcher receives: the annotator's type and + // fields, then from, then the annotation's own fields. + config map[string]any +} + +// errManifestInvalid is AGT's runtime_error:manifest_invalid. +var errManifestInvalid = errors.New("runtime_error:manifest_invalid") + +func parseManifest(src []byte) (*manifest, error) { + dec := yaml.NewDecoder(bytes.NewReader(src)) + dec.KnownFields(true) + var m manifest + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("%w: %v", errManifestInvalid, err) + } + return &m, nil +} + +// compile validates the manifest as AGT's validate_point_config does, and +// refuses every feature outside what the Go evaluator reproduces. +func (m *manifest) compile() (map[string]*compiledPoint, error) { + invalid := func(format string, args ...any) error { + return fmt.Errorf("%w: "+format, append([]any{errManifestInvalid}, args...)...) + } + unsupported := func(what string) error { + return fmt.Errorf("the manifest uses %s, which the Go evaluator does not implement", what) + } + if !slices.Contains(supportedManifestVersions, m.Version) { + return nil, invalid("agent_control_specification_version %q is not supported", m.Version) + } + switch { + case len(m.Extends) > 0: + return nil, unsupported("extends") + case m.Approval != nil: + return nil, unsupported("an approval section") + case len(m.InterventionPoints) == 0: + return nil, invalid("at least one intervention point config is required") + } + for id, p := range m.Policies { + switch { + case p.Type != "rego": + return nil, unsupported(fmt.Sprintf("policy %q of type %q", id, p.Type)) + case len(p.Other) > 0: + return nil, unsupported(fmt.Sprintf("policy %q with fields beyond query and bundle", id)) + case p.Bundle == "": + return nil, unsupported(fmt.Sprintf("policy %q without a local bundle", id)) + } + } + for name, a := range m.Annotators { + if a["type"] != "classifier" { + return nil, unsupported(fmt.Sprintf("annotator %q of type %v", name, a["type"])) + } + if name != agtbridge.EgressAnnotator { + return nil, unsupported(fmt.Sprintf("annotator %q, for which this Guardian has no dispatcher", name)) + } + } + points := map[string]*compiledPoint{} + for name, cfg := range m.InterventionPoints { + if !slices.Contains(knownPoints, name) { + return nil, invalid("unknown intervention point %q", name) + } + if name != agtbridge.PointPreToolCall && name != agtbridge.PointPostToolCall { + return nil, unsupported(fmt.Sprintf("intervention point %q, whose snapshot agtbridge does not assemble", name)) + } + cp, err := m.compilePoint(name, cfg) + if err != nil { + return nil, err + } + points[name] = cp + } + return points, nil +} + +func (m *manifest) compilePoint(name string, cfg pointConfig) (*compiledPoint, error) { + invalid := func(format string, args ...any) error { + return fmt.Errorf("%w: intervention point %s: "+format, append([]any{errManifestInvalid, name}, args...)...) + } + cp := &compiledPoint{name: name, targetRaw: cfg.PolicyTarget, targetKind: cfg.PolicyTargetKind, policyID: cfg.Policy.ID} + if strings.TrimSpace(cfg.PolicyTarget) == "" { + return nil, invalid("policy_target is required") + } + target, err := parseSnapshotPath(cfg.PolicyTarget) + if err != nil || target.root != rootSnap { + return nil, invalid("policy_target %q must be a snapshot path", cfg.PolicyTarget) + } + cp.target = target + if cfg.PolicyTargetKind != nil && strings.TrimSpace(*cfg.PolicyTargetKind) == "" { + return nil, invalid("policy_target_kind is empty") + } + if cfg.ToolNameFrom != nil { + p, err := parseSnapshotPath(*cfg.ToolNameFrom) + if err != nil || p.root != rootSnap { + return nil, invalid("tool_name_from %q must be a snapshot path", *cfg.ToolNameFrom) + } + cp.toolNameFrom = &p + } + for _, annotator := range sortedKeys(cfg.Annotations) { + fields := cfg.Annotations[annotator] + global, ok := m.Annotators[annotator] + if !ok { + return nil, invalid("annotation %q names no declared annotator", annotator) + } + if _, shadow := fields["annotator"]; shadow { + return nil, invalid("annotation %q carries an annotator field", annotator) + } + from, _ := fields["from"].(string) + if strings.TrimSpace(from) == "" { + return nil, invalid("annotation %q requires from", annotator) + } + p, err := parseSnapshotPath(from) + if err != nil || p.referencesPIAnnotations() { + return nil, invalid("annotation %q has an invalid from %q", annotator, from) + } + config := map[string]any{} + for k, v := range global { + config[k] = v + } + config["from"] = from + for k, v := range fields { + config[k] = v + } + cp.annotations = append(cp.annotations, compiledAnnotation{name: annotator, from: p, config: config}) + } + policy, ok := m.Policies[cfg.Policy.ID] + switch { + case strings.TrimSpace(cfg.Policy.ID) == "" || !ok: + return nil, invalid("references unknown policy %q", cfg.Policy.ID) + case len(cfg.Policy.Other) > 0: + return nil, fmt.Errorf("intervention point %s binds adapter fields, which the Go evaluator does not implement", name) + } + cp.query = cfg.Policy.Query + if cp.query == "" { + cp.query = policy.Query + } + if cp.query == "" { + return nil, invalid("the rego policy %q has no query", cfg.Policy.ID) + } + return cp, nil +} + +// projectedTool is AGT's ToolConfig::to_projected_value: the entry's fields +// with name set to the registry key. +func (m *manifest) projectedTool(name string) (map[string]any, bool) { + fields, ok := m.Tools[name] + if !ok { + return nil, false + } + out := map[string]any{} + for k, v := range fields { + out[k] = v + } + out["name"] = name + return out, true +} + +// asJSON converts a YAML-decoded value to the shapes a JSON decoder yields. +func asJSON(v any) (any, error) { + b, err := jsonv2.Marshal(v) + if err != nil { + return nil, err + } + var out any + return out, jsonv2.Unmarshal(b, &out) +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/reference-implementations/agt-go/agtbridge/goeval/paths.go b/reference-implementations/agt-go/agtbridge/goeval/paths.go new file mode 100644 index 00000000..f1782339 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/goeval/paths.go @@ -0,0 +1,208 @@ +package goeval + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +// The path dialect of AGT's manifest (policy-engine/core/src/paths.rs): a +// root, then a flat sequence of .field, ["field"] and [index] segments. No +// wildcard, recursive descent, slice, filter or negative index exists. + +type pathRoot int + +const ( + rootSnap pathRoot = iota + rootPI + rootPolicyTarget + rootTool +) + +var rootNames = []struct { + name string + root pathRoot +}{ + // Longest first, so $policy_target is not read as $pi. + {"$policy_target", rootPolicyTarget}, + {"$snap", rootSnap}, + {"$tool", rootTool}, + {"$pi", rootPI}, +} + +type segment struct { + field string + index int + isIdx bool +} + +type jsonPath struct { + original string + root pathRoot + segments []segment +} + +// parseSnapshotPath parses a path in which $ and $. stand for $snap, as +// policy_target, tool_name_from and an annotation's from are written. +func parseSnapshotPath(s string) (jsonPath, error) { + aliased := s + switch { + case s == "$": + aliased = "$snap" + case strings.HasPrefix(s, "$."): + aliased = "$snap." + s[2:] + } + p, err := parsePath(aliased) + p.original = s + return p, err +} + +// parsePath parses a path with an explicit root, as a transform's path is +// written. +func parsePath(s string) (jsonPath, error) { + p := jsonPath{original: s} + rest := "" + found := false + for _, r := range rootNames { + if after, ok := strings.CutPrefix(s, r.name); ok && (after == "" || after[0] == '.' || after[0] == '[') { + p.root, rest, found = r.root, after, true + break + } + } + if !found { + return p, fmt.Errorf("path %q has an unknown root", s) + } + for rest != "" { + switch rest[0] { + case '.': + rest = rest[1:] + end := strings.IndexAny(rest, ".[") + if end < 0 { + end = len(rest) + } + field := rest[:end] + if field == "" || strings.Contains(field, "]") { + return p, fmt.Errorf("path %q has an invalid field segment", s) + } + p.segments = append(p.segments, segment{field: field}) + rest = rest[end:] + case '[': + end := strings.IndexByte(rest, ']') + if len(rest) > 1 && rest[1] == '"' { + end = closingQuote(rest) + } + if end < 0 { + return p, fmt.Errorf("path %q has an unclosed bracket", s) + } + inner := rest[1:end] + rest = rest[end+1:] + if strings.HasPrefix(inner, `"`) { + var field string + if err := json.Unmarshal([]byte(inner), &field); err != nil { + return p, fmt.Errorf("path %q has an invalid quoted field", s) + } + p.segments = append(p.segments, segment{field: field}) + continue + } + if strings.HasPrefix(inner, "-") { + return p, fmt.Errorf("path %q has a negative index", s) + } + n, err := strconv.Atoi(inner) + if err != nil || n < 0 { + return p, fmt.Errorf("path %q has an invalid index", s) + } + p.segments = append(p.segments, segment{index: n, isIdx: true}) + default: + return p, fmt.Errorf("path %q is malformed", s) + } + } + return p, nil +} + +// closingQuote returns the index of the ] that closes a ["..."] segment +// starting at s[0], honouring escapes inside the quoted field. +func closingQuote(s string) int { + for i := 2; i < len(s); i++ { + switch s[i] { + case '\\': + i++ + case '"': + if i+1 < len(s) && s[i+1] == ']' { + return i + 1 + } + return -1 + } + } + return -1 +} + +// pathEnv holds the documents a path may start from. +type pathEnv struct { + snap any + pi map[string]any +} + +// Errors of resolving a path, each an AGT runtime_error reason. +var ( + errPathMissing = errors.New("runtime_error:path_missing") + errPathTypeMismatch = errors.New("runtime_error:path_type_mismatch") +) + +// resolve reads the value p addresses. +func (p jsonPath) resolve(env pathEnv) (any, error) { + var node any + switch p.root { + case rootSnap: + node = env.snap + case rootPI: + if env.pi == nil { + return nil, fmt.Errorf("%w: root not available for %s", errPathMissing, p.original) + } + node = env.pi + case rootPolicyTarget: + target, _ := env.pi["policy_target"].(map[string]any) + if target == nil { + return nil, fmt.Errorf("%w: root not available for %s", errPathMissing, p.original) + } + node = target["value"] + case rootTool: + tool, ok := env.pi["tool"] + if !ok { + return nil, fmt.Errorf("%w: root not available for %s", errPathMissing, p.original) + } + node = tool + } + for _, s := range p.segments { + switch n := node.(type) { + case map[string]any: + if s.isIdx { + return nil, fmt.Errorf("%w: %s", errPathTypeMismatch, p.original) + } + v, ok := n[s.field] + if !ok { + return nil, fmt.Errorf("%w: %s", errPathMissing, p.original) + } + node = v + case []any: + if !s.isIdx { + return nil, fmt.Errorf("%w: %s", errPathTypeMismatch, p.original) + } + if s.index >= len(n) { + return nil, fmt.Errorf("%w: %s", errPathMissing, p.original) + } + node = n[s.index] + default: + return nil, fmt.Errorf("%w: %s", errPathTypeMismatch, p.original) + } + } + return node, nil +} + +// referencesPIAnnotations reports whether p reads the annotations member of +// the policy input, which does not exist when an annotation's from is +// checked. +func (p jsonPath) referencesPIAnnotations() bool { + return p.root == rootPI && len(p.segments) > 0 && !p.segments[0].isIdx && p.segments[0].field == "annotations" +} diff --git a/reference-implementations/agt-go/agtbridge/goeval/runtime.go b/reference-implementations/agt-go/agtbridge/goeval/runtime.go new file mode 100644 index 00000000..d97c991f --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/goeval/runtime.go @@ -0,0 +1,536 @@ +package goeval + +import ( + "context" + "errors" + "fmt" + "io/fs" + "strings" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/open-policy-agent/opa/v1/bundle" + "github.com/open-policy-agent/opa/v1/rego" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" +) + +// AGT's resource limits (policy-engine/core/src/limits.rs, Limits::default). +const ( + maxDepth = 64 + maxSnapshotBytes = 1 << 20 + maxAnnotatorsPerPoint = 16 + maxAnnotatorOutputBytes = 256 << 10 + maxPolicyOutputBytes = 256 << 10 +) + +// AGT's runtime_error reasons (policy-engine/core/src/error.rs) this path +// can produce. +const ( + reasonInterventionPointUnknown = "runtime_error:intervention_point_unknown" + reasonToolUnknown = "runtime_error:tool_unknown" + reasonAnnotationFailed = "runtime_error:annotation_failed" + reasonPolicyInvocationFailed = "runtime_error:policy_invocation_failed" + reasonPolicyOutputInvalid = "runtime_error:policy_output_invalid" + reasonResourceLimitExceeded = "runtime_error:resource_limit_exceeded" + reasonTransformTargetForbidden = "runtime_error:transform_target_forbidden" + reasonTransformInvalid = "runtime_error:transform_invalid" + runtimeErrorPrefix = "runtime_error:" +) + +// runtimeErrorMessage is the message of every verdict AGT builds from a +// runtime error (policy-engine/core/src/verdict.rs, Verdict::runtime_error). +const runtimeErrorMessage = "Request blocked by Agent Control Specification." + +// failure is a runtime error on the evaluation path: AGT answers it with a +// fail-closed deny, never an exception. +type failure struct { + reason string + detail string +} + +func (f *failure) Error() string { return f.reason + ": " + f.detail } + +func fail(reason, format string, args ...any) *failure { + return &failure{reason: reason, detail: fmt.Sprintf(format, args...)} +} + +func (f *failure) verdict() agtbridge.Verdict { + message := runtimeErrorMessage + if f.reason == reasonAnnotationFailed && f.detail != "" { + message += " " + f.detail + } + reason := f.reason + return agtbridge.Verdict{Decision: "deny", Reason: &reason, Message: &message} +} + +// prepare prepares one OPA query per intervention point over the bundle +// each binds. manifestDir is the manifest's directory within fsys; AGT +// resolves a bundle relative to it. +func (r *Evaluator) prepare(ctx context.Context, fsys fs.FS, manifestDir string) error { + m := r.manifest + bundles := map[string]*bundle.Bundle{} + for name, p := range r.points { + dir := joinFS(manifestDir, m.Policies[p.policyID].Bundle) + b, ok := bundles[dir] + if !ok { + var err error + b, err = readBundle(fsys, dir) + if err != nil { + return err + } + bundles[dir] = b + } + q, err := rego.New(rego.Query(p.query), rego.ParsedBundle(dir, b)).PrepareForEval(ctx) + if err != nil { + return fmt.Errorf("prepare %s at %s: %w", p.query, name, err) + } + r.queries[name] = q + } + return nil +} + +// joinFS joins a manifest-relative bundle path the way AGT's +// resolve_relative_string does, without cleaning it: a "./" segment stays, +// as AGT's Path::join keeps it. +func joinFS(dir, bundle string) string { + if dir == "." || dir == "" { + return bundle + } + return dir + "/" + bundle +} + +func readBundle(fsys fs.FS, dir string) (*bundle.Bundle, error) { + if strings.Contains("/"+dir+"/", "/./") { + return nil, fmt.Errorf("bundle path %q contains a ./ segment, which makes OPA drop the bundle's data document and so disables every rule", dir) + } + sub, err := fs.Sub(fsys, dir) + if err != nil { + return nil, err + } + loader, err := bundle.NewFSLoader(sub) + if err != nil { + return nil, err + } + b, err := bundle.NewCustomReader(loader).Read() + if err != nil { + return nil, fmt.Errorf("read bundle %s: %w", dir, err) + } + return &b, nil +} + +// Evaluate implements agtbridge.Evaluator: every failure is a fail-closed +// verdict, never an error. An error is returned only for a cancelled or +// expired context. +func (r *Evaluator) Evaluate(ctx context.Context, point string, snapshot map[string]any) (agtbridge.Verdict, error) { + v, f := r.evaluate(ctx, point, snapshot) + if ctx.Err() != nil { + return agtbridge.Verdict{}, ctx.Err() + } + if f != nil { + return f.verdict(), nil + } + return v, nil +} + +func (r *Evaluator) evaluate(ctx context.Context, point string, snapshot map[string]any) (agtbridge.Verdict, *failure) { + p, ok := r.points[point] + if !ok { + return agtbridge.Verdict{}, fail(reasonInterventionPointUnknown, "%s", point) + } + if f := checkSize(snapshot, maxSnapshotBytes, reasonResourceLimitExceeded, "snapshot"); f != nil { + return agtbridge.Verdict{}, f + } + env := pathEnv{snap: snapshot} + target, err := p.target.resolve(env) + if err != nil { + return agtbridge.Verdict{}, pathFailure(err) + } + tool, f := r.projectTool(p, env) + if f != nil { + return agtbridge.Verdict{}, f + } + pi := policyInput(p, target, snapshot, map[string]any{}, tool) + if f := checkDepth(pi, reasonResourceLimitExceeded, "policy input"); f != nil { + return agtbridge.Verdict{}, f + } + annotations, f := r.annotate(p, pi, snapshot) + if f != nil { + return agtbridge.Verdict{}, f + } + pi = policyInput(p, target, snapshot, annotations, tool) + if f := checkDepth(pi, reasonResourceLimitExceeded, "policy input"); f != nil { + return agtbridge.Verdict{}, f + } + + output, f := r.query(ctx, point, pi) + if f != nil { + return agtbridge.Verdict{}, f + } + if f := checkSize(output, maxPolicyOutputBytes, reasonResourceLimitExceeded, "policy output"); f != nil { + return agtbridge.Verdict{}, f + } + v, f := normalize(output) + if f != nil { + return agtbridge.Verdict{}, f + } + if v.Transform != nil { + if f := applyTransform(p, snapshot, target, *v.Transform); f != nil { + return agtbridge.Verdict{}, f + } + } + return v, nil +} + +// policyInput is AGT's five-member policy input document +// (policy-engine/core/src/policy_input.rs). +func policyInput(p *compiledPoint, target any, snapshot map[string]any, annotations map[string]any, tool any) map[string]any { + var kind any + if p.targetKind != nil { + kind = *p.targetKind + } + return map[string]any{ + "intervention_point": p.name, + "policy_target": map[string]any{"kind": kind, "path": p.targetRaw, "value": target}, + "snapshot": snapshot, + "annotations": annotations, + "tool": tool, + } +} + +func (r *Evaluator) projectTool(p *compiledPoint, env pathEnv) (any, *failure) { + if p.toolNameFrom == nil { + return nil, nil + } + v, err := p.toolNameFrom.resolve(env) + if err != nil { + return nil, pathFailure(err) + } + name, ok := v.(string) + if !ok { + return nil, fail(errPathTypeMismatch.Error(), "tool_name_from '%s' did not resolve to a string", p.toolNameFrom.original) + } + tool, ok := r.manifest.projectedTool(name) + if !ok { + return nil, fail(reasonToolUnknown, "%s", name) + } + projected, err := asJSON(tool) + if err != nil { + return nil, fail(errManifestInvalid.Error(), "tool %s: %v", name, err) + } + return projected, nil +} + +// annotate runs each annotator of the point in name order. Its from path is +// a precondition that must resolve; the dispatcher receives the whole +// preliminary policy input and its answer lands at annotations.. +func (r *Evaluator) annotate(p *compiledPoint, preliminary, snapshot map[string]any) (map[string]any, *failure) { + out := map[string]any{} + if len(p.annotations) > maxAnnotatorsPerPoint { + return nil, fail(reasonResourceLimitExceeded, "%d annotators exceed the limit %d", len(p.annotations), maxAnnotatorsPerPoint) + } + for _, a := range p.annotations { + if _, err := a.from.resolve(pathEnv{snap: snapshot, pi: preliminary}); err != nil { + return nil, pathFailure(err) + } + // compile admits the egress annotator only. + value := any(agtbridge.AnnotateEgress(preliminary)) + if f := checkAnnotatorOutput(a.name, value); f != nil { + return nil, f + } + out[a.name] = value + } + return out, nil +} + +func (r *Evaluator) query(ctx context.Context, point string, pi map[string]any) (any, *failure) { + input, err := asJSON(pi) + if err != nil { + return nil, fail(reasonPolicyInvocationFailed, "encode policy input: %v", err) + } + rs, err := r.queries[point].Eval(ctx, rego.EvalInput(input)) + switch { + case err != nil: + return nil, fail(reasonPolicyInvocationFailed, "opa eval failed: %v", err) + case len(rs) == 0: + return nil, fail(reasonPolicyInvocationFailed, "opa eval returned no result") + case len(rs) > 1: + return nil, fail(reasonPolicyInvocationFailed, "opa eval returned multiple results") + case len(rs[0].Expressions) != 1: + return nil, fail(reasonPolicyInvocationFailed, "opa eval returned %d expressions", len(rs[0].Expressions)) + } + value, err := asJSON(rs[0].Expressions[0].Value) + if err != nil { + return nil, fail(reasonPolicyInvocationFailed, "decode opa result: %v", err) + } + return value, nil +} + +// normalize is AGT's normalize_policy_output +// (policy-engine/core/src/verdict.rs). An empty result_labels is left out, +// as AGT's C ABI serializes it. +func normalize(output any) (agtbridge.Verdict, *failure) { + invalid := func(message string) *failure { return fail(reasonPolicyOutputInvalid, "%s", message) } + obj, ok := output.(map[string]any) + if !ok { + return agtbridge.Verdict{}, invalid("policy output decision is required") + } + decision, ok := obj["decision"].(string) + if !ok { + return agtbridge.Verdict{}, invalid("policy output decision is required") + } + switch decision { + case "allow", "deny", "warn", "escalate", "transform": + default: + return agtbridge.Verdict{}, invalid(fmt.Sprintf("unsupported decision '%s'", decision)) + } + v := agtbridge.Verdict{Decision: decision} + switch reason := obj["reason"].(type) { + case nil: + case string: + if strings.HasPrefix(reason, runtimeErrorPrefix) { + return agtbridge.Verdict{}, invalid("policy reasons must not use reserved runtime_error:* prefix") + } + v.Reason = &reason + default: + return agtbridge.Verdict{}, invalid("policy output reason must be a string") + } + switch message := obj["message"].(type) { + case nil: + case string: + v.Message = &message + default: + return agtbridge.Verdict{}, invalid("policy output message must be a string") + } + if _, present := obj["effects"]; present { + return agtbridge.Verdict{}, invalid("verdict 'effects' is no longer supported; remove the effects key and use the transform decision per SPECIFICATION.md §14. Migrate multi-step rewriting to an annotator") + } + if labels, present := obj["result_labels"]; present { + values, ok := labels.([]any) + if !ok { + return agtbridge.Verdict{}, invalid("policy output result_labels must be an array") + } + for _, l := range values { + s, ok := l.(string) + if !ok { + return agtbridge.Verdict{}, invalid("policy output result_labels must be an array of strings") + } + v.ResultLabels = append(v.ResultLabels, s) + } + } + t := obj["transform"] + switch { + case decision == "transform" && t == nil: + return agtbridge.Verdict{}, invalid("transform decision requires a transform object") + case decision != "transform" && t != nil: + return agtbridge.Verdict{}, invalid("transform is only permitted on the transform decision") + case decision == "transform": + tm, ok := t.(map[string]any) + if !ok { + return agtbridge.Verdict{}, invalid("transform must be an object") + } + pathText, ok := tm["path"].(string) + if !ok { + return agtbridge.Verdict{}, invalid("transform.path must be a string") + } + p, err := parsePath(pathText) + if err != nil { + return agtbridge.Verdict{}, invalid(err.Error()) + } + if p.root != rootPolicyTarget { + return agtbridge.Verdict{}, fail(reasonTransformTargetForbidden, "%s", pathText) + } + value, ok := tm["value"] + if !ok { + return agtbridge.Verdict{}, invalid("transform.value is required when decision is transform") + } + v.Transform = &agtbridge.Transform{Path: pathText, Value: value} + } + if f := checkEvidence(obj["evidence"]); f != nil { + return agtbridge.Verdict{}, f + } + return v, nil +} + +// checkEvidence applies AGT's evidence checks; the Guardian does not carry +// evidence onto the wire, as the TypeScript reference does not. +func checkEvidence(e any) *failure { + if e == nil { + return nil + } + invalid := func(message string) *failure { return fail(reasonPolicyOutputInvalid, "%s", message) } + obj, ok := e.(map[string]any) + if !ok { + return invalid("evidence must be an object") + } + if b, err := jsonv2.Marshal(obj); err != nil || len(b) > 4096 { + return invalid("evidence exceeds 4096 bytes") + } + switch obj["artefact"].(type) { + case nil, string: + default: + return invalid("evidence.artefact must be a string") + } + switch pointers := obj["verification_pointers"].(type) { + case nil: + case map[string]any: + for key, value := range pointers { + if _, ok := value.(string); !ok { + return invalid("evidence.verification_pointers." + key + " must be a string") + } + } + default: + return invalid("evidence.verification_pointers must be an object of strings") + } + return nil +} + +// applyTransform checks that a transform lands inside the policy target, and +// that the snapshot rebuilt with it stays within the snapshot limits, as +// AGT does in enforce mode before it surfaces the rewrite. +func applyTransform(p *compiledPoint, snapshot map[string]any, target any, t agtbridge.Transform) *failure { + tp, err := parsePath(t.Path) + if err != nil || tp.root != rootPolicyTarget { + return fail(reasonTransformTargetForbidden, "%s", t.Path) + } + transformed, err := setIn(target, tp.segments, t.Value) + if err != nil { + return fail(reasonTransformInvalid, "transform could not be applied: %v", err) + } + rebuilt, err := setIn(any(snapshot), p.target.segments, transformed) + if err != nil { + return fail(reasonTransformInvalid, "transform could not be applied: %v", err) + } + if f := checkSize(rebuilt, maxSnapshotBytes, reasonResourceLimitExceeded, "snapshot"); f != nil { + return f + } + return nil +} + +// setIn returns a copy of node with the value at segments replaced; every +// segment must already exist. +func setIn(node any, segments []segment, value any) (any, error) { + if len(segments) == 0 { + return value, nil + } + s := segments[0] + switch n := node.(type) { + case map[string]any: + if s.isIdx { + return nil, errPathTypeMismatch + } + child, ok := n[s.field] + if !ok { + return nil, errPathMissing + } + replaced, err := setIn(child, segments[1:], value) + if err != nil { + return nil, err + } + out := make(map[string]any, len(n)) + for k, v := range n { + out[k] = v + } + out[s.field] = replaced + return out, nil + case []any: + if !s.isIdx { + return nil, errPathTypeMismatch + } + if s.index >= len(n) { + return nil, errPathMissing + } + replaced, err := setIn(n[s.index], segments[1:], value) + if err != nil { + return nil, err + } + out := append([]any(nil), n...) + out[s.index] = replaced + return out, nil + default: + return nil, errPathTypeMismatch + } +} + +func pathFailure(err error) *failure { + reason := errPathMissing.Error() + if errors.Is(err, errPathTypeMismatch) { + reason = errPathTypeMismatch.Error() + } + return &failure{reason: reason, detail: err.Error()} +} + +func checkAnnotatorOutput(name string, value any) *failure { + if f := checkDepth(value, reasonAnnotationFailed, "annotator output"); f != nil { + f.detail = name + ": " + f.detail + return f + } + if reservedReason(value) { + return fail(reasonAnnotationFailed, "%s: annotator output reason must not use reserved runtime_error prefix", name) + } + if f := checkSize(value, maxAnnotatorOutputBytes, reasonAnnotationFailed, "annotator output"); f != nil { + f.detail = name + ": " + f.detail + return f + } + return nil +} + +func reservedReason(v any) bool { + switch n := v.(type) { + case []any: + for _, e := range n { + if reservedReason(e) { + return true + } + } + case map[string]any: + if r, ok := n["reason"].(string); ok && strings.HasPrefix(r, runtimeErrorPrefix) { + return true + } + for _, e := range n { + if reservedReason(e) { + return true + } + } + } + return false +} + +// checkSize checks depth and the serialized size, as AGT's validators do. +func checkSize(v any, limit int, reason, what string) *failure { + if f := checkDepth(v, reason, what); f != nil { + return f + } + b, err := jsonv2.Marshal(v) + if err != nil { + return fail(reason, "failed to serialize %s: %v", what, err) + } + if len(b) > limit { + return fail(reason, "%s serialized size %d exceeds limit %d", what, len(b), limit) + } + return nil +} + +func checkDepth(v any, reason, what string) *failure { + if depth(v) > maxDepth { + return fail(reason, "%s JSON nesting depth exceeds limit %d", what, maxDepth) + } + return nil +} + +// depth counts nested objects and arrays; a scalar has depth 0. +func depth(v any) int { + deepest := 0 + switch n := v.(type) { + case map[string]any: + for _, e := range n { + deepest = max(deepest, depth(e)) + } + return deepest + 1 + case []any: + for _, e := range n { + deepest = max(deepest, depth(e)) + } + return deepest + 1 + } + return 0 +} diff --git a/reference-implementations/agt-go/agtbridge/goeval/runtime_internal_test.go b/reference-implementations/agt-go/agtbridge/goeval/runtime_internal_test.go new file mode 100644 index 00000000..9f8e022a --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/goeval/runtime_internal_test.go @@ -0,0 +1,210 @@ +package goeval + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" +) + +func TestParsePath(t *testing.T) { + tests := []struct { + in string + snapshot bool + root pathRoot + segments int + invalid bool + }{ + {"$", true, rootSnap, 0, false}, + {"$.tool_call.args.command", true, rootSnap, 3, false}, + {"$.tool_result.outputs[0].value", true, rootSnap, 4, false}, + {`$snap["a.b"][2]`, false, rootSnap, 2, false}, + {`$pi["x\"]y"]`, false, rootPI, 1, false}, + {"$policy_target", false, rootPolicyTarget, 0, false}, + {"$policy_target.a", false, rootPolicyTarget, 1, false}, + {"$tool.name", false, rootTool, 1, false}, + {"$.a[-1]", true, 0, 0, true}, + {"$.a[x]", true, 0, 0, true}, + {"$.a[0", true, 0, 0, true}, + {"$.a..b", true, 0, 0, true}, + {"$.a]b", true, 0, 0, true}, + {"tool_call.name", true, 0, 0, true}, + {"$policy", false, 0, 0, true}, + {`$snap["unterminated]`, false, 0, 0, true}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + parse := parsePath + if tt.snapshot { + parse = parseSnapshotPath + } + p, err := parse(tt.in) + if (err != nil) != tt.invalid { + t.Fatalf("error %v, want invalid=%v", err, tt.invalid) + } + if !tt.invalid && (p.root != tt.root || len(p.segments) != tt.segments) { + t.Fatalf("root %d with %d segments", p.root, len(p.segments)) + } + }) + } +} + +func TestResolve(t *testing.T) { + snap := map[string]any{"a": map[string]any{"b": []any{"x", map[string]any{"c": 1.0}}}, "s": "str"} + pi := map[string]any{"policy_target": map[string]any{"value": map[string]any{"k": "v"}}, "tool": map[string]any{"name": "Bash"}} + env := pathEnv{snap: snap, pi: pi} + tests := []struct { + path string + want any + err error + }{ + {"$snap.a.b[0]", "x", nil}, + {"$snap.a.b[1].c", 1.0, nil}, + {"$snap.a.missing", nil, errPathMissing}, + {"$snap.a.b[5]", nil, errPathMissing}, + {"$snap.a.b.c", nil, errPathTypeMismatch}, + {"$snap.a[0]", nil, errPathTypeMismatch}, + {"$snap.s.x", nil, errPathTypeMismatch}, + {"$policy_target.k", "v", nil}, + {"$tool.name", "Bash", nil}, + {"$pi.tool.name", "Bash", nil}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + p, err := parsePath(tt.path) + if err != nil { + t.Fatal(err) + } + got, err := p.resolve(env) + if !errors.Is(err, tt.err) || (tt.err == nil && got != tt.want) { + t.Fatalf("got %v, %v", got, err) + } + }) + } + p, _ := parsePath("$pi.x") + if _, err := p.resolve(pathEnv{snap: snap}); !errors.Is(err, errPathMissing) { + t.Fatalf("a path from an absent root: %v", err) + } +} + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + output any + reason string // expected failure reason, empty for success + }{ + {"allow", map[string]any{"decision": "allow"}, ""}, + {"deny_with_reason", map[string]any{"decision": "deny", "reason": "r", "message": "m"}, ""}, + {"not_an_object", "allow", reasonPolicyOutputInvalid}, + {"no_decision", map[string]any{}, reasonPolicyOutputInvalid}, + {"unknown_decision", map[string]any{"decision": "maybe"}, reasonPolicyOutputInvalid}, + {"reserved_reason", map[string]any{"decision": "deny", "reason": "runtime_error:x"}, reasonPolicyOutputInvalid}, + {"reason_not_a_string", map[string]any{"decision": "deny", "reason": 1.0}, reasonPolicyOutputInvalid}, + {"message_not_a_string", map[string]any{"decision": "deny", "message": true}, reasonPolicyOutputInvalid}, + {"effects", map[string]any{"decision": "allow", "effects": nil}, reasonPolicyOutputInvalid}, + {"labels", map[string]any{"decision": "allow", "result_labels": []any{"a"}}, ""}, + {"labels_not_strings", map[string]any{"decision": "allow", "result_labels": []any{1.0}}, reasonPolicyOutputInvalid}, + {"labels_not_an_array", map[string]any{"decision": "allow", "result_labels": "a"}, reasonPolicyOutputInvalid}, + {"transform", map[string]any{"decision": "transform", "transform": map[string]any{"path": "$policy_target", "value": "x"}}, ""}, + {"transform_missing", map[string]any{"decision": "transform"}, reasonPolicyOutputInvalid}, + {"transform_on_allow", map[string]any{"decision": "allow", "transform": map[string]any{}}, reasonPolicyOutputInvalid}, + {"transform_not_an_object", map[string]any{"decision": "transform", "transform": "x"}, reasonPolicyOutputInvalid}, + {"transform_no_path", map[string]any{"decision": "transform", "transform": map[string]any{"value": 1.0}}, reasonPolicyOutputInvalid}, + {"transform_bad_path", map[string]any{"decision": "transform", "transform": map[string]any{"path": "x", "value": 1.0}}, reasonPolicyOutputInvalid}, + {"transform_outside_target", map[string]any{"decision": "transform", "transform": map[string]any{"path": "$snap.x", "value": 1.0}}, reasonTransformTargetForbidden}, + {"transform_no_value", map[string]any{"decision": "transform", "transform": map[string]any{"path": "$policy_target"}}, reasonPolicyOutputInvalid}, + {"evidence", map[string]any{"decision": "allow", "evidence": map[string]any{"artefact": "a", "verification_pointers": map[string]any{"k": "v"}}}, ""}, + {"evidence_not_an_object", map[string]any{"decision": "allow", "evidence": "x"}, reasonPolicyOutputInvalid}, + {"evidence_artefact", map[string]any{"decision": "allow", "evidence": map[string]any{"artefact": 1.0}}, reasonPolicyOutputInvalid}, + {"evidence_pointer", map[string]any{"decision": "allow", "evidence": map[string]any{"verification_pointers": map[string]any{"k": 1.0}}}, reasonPolicyOutputInvalid}, + {"evidence_pointers", map[string]any{"decision": "allow", "evidence": map[string]any{"verification_pointers": "x"}}, reasonPolicyOutputInvalid}, + {"evidence_too_large", map[string]any{"decision": "allow", "evidence": map[string]any{"artefact": strings.Repeat("a", 5000)}}, reasonPolicyOutputInvalid}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, f := normalize(tt.output) + switch { + case tt.reason == "" && f != nil: + t.Fatalf("failed: %v", f) + case tt.reason != "" && (f == nil || f.reason != tt.reason): + t.Fatalf("got %v, want %s", f, tt.reason) + } + }) + } +} + +// An empty result_labels is left out, as AGT's C ABI serializes it. +func TestNormalizeResultLabels(t *testing.T) { + for name, tt := range map[string]struct { + output map[string]any + want []string + }{ + "absent": {map[string]any{"decision": "allow"}, nil}, + "empty": {map[string]any{"decision": "allow", "result_labels": []any{}}, nil}, + "non_empty": {map[string]any{"decision": "allow", "result_labels": []any{"public"}}, []string{"public"}}, + } { + t.Run(name, func(t *testing.T) { + v, f := normalize(tt.output) + if f != nil || !slices.Equal(v.ResultLabels, tt.want) || (tt.want == nil) != (v.ResultLabels == nil) { + t.Fatalf("verdict=%+v failure=%v, want labels %v", v, f, tt.want) + } + }) + } +} + +func TestApplyTransform(t *testing.T) { + p := &compiledPoint{} + p.target, _ = parseSnapshotPath("$.a") + snapshot := map[string]any{"a": map[string]any{"b": "x"}} + target := snapshot["a"] + if f := applyTransform(p, snapshot, target, agtbridge.Transform{Path: "$policy_target.b", Value: "y"}); f != nil { + t.Fatalf("a transform inside the target failed: %v", f) + } + if f := applyTransform(p, snapshot, target, agtbridge.Transform{Path: "$policy_target.missing", Value: "y"}); f == nil || f.reason != reasonTransformInvalid { + t.Fatalf("a transform of a missing field: %v", f) + } + if f := applyTransform(p, snapshot, target, agtbridge.Transform{Path: "$snap.a", Value: "y"}); f == nil || f.reason != reasonTransformTargetForbidden { + t.Fatalf("a transform outside the target: %v", f) + } + if f := applyTransform(p, snapshot, target, agtbridge.Transform{Path: "$policy_target", Value: strings.Repeat("x", maxSnapshotBytes)}); f == nil || f.reason != reasonResourceLimitExceeded { + t.Fatalf("a transform past the snapshot limit: %v", f) + } + if snapshot["a"].(map[string]any)["b"] != "x" { + t.Fatal("applying a transform changed the snapshot") + } +} + +func TestAnnotatorOutputChecks(t *testing.T) { + deep := any("leaf") + for range maxDepth + 1 { + deep = []any{deep} + } + for name, v := range map[string]any{ + "reserved_reason": map[string]any{"x": []any{map[string]any{"reason": "runtime_error:forged"}}}, + "too_deep": deep, + "too_large": map[string]any{"x": strings.Repeat("a", maxAnnotatorOutputBytes)}, + } { + if f := checkAnnotatorOutput("egress", v); f == nil || f.reason != reasonAnnotationFailed || !strings.HasPrefix(f.detail, "egress: ") { + t.Errorf("%s: %v", name, f) + } + } + if f := checkAnnotatorOutput("egress", map[string]any{"destination": "https://x.example.com"}); f != nil { + t.Fatal(f) + } +} + +func TestFailureVerdict(t *testing.T) { + v := fail(reasonAnnotationFailed, "egress: broke").verdict() + if v.Decision != "deny" || *v.Reason != reasonAnnotationFailed || *v.Message != runtimeErrorMessage+" egress: broke" { + t.Fatalf("%+v", v) + } + v = fail(reasonToolUnknown, "Write").verdict() + if *v.Message != runtimeErrorMessage { + t.Fatalf("%q", *v.Message) + } + if fail(reasonToolUnknown, "Write").Error() != "runtime_error:tool_unknown: Write" { + t.Fatal("failure text") + } +} diff --git a/reference-implementations/agt-go/agtbridge/invariants_test.go b/reference-implementations/agt-go/agtbridge/invariants_test.go new file mode 100644 index 00000000..b180c577 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/invariants_test.go @@ -0,0 +1,90 @@ +package agtbridge_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" +) + +// agtVocabulary is what only the AGT engine may know: AGT's verdicts, +// intervention points and manifest terms. +var agtVocabulary = regexp.MustCompile(`\b(escalate|intervention_point|pre_tool_call|post_tool_call|agent_control_specification|result_labels|policy_target_argument)\b`) + +const module = "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go" + +// dependencyRules say which packages may depend on what, in the build +// without the agteval tag and in the build with it. OPA belongs to the Go +// evaluator alone: the projection and the AGT evaluator, which calls AGT's +// own runtime library, never link it. The AGT evaluator reaches only the +// command, so a program importing guardian or agtbridge links no native +// library unless it imports agtbridge/agteval itself. +var dependencyRules = []struct { + dependency string + allowed []string +}{ + {"github.com/open-policy-agent/", []string{module + "/agtbridge/goeval", module + "/cmd/guardian"}}, + {module + "/agtbridge", []string{module + "/agtbridge/goeval", module + "/agtbridge/agteval", module + "/cmd/guardian"}}, + {module + "/agtbridge/agteval", []string{module + "/cmd/guardian"}}, +} + +// TestAGTConfinedToAgtbridge keeps AGT out of the protocol packages and each +// evaluator's dependencies in its own package, with and without the agteval +// build tag; and no protocol source names AGT's vocabulary. The corpus of +// recorded cases describe AGT's behaviour and are not protocol code. +func TestAGTConfinedToAgtbridge(t *testing.T) { + for _, tags := range []string{"", "agteval"} { + cmd := exec.Command("go", "list", "-C", "..", "-tags", tags, "-f", `{{.ImportPath}} {{join .Deps " "}}`, "./...") + cmd.Env = append(os.Environ(), "CGO_ENABLED=1") + out, err := cmd.Output() + if err != nil { + t.Fatalf("go list -tags %q: %v", tags, err) + } + listed := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + pkg, deps, _ := strings.Cut(line, " ") + listed[pkg] = true + for _, dep := range strings.Fields(deps) { + for _, rule := range dependencyRules { + if dependsOn(dep, rule.dependency) && !slices.Contains(rule.allowed, pkg) { + t.Errorf("-tags %q: %s imports %s", tags, pkg, dep) + } + } + } + } + if listed[module+"/agtbridge/agteval"] != (tags == "agteval") { + t.Errorf("-tags %q: agtbridge/agteval listed=%v; it must compile only with the tag", tags, listed[module+"/agtbridge/agteval"]) + } + } + for _, dir := range []string{"../acs", "../guardian", "../internal"} { + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || + strings.HasPrefix(path, "../internal/tscases") { + return err + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + if m := agtVocabulary.Find(b); m != nil { + t.Errorf("%s names AGT's %q", path, m) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } +} + +// dependsOn matches a package path, or every package under a path that ends +// in a slash. +func dependsOn(dep, rule string) bool { + if strings.HasSuffix(rule, "/") { + return strings.HasPrefix(dep, rule) + } + return dep == rule +} diff --git a/reference-implementations/agt-go/agtbridge/load_test.go b/reference-implementations/agt-go/agtbridge/load_test.go new file mode 100644 index 00000000..4b5b8e48 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/load_test.go @@ -0,0 +1,275 @@ +//go:build load + +package agtbridge_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/handshake" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/tscases" +) + +// TestLoad measures the Guardian over the evaluator ACS__POLICY__EVALUATOR +// names (agt, the default, or go): decisions per second (engine_saturated +// refusals not counted), latency of every step, the refusals, the opa +// processes alive at once and their memory (agt runs one per decision; go +// runs none), and the audit log's size on disk. It asserts only that the +// audit log lost, split and reordered no record it kept, and stayed within +// its rotation bound; `make load` runs it with the Makefile's EVALUATOR, and +// docs/load.md records the results the defaults rest on. +// +// ACS_LOAD_CLIENTS lists the concurrent clients per run (default +// 1,8,32,64,128,256), ACS_LOAD_STEPS the steps each client sends (default +// 20), ACS_LOAD_MAX_ENGINE_CALLS the Guardian's limit (default the library +// default), and ACS_LOAD_AUDIT_MAX_FILE_BYTES and ACS_LOAD_AUDIT_MAX_FILES +// the audit rotation (default none). +func TestLoad(t *testing.T) { + clients := envInts(t, "ACS_LOAD_CLIENTS", []int{1, 8, 32, 64, 128, 256}) + steps := envInts(t, "ACS_LOAD_STEPS", []int{20})[0] + maxEngineCalls := envInts(t, "ACS_LOAD_MAX_ENGINE_CALLS", []int{guardian.DefaultMaxEngineCalls})[0] + rotation := guardian.JSONLRotation{ + MaxFileBytes: int64(envInts(t, "ACS_LOAD_AUDIT_MAX_FILE_BYTES", []int{0})[0]), + MaxFiles: envInts(t, "ACS_LOAD_AUDIT_MAX_FILES", []int{0})[0], + } + name := os.Getenv("ACS__POLICY__EVALUATOR") + if name == "" { + name = "agt" + } + evaluator, ok := recordedEvaluators[name] + if !ok { + t.Fatalf("evaluator %q is not built in; agt needs the agteval tag", name) + } + requests := loadRequests() + signer := testSigner(t) + t.Logf("evaluator=%s, max_engine_calls=%d, %d steps per client, audit rotation %d bytes x %d files", name, maxEngineCalls, steps, rotation.MaxFileBytes, rotation.MaxFiles) + t.Logf("%8s %9s %8s %8s %8s %8s %10s %10s %12s", "clients", "decided/s", "p50", "p95", "p99", "max", "saturated", "opa_peak", "opa_rss_MiB") + for _, n := range clients { + dir := t.TempDir() + audit, err := guardian.NewJSONLAuditLog(filepath.Join(dir, "envelopes.jsonl"), filepath.Join(dir, "events.jsonl"), rotation, guardian.JSONLQueue{}, nil) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: engineOver(t, evaluator(t)), Signer: signer, AuditLog: audit, MaxEngineCalls: maxEngineCalls}) + if err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + peak := make(chan opaSample, 1) + go sampleOPA(stop, peak) + + latencies := make([][]time.Duration, n) + var saturated atomic.Int64 + var wg sync.WaitGroup + start := time.Now() + for c := range n { + wg.Add(1) + go func() { + defer wg.Done() + latencies[c] = runClient(t, g, signer, requests, c, steps, &saturated) + }() + } + wg.Wait() + elapsed := time.Since(start) + close(stop) + sample := <-peak + if err := audit.Close(context.Background()); err != nil { + t.Fatalf("audit log: %v", err) + } + + all := slices.Concat(latencies...) + slices.Sort(all) + total := len(all) + t.Logf("%8d %9.0f %8s %8s %8s %8s %10d %10d %12.0f", n, float64(int64(total)-saturated.Load())/elapsed.Seconds(), + percentile(all, 50), percentile(all, 95), percentile(all, 99), all[total-1].Round(time.Millisecond), saturated.Load(), sample.processes, float64(sample.rssKiB)/1024) + for _, log := range []string{"envelopes.jsonl", "events.jsonl"} { + a := checkAuditLog(t, dir, log, rotation) + t.Logf("%8s %s: %d records written, %d kept in %d files, %d bytes", "", log, a.written, a.kept, a.files, a.bytes) + } + } +} + +// loadRequests is every toolCallRequest the parity corpus sends, allowed and +// denied alike, so the load exercises the same policy branches. +func loadRequests() []observedagent.Request { + var requests []observedagent.Request + for _, c := range tscases.Corpus() { + for _, s := range c.Steps { + if s.Method == acs.StepToolCallRequest { + requests = append(requests, observedagent.Request{Method: s.Method, Payload: s.Payload}) + } + } + } + return requests +} + +func runClient(t *testing.T, g *guardian.Guardian, signer guardian.Signer, requests []observedagent.Request, client, steps int, saturated *atomic.Int64) []time.Duration { + ctx := context.Background() + c := &observedagent.Client{ + Transport: func(ctx context.Context, body []byte) ([]byte, error) { return g.Handle(ctx, body), nil }, + Signer: signer, AgentID: "load", SessionID: observedagent.NewUUID(), + } + if o, err := c.Handshake(ctx, observedagent.DefaultHello(handshake.CoreMethods()...)); err != nil || o.Hello == nil { + t.Errorf("client %d handshake: %v", client, err) + return nil + } + latencies := make([]time.Duration, 0, steps) + for i := range steps { + start := time.Now() + o, err := c.Send(ctx, requests[(client+i)%len(requests)]) + latencies = append(latencies, time.Since(start)) + if err != nil || o.Result == nil { + t.Errorf("client %d step %d: %v", client, i, err) + return latencies + } + if slices.Contains(o.Result.ReasonCodes, disposition.ReasonEngineSaturated) { + saturated.Add(1) + } + } + return latencies +} + +type opaSample struct { + processes int + rssKiB int +} + +// sampleOPA reports the most opa processes this process had alive at once, +// and the most resident memory they held together, sampled every 5ms. +func sampleOPA(stop <-chan struct{}, peak chan<- opaSample) { + var best opaSample + self := strconv.Itoa(os.Getpid()) + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + peak <- best + return + case <-ticker.C: + } + var now opaSample + entries, _ := os.ReadDir("/proc") + for _, e := range entries { + status, err := os.ReadFile(filepath.Join("/proc", e.Name(), "status")) + if err != nil { + continue + } + fields := statusFields(string(status)) + if fields["Name"] == "opa" && fields["PPid"] == self { + now.processes++ + rss, _ := strconv.Atoi(strings.TrimSuffix(fields["VmRSS"], " kB")) + now.rssKiB += rss + } + } + best.processes = max(best.processes, now.processes) + best.rssKiB = max(best.rssKiB, now.rssKiB) + } +} + +func statusFields(status string) map[string]string { + fields := map[string]string{} + for line := range strings.SplitSeq(status, "\n") { + if key, value, ok := strings.Cut(line, ":"); ok { + fields[key] = strings.TrimSpace(value) + } + } + return fields +} + +func percentile(sorted []time.Duration, p int) time.Duration { + return sorted[(len(sorted)-1)*p/100].Round(time.Millisecond) +} + +type auditLogSize struct { + written, kept, files int + bytes int64 +} + +// checkAuditLog reads one audit log's files oldest first and requires that +// no record is split, that the kept records are the newest ones with +// consecutive seq numbers, and that the files stay within the rotation +// bound: max_file_bytes × (max_files + 1), each file past max_file_bytes by +// at most its one record. The writer numbers records from 1, so the last +// seq is the number written. +func checkAuditLog(t *testing.T, dir, name string, rotation guardian.JSONLRotation) auditLogSize { + t.Helper() + paths := []string{filepath.Join(dir, name)} + for i := 1; ; i++ { + p := fmt.Sprintf("%s.%d", paths[0], i) + if _, err := os.Stat(p); err != nil { + break + } + paths = append(paths, p) + } + slices.Reverse(paths) + var size auditLogSize + prev := 0 + for _, p := range paths { + content, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + size.files++ + size.bytes += int64(len(content)) + if len(content) > 0 && content[len(content)-1] != '\n' { + t.Errorf("%s ends inside a record", p) + } + lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + if rotation.MaxFileBytes > 0 && len(lines) > 1 && int64(len(content)-len(lines[len(lines)-1])-1) > rotation.MaxFileBytes { + t.Errorf("%s holds %d bytes before its last record, past max_file_bytes %d", p, len(content), rotation.MaxFileBytes) + } + for _, line := range lines { + var r struct { + Seq int `json:"seq"` + } + if err := jsonv2.Unmarshal([]byte(line), &r); err != nil { + t.Fatalf("%s holds a line that is not one record: %v", p, err) + } + if prev != 0 && r.Seq != prev+1 { + t.Errorf("%s: seq %d follows %d", p, r.Seq, prev) + } + prev = r.Seq + size.kept++ + } + } + if rotation.MaxFileBytes > 0 && len(paths) > rotation.MaxFiles+1 { + t.Errorf("%s has %d files, past max_files %d", name, len(paths), rotation.MaxFiles) + } + size.written = prev + if rotation.MaxFileBytes == 0 && size.kept != size.written { + t.Errorf("%s kept %d of %d records without rotation", name, size.kept, size.written) + } + return size +} + +func envInts(t *testing.T, name string, fallback []int) []int { + raw := os.Getenv(name) + if raw == "" { + return fallback + } + var values []int + for _, field := range strings.Split(raw, ",") { + v, err := strconv.Atoi(strings.TrimSpace(field)) + if err != nil || v <= 0 { + t.Fatal(fmt.Errorf("%s: %q is not a positive integer", name, field)) + } + values = append(values, v) + } + return values +} diff --git a/reference-implementations/agt-go/agtbridge/locks_test.go b/reference-implementations/agt-go/agtbridge/locks_test.go new file mode 100644 index 00000000..44e0622e --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/locks_test.go @@ -0,0 +1,40 @@ +package agtbridge_test + +import ( + "os" + "testing" + + jsonv2 "github.com/go-json-experiment/json" +) + +// TestAGTLocksPinOneCommit: the agt evaluator links the AGT that +// agt-native.lock pins, and runs the policy bundle agt.lock pins, so an AGT +// bump that moves one lock fails here until it moves the other. The npm +// package version and the crate version are not compared: AGT can publish +// one without the other. +func TestAGTLocksPinOneCommit(t *testing.T) { + type pin struct { + Repo string `json:"agt_repo"` + Ref string `json:"agt_ref"` + } + var typescript, native pin + readLock(t, agtTree+"/agt.lock", &typescript) + readLock(t, "../agt-native.lock", &native) + if typescript.Repo == "" || typescript.Ref == "" { + t.Fatalf("agt.lock names no repo or ref: %+v", typescript) + } + if typescript != native { + t.Fatalf("agt.lock pins %s@%s, and agt-native.lock pins %s@%s", typescript.Repo, typescript.Ref, native.Repo, native.Ref) + } +} + +func readLock(t *testing.T, path string, v any) { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := jsonv2.Unmarshal(raw, v); err != nil { + t.Fatalf("%s: %v", path, err) + } +} diff --git a/reference-implementations/agt-go/agtbridge/mapping.go b/reference-implementations/agt-go/agtbridge/mapping.go new file mode 100644 index 00000000..b109fdee --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/mapping.go @@ -0,0 +1,284 @@ +package agtbridge + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + + jsonv2 "github.com/go-json-experiment/json" + "go.yaml.in/yaml/v3" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// mapping is the TypeScript reference's mapping.yaml, the single source of +// truth for how an AGT verdict becomes an ACS decision. This file reads it +// and implements it as packages/guardian/src/map-verdict.ts does; it +// declares nothing of its own. +type mapping struct { + ACSVersion string `yaml:"acs_version"` + AGTVersion string `yaml:"agt_version"` + InterventionPoints map[string]mappingPoint `yaml:"intervention_points"` + Verdicts map[string]verdictRule `yaml:"verdicts"` + FieldSynthesis fieldSynthesis `yaml:"field_synthesis"` + + detail *regexp.Regexp +} + +type mappingPoint struct { + ACSMethod *string `yaml:"acs_method"` + Note string `yaml:"note"` + PolicyTargetArgument *policyTargetArgument `yaml:"policy_target_argument"` + Modifications *modificationsRule `yaml:"modifications"` +} + +type policyTargetArgument struct { + Default string `yaml:"default"` + ByTool map[string]string `yaml:"by_tool"` +} + +type modificationsRule struct { + From string `yaml:"from"` + WhenPath string `yaml:"when_path"` + Into string `yaml:"into"` + IntoPath string `yaml:"into_path"` +} + +type verdictRule struct { + Decision acs.Disposition `yaml:"decision"` + RequirePolicyReferences bool `yaml:"require_policy_references"` +} + +type fieldSynthesis struct { + Reasoning struct { + Source string `yaml:"source"` + Template *string `yaml:"template"` + Summaries map[string]any `yaml:"summaries"` + Detail *struct { + WhenMatches string `yaml:"when_matches"` + Render string `yaml:"render"` + Otherwise string `yaml:"otherwise"` + } `yaml:"detail"` + } `yaml:"reasoning"` + ReasonCodes struct { + Source string `yaml:"source"` + Wrap string `yaml:"wrap"` + } `yaml:"reason_codes"` + PolicyReferences struct { + RuleID struct { + Source string `yaml:"source"` + } `yaml:"rule_id"` + PolicyID struct { + Literal string `yaml:"literal"` + } `yaml:"policy_id"` + } `yaml:"policy_references"` +} + +var verdictFields = []string{"verdict.decision", "verdict.reason", "verdict.message", "verdict.transform", "verdict.result_labels"} + +// parseMapping reads mapping.yaml and checks every rule the translation +// reads, so a broken table stops the Guardian at start rather than denying +// at the first step that reaches it. +func parseMapping(src []byte) (*mapping, error) { + dec := yaml.NewDecoder(bytes.NewReader(src)) + dec.KnownFields(true) + var m mapping + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("mapping.yaml: %w", err) + } + fs := m.FieldSynthesis + for _, source := range []string{fs.Reasoning.Source, fs.ReasonCodes.Source, fs.PolicyReferences.RuleID.Source} { + if !slices.Contains(verdictFields, source) { + return nil, fmt.Errorf("mapping.yaml: field_synthesis reads %q, which is not a verdict field", source) + } + } + if fs.ReasonCodes.Wrap != "array" { + return nil, fmt.Errorf("mapping.yaml: field_synthesis.reason_codes.wrap is %q; only array is expressible", fs.ReasonCodes.Wrap) + } + if d := fs.Reasoning.Detail; d != nil { + re, err := regexp.Compile(d.WhenMatches) + if err != nil { + return nil, fmt.Errorf("mapping.yaml: field_synthesis.reasoning.detail.when_matches: %w", err) + } + m.detail = re + } + for name, rule := range m.Verdicts { + switch rule.Decision { + case acs.Allow, acs.Deny, acs.Modify, acs.Ask, acs.Defer: + default: + return nil, fmt.Errorf("mapping.yaml: verdict %s maps to %q, which is not an ACS disposition", name, rule.Decision) + } + } + methods := map[string]string{} + for point, row := range m.InterventionPoints { + if row.ACSMethod != nil { + if other, dup := methods[*row.ACSMethod]; dup { + return nil, fmt.Errorf("mapping.yaml: %s maps to both %s and %s", *row.ACSMethod, other, point) + } + methods[*row.ACSMethod] = point + } + if r := row.Modifications; r != nil { + if !slices.Contains(verdictFields, r.From) || (r.Into != "parameter_overrides" && r.Into != "redactions") { + return nil, fmt.Errorf("mapping.yaml: intervention_points.%s.modifications is not expressible", point) + } + } + } + return &m, nil +} + +// point returns the AGT intervention point an ACS method maps to. +func (m *mapping) point(method string) (string, bool) { + for point, row := range m.InterventionPoints { + if row.ACSMethod != nil && *row.ACSMethod == method { + return point, true + } + } + return "", false +} + +// policyTargetArgument is the tool argument a point's policy target is read +// from and an override is written to; empty when the point declares none. +func (m *mapping) policyTargetArgument(point, tool string) (string, error) { + table := m.InterventionPoints[point].PolicyTargetArgument + if table == nil { + return "", nil + } + if named, ok := table.ByTool[tool]; ok { + return named, nil + } + if table.Default == "" { + return "", fmt.Errorf("mapping.yaml's intervention_points.%s.policy_target_argument names no argument for tool %q and declares no default", point, tool) + } + return table.Default, nil +} + +// decision translates a verdict at a point, as mapVerdict does. +func (m *mapping) decision(v Verdict, point, argument string) (acs.Decision, error) { + rule, ok := m.Verdicts[v.Decision] + if !ok { + return acs.Decision{}, fmt.Errorf("mapping.yaml has no verdict rule for AGT decision %q", v.Decision) + } + fs := m.FieldSynthesis + d := acs.Decision{Disposition: rule.Decision} + ruleID := v.field(fs.PolicyReferences.RuleID.Source) + if reasoning := m.reasoning(ruleID, v.field(fs.Reasoning.Source), fs.PolicyReferences.PolicyID.Literal, point); reasoning != nil { + d.Reasoning = *reasoning + } + if code := v.field(fs.ReasonCodes.Source); code != nil { + d.ReasonCodes = []string{*code} + } + if ruleID != nil { + d.PolicyReferences = []acs.PolicyReference{{PolicyID: fs.PolicyReferences.PolicyID.Literal, RuleID: *ruleID}} + } + if rule.RequirePolicyReferences && len(d.PolicyReferences) == 0 { + return acs.Decision{}, fmt.Errorf("mapping.yaml declares require_policy_references for AGT decision %q, but no policy_references could be synthesized (verdict.reason was empty)", v.Decision) + } + if rule.Decision == acs.Modify { + mods, err := m.modifications(v, point, argument) + if err != nil { + return acs.Decision{}, err + } + d.Modifications = mods + } + return d, nil +} + +// field reads a string verdict field a mapping source names. An empty string +// is absent: response-envelope.json requires a non-empty rule_id, and Trace +// reads "" in its policy arrays as absent. +func (v Verdict) field(source string) *string { + var value *string + switch source { + case "verdict.reason": + value = v.Reason + case "verdict.message": + value = v.Message + case "verdict.decision": + value = &v.Decision + } + if value == nil || *value == "" { + return nil + } + return value +} + +// reasoning is composeReasoning: the template filled in order, each +// placeholder replaced across the text the previous ones produced. +func (m *mapping) reasoning(ruleID, message *string, policyID, point string) *string { + rule := m.FieldSynthesis.Reasoning + if rule.Template == nil { + return message + } + if ruleID == nil { + return nil + } + detail := "" + if rule.Detail != nil && message != nil { + if hit := m.detail.FindStringSubmatch(*message); hit != nil { + group := "" + if len(hit) > 1 { + group = hit[1] + } + detail = strings.ReplaceAll(rule.Detail.Render, "{1}", group) + } else { + detail = strings.ReplaceAll(rule.Detail.Otherwise, "{message}", *message) + } + } + out := *rule.Template + out = strings.ReplaceAll(out, "{summary}", m.summary(*ruleID, point)) + out = strings.ReplaceAll(out, "{rule_id}", *ruleID) + out = strings.ReplaceAll(out, "{policy_id}", policyID) + out = strings.ReplaceAll(out, "{detail}", detail) + return &out +} + +func (m *mapping) summary(ruleID, point string) string { + summaries := m.FieldSynthesis.Reasoning.Summaries + switch entry := summaries[ruleID].(type) { + case string: + return entry + case map[string]any: + if perPoint, ok := entry[point].(string); ok { + return perPoint + } + } + fallback, _ := summaries["default"].(string) + return fallback +} + +var errModificationUnexpressible = errors.New("the rewrite cannot be expressed as an ACS modification") + +// modifications is synthesizeModifications. +func (m *mapping) modifications(v Verdict, point, argument string) (*acs.Modifications, error) { + rule := m.InterventionPoints[point].Modifications + if rule == nil { + return nil, fmt.Errorf("%w: mapping.yaml maps AGT decision %q to ACS modify, but its intervention_points row for %q declares no modifications rule", errModificationUnexpressible, v.Decision, point) + } + if v.Transform == nil { + return nil, fmt.Errorf("%w: %s is absent", errModificationUnexpressible, rule.From) + } + if v.Transform.Path != rule.WhenPath { + return nil, fmt.Errorf("%w: mapping.yaml can express a transform of %q only, but the verdict rewrote %q", errModificationUnexpressible, rule.WhenPath, v.Transform.Path) + } + value, err := jsonv2.Marshal(v.Transform.Value) + if err != nil { + return nil, err + } + switch rule.Into { + case "parameter_overrides": + if argument == "" { + return nil, fmt.Errorf("%w: intervention point %q declares no policy_target_argument for the override to land on", errModificationUnexpressible, point) + } + return &acs.Modifications{ParameterOverrides: map[string]json.RawMessage{argument: value}}, nil + default: + replacement, ok := v.Transform.Value.(string) + if !ok { + return nil, fmt.Errorf("%w: a redaction's replacement is a string, and %s.value is not", errModificationUnexpressible, rule.From) + } + return &acs.Modifications{Redactions: []acs.Redaction{{Path: rule.IntoPath, Replacement: &replacement}}}, nil + } +} diff --git a/reference-implementations/agt-go/agtbridge/mcp.go b/reference-implementations/agt-go/agtbridge/mcp.go new file mode 100644 index 00000000..d808e741 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/mcp.go @@ -0,0 +1,237 @@ +package agtbridge + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/jcs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/mcp" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" +) + +// mcpToolsCall is the MCP method a tool call travels in. +const mcpToolsCall = "tools/call" + +// The exit_status values (tool-call-result.json) a projected MCP result +// takes. +const ( + exitSuccess = "success" + exitFailure = "failure" +) + +// projection is a wrapped MCP tools/call seen as the native tool step it +// is, so AGT's tool gates govern a tool called through MCP exactly as one +// called directly: the request as steps/toolCallRequest, with the MCP +// tool's name and arguments, and the response as steps/toolCallResult, with +// text outputs first for AGT compatibility and every other returned value +// preserved after them. +type projection struct { + req acs.Request + // call is the MCP id of a request, and tool its tool's name. An MCP + // response does not name its tool, so a request the decision lets + // through is remembered in the session's state until its response. + call, tool string + // arguments are a request's argument names, which a parameter override + // must name. + arguments []string + // outputs are, for a response, the JSON pointer into the MCP message of + // each projected output, by output index, which a redaction is moved to. + outputs []string + wrapped bool +} + +// errMCPCallPending is a wrapped tools/call whose MCP id names a call still +// awaiting its response: a response could no longer be told apart. +var errMCPCallPending = errors.New("a tools/call with this MCP id is still awaiting its response") + +// project returns the projection of req, which is req itself for anything +// but a wrapped MCP tools/call. A response to no request the session let +// through is an error; its pending call is removed from state. +func project(req acs.Request, state *policyState) (projection, error) { + protocol, _, m, ok := method.SplitWrapped(req.Method) + if !ok || !strings.EqualFold(protocol, mcp.Protocol) || m != mcpToolsCall { + return projection{req: req}, nil + } + msg, err := mcp.Read(m, req.Params.Payload) + if err != nil { + return projection{}, err + } + p := projection{wrapped: true} + id, err := mcpCorrelationID(msg.ID) + if err != nil { + return projection{}, err + } + var payload any + if msg.IsResponse() { + tool, ok := state.MCPCalls[id] + if !ok { + return projection{}, fmt.Errorf("the MCP response answers id %s, and no tools/call with that id was let through in this session", id) + } + delete(state.MCPCalls, id) + var result acs.ToolCallResultPayload + result, p.outputs = toolResult(msg, tool) + req.Method, payload = acs.StepToolCallResult, result + } else { + call, err := toolRequest(msg) + if err != nil { + return projection{}, err + } + if _, pending := state.MCPCalls[id]; pending && id != "" { + return projection{}, fmt.Errorf("%w: %s", errMCPCallPending, id) + } + p.call, p.tool = id, call.Tool.Name + for name := range call.Arguments { + p.arguments = append(p.arguments, name) + } + req.Method, payload = acs.StepToolCallRequest, call + } + req.Params.Payload, err = jsonv2.Marshal(payload) + p.req = req + return p, err +} + +func mcpCorrelationID(id []byte) (string, error) { + if len(id) == 0 { + return "", nil + } + canonical, err := jcs.Canonicalize(id) + if err != nil { + return "", fmt.Errorf("canonicalize MCP id: %w", err) + } + return string(canonical), nil +} + +// settle remembers a request the decision lets through, so its response +// can be matched to its tool. +func (p projection) settle(state *policyState, d acs.Disposition) { + if p.call == "" || (d != acs.Allow && d != acs.Modify) { + return + } + if state.MCPCalls == nil { + state.MCPCalls = map[string]string{} + } + state.MCPCalls[p.call] = p.tool +} + +// readdress fits a modification to the MCP message the Observed Agent +// holds: a response's redaction of a projected output moves to that +// output's place, and a request's parameter overrides name members of +// params.arguments, which a wrapped tools/call's arguments are. Anything +// else cannot be applied to the message, and is refused. +func (p projection) readdress(mods *acs.Modifications) error { + if mods == nil || !p.wrapped { + return nil + } + if mods.ModifiedContent != nil { + return errors.New("a wrapped MCP message cannot be replaced whole") + } + if p.outputs == nil { + if len(mods.Redactions) > 0 { + return errors.New("a wrapped MCP request can only be modified by parameter overrides") + } + for name := range mods.ParameterOverrides { + if !slices.Contains(p.arguments, name) { + return fmt.Errorf("the override of %q names no argument of the wrapped MCP call", name) + } + } + return nil + } + if len(mods.ParameterOverrides) > 0 { + return errors.New("a wrapped MCP response can only be redacted") + } + for i, r := range mods.Redactions { + rest, ok := strings.CutPrefix(r.Path, "/outputs/") + index, leaf, found := strings.Cut(rest, "/") + n, err := strconv.Atoi(index) + if !ok || !found || leaf != "value" || err != nil || n < 0 || n >= len(p.outputs) { + return fmt.Errorf("the redaction of %s addresses nothing in the wrapped MCP response", r.Path) + } + mods.Redactions[i].Path = p.outputs[n] + } + return nil +} + +func toolRequest(msg mcp.Message) (acs.ToolCallRequestPayload, error) { + var params struct { + Name string `json:"name"` + Arguments map[string]json.RawMessage `json:"arguments"` + } + if err := jsonv2.Unmarshal(msg.Params, ¶ms); err != nil { + return acs.ToolCallRequestPayload{}, err + } + p := acs.ToolCallRequestPayload{Tool: acs.Tool{Name: params.Name}, Arguments: map[string]acs.ToolArgument{}} + for name, value := range params.Arguments { + p.Arguments[name] = acs.ToolArgument{Value: value} + } + return p, nil +} + +// toolResult carries every MCP content item, structured result and error +// detail as an output, and returns each output's pointer into the message. +// Text stays a string so the stock AGT egress policies see the same shape as +// a native text result. Other content stays in its original JSON shape. +func toolResult(msg mcp.Message, tool string) (acs.ToolCallResultPayload, []string) { + p := acs.ToolCallResultPayload{Tool: acs.Tool{Name: tool}, ExitStatus: exitSuccess, Outputs: []acs.ToolOutput{}} + var result struct { + Content []json.RawMessage `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent"` + Metadata json.RawMessage `json:"_meta"` + IsError bool `json:"isError"` + } + _ = jsonv2.Unmarshal(msg.Result, &result) + if result.IsError || len(msg.Error) > 0 { + p.ExitStatus = exitFailure + } + var paths []string + type projectedContent struct { + value json.RawMessage + path string + } + var textOutputs, otherOutputs []projectedContent + for i, content := range result.Content { + var textContent struct { + Type string `json:"type"` + Text *string `json:"text"` + } + if jsonv2.Unmarshal(content, &textContent) == nil && textContent.Type == "text" && textContent.Text != nil { + text, _ := jsonv2.Marshal(*textContent.Text) + textOutputs = append(textOutputs, projectedContent{value: text, path: fmt.Sprintf("/result/content/%d/text", i)}) + continue + } + otherOutputs = append(otherOutputs, projectedContent{value: slices.Clone(content), path: fmt.Sprintf("/result/content/%d", i)}) + } + for _, output := range append(textOutputs, otherOutputs...) { + p.Outputs = append(p.Outputs, acs.ToolOutput{Value: output.value}) + paths = append(paths, output.path) + } + if len(result.StructuredContent) > 0 { + p.Outputs = append(p.Outputs, acs.ToolOutput{Value: slices.Clone(result.StructuredContent)}) + paths = append(paths, "/result/structuredContent") + } + if len(result.Metadata) > 0 { + p.Outputs = append(p.Outputs, acs.ToolOutput{Value: slices.Clone(result.Metadata)}) + paths = append(paths, "/result/_meta") + } + if len(msg.Error) > 0 { + var e struct { + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + _ = jsonv2.Unmarshal(msg.Error, &e) + text, _ := jsonv2.Marshal(e.Message) + p.Outputs = append(p.Outputs, acs.ToolOutput{Value: text}) + paths = append(paths, "/error/message") + if len(e.Data) > 0 { + p.Outputs = append(p.Outputs, acs.ToolOutput{Value: slices.Clone(e.Data)}) + paths = append(paths, "/error/data") + } + } + return p, append([]string{}, paths...) +} diff --git a/reference-implementations/agt-go/agtbridge/mcp_internal_test.go b/reference-implementations/agt-go/agtbridge/mcp_internal_test.go new file mode 100644 index 00000000..8ee384a5 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/mcp_internal_test.go @@ -0,0 +1,181 @@ +package agtbridge + +import ( + "encoding/json" + "errors" + "slices" + "testing" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +func mcpRequest(t *testing.T, message map[string]any) acs.Request { + t.Helper() + payload, err := jsonv2.Marshal(message) + if err != nil { + t.Fatal(err) + } + return acs.Request{Method: "protocols/MCP/tools/call", Params: acs.Params{Payload: payload}} +} + +// A call the decision stops is not remembered, so no response can be +// answered as though it ran. +func TestProjectionRemembersOnlyWhatRuns(t *testing.T) { + var state policyState + call := mcpRequest(t, map[string]any{"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": map[string]any{"name": "Bash", "arguments": map[string]any{"command": "ls"}}}) + p, err := project(call, &state) + if err != nil { + t.Fatal(err) + } + p.settle(&state, acs.Deny) + if len(state.MCPCalls) != 0 { + t.Fatalf("a denied call is pending: %v", state.MCPCalls) + } + response := mcpRequest(t, map[string]any{"jsonrpc": "2.0", "id": 7, "result": map[string]any{"content": []any{}}}) + if _, err := project(response, &state); err == nil { + t.Fatal("a response to a denied call was projected") + } + p.settle(&state, acs.Modify) + if state.MCPCalls["7"] != "Bash" { + t.Fatalf("a call let through is not pending: %v", state.MCPCalls) + } +} + +func TestProjectionReaddress(t *testing.T) { + var state policyState + call := mcpRequest(t, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "Bash", "arguments": map[string]any{"command": "ls"}}}) + p, err := project(call, &state) + if err != nil { + t.Fatal(err) + } + override := func(name string) *acs.Modifications { + return &acs.Modifications{ParameterOverrides: map[string]json.RawMessage{name: json.RawMessage(`"ls -l"`)}} + } + if err := p.readdress(override("command")); err != nil { + t.Fatalf("an override of the call's argument: %v", err) + } + for name, mods := range map[string]*acs.Modifications{ + "unknown_argument": override("url"), + "redaction": {Redactions: []acs.Redaction{{Path: "/arguments/command/value"}}}, + "whole_message": {ModifiedContent: new(string)}, + } { + if err := p.readdress(mods); err == nil { + t.Errorf("%s: applied to a wrapped MCP request", name) + } + } + p.settle(&state, acs.Allow) + response := mcpRequest(t, map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []any{map[string]any{"type": "text", "text": "a"}}}}) + r, err := project(response, &state) + if err != nil { + t.Fatal(err) + } + if err := r.readdress(override("command")); err == nil { + t.Error("an override was applied to a wrapped MCP response") + } + outOfRange := &acs.Modifications{Redactions: []acs.Redaction{{Path: "/outputs/1/value"}}} + if err := r.readdress(outOfRange); err == nil { + t.Error("a redaction of an output the response lacks was applied") + } +} + +func TestProjectionKeepsEveryMCPResultShape(t *testing.T) { + state := policyState{MCPCalls: map[string]string{"1": "Read"}} + response := mcpRequest(t, map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "plain"}, + map[string]any{"type": "image", "data": "secret", "mimeType": "image/png"}, + map[string]any{"type": "resource", "resource": map[string]any{"uri": "file:///secret"}}, + }, + "structuredContent": map[string]any{"token": "secret"}, + "_meta": map[string]any{"trace": "private"}, + }, + }) + p, err := project(response, &state) + if err != nil { + t.Fatal(err) + } + var result acs.ToolCallResultPayload + if err := jsonv2.Unmarshal(p.req.Params.Payload, &result); err != nil { + t.Fatal(err) + } + if len(result.Outputs) != 5 { + t.Fatalf("projected %d outputs, want text, image, resource, structured content and metadata", len(result.Outputs)) + } + wantPaths := []string{ + "/result/content/0/text", + "/result/content/1", + "/result/content/2", + "/result/structuredContent", + "/result/_meta", + } + if !slices.Equal(p.outputs, wantPaths) { + t.Fatalf("output paths %v, want %v", p.outputs, wantPaths) + } + mods := &acs.Modifications{Redactions: []acs.Redaction{{Path: "/outputs/3/value"}}} + if err := p.readdress(mods); err != nil { + t.Fatal(err) + } + if mods.Redactions[0].Path != "/result/structuredContent" { + t.Fatalf("structured-content redaction points to %q", mods.Redactions[0].Path) + } +} + +// Two calls in flight under one MCP id would make their results +// indistinguishable, so the second is refused until the first is answered. +func TestProjectionRefusesAPendingID(t *testing.T) { + var state policyState + call := func(tool string) acs.Request { + return mcpRequest(t, map[string]any{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": map[string]any{"name": tool, "arguments": map[string]any{}}}) + } + p, err := project(call("Bash"), &state) + if err != nil { + t.Fatal(err) + } + p.settle(&state, acs.Allow) + if _, err := project(call("WebFetch"), &state); !errors.Is(err, errMCPCallPending) { + t.Fatalf("a second call under a pending id: %v", err) + } + if _, err := project(mcpRequest(t, map[string]any{"jsonrpc": "2.0", "id": 3, "result": map[string]any{"content": []any{}}}), &state); err != nil { + t.Fatal(err) + } + if _, err := project(call("WebFetch"), &state); err != nil { + t.Fatalf("the id once answered: %v", err) + } +} + +func TestProjectionCanonicalizesMCPIDs(t *testing.T) { + for _, tt := range []struct { + name, first, equivalent string + }{ + {"escaped_string", `"a"`, `"\u0061"`}, + {"equivalent_number", `1`, `1.0`}, + } { + t.Run(tt.name, func(t *testing.T) { + request := func(id, tool string) acs.Request { + payload := json.RawMessage(`{"jsonrpc":"2.0","id":` + id + `,"method":"tools/call","params":{"name":"` + tool + `","arguments":{}}}`) + return acs.Request{Method: "protocols/MCP/tools/call", Params: acs.Params{Payload: payload}} + } + response := func(id string) acs.Request { + payload := json.RawMessage(`{"jsonrpc":"2.0","id":` + id + `,"result":{"content":[]}}`) + return acs.Request{Method: "protocols/MCP/tools/call", Params: acs.Params{Payload: payload}} + } + var state policyState + pending, err := project(request(tt.first, "Bash"), &state) + if err != nil { + t.Fatal(err) + } + pending.settle(&state, acs.Allow) + if _, err := project(request(tt.equivalent, "WebFetch"), &state); !errors.Is(err, errMCPCallPending) { + t.Fatalf("equivalent pending id: %v", err) + } + if _, err := project(response(tt.equivalent), &state); err != nil { + t.Fatalf("equivalent response id: %v", err) + } + }) + } +} diff --git a/reference-implementations/agt-go/agtbridge/recorded_cases_test.go b/reference-implementations/agt-go/agtbridge/recorded_cases_test.go new file mode 100644 index 00000000..e73d87bc --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/recorded_cases_test.go @@ -0,0 +1,200 @@ +package agtbridge_test + +import ( + "context" + "fmt" + "os" + "slices" + "testing" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/handshake" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/tscases" +) + +// recordedEvaluators are the evaluators this build has, by +// policy.evaluator name; a build with the agteval tag adds agt. +var recordedEvaluators = map[string]func(testing.TB) agtbridge.Evaluator{ + "go": func(t testing.TB) agtbridge.Evaluator { return goEvaluator(t) }, +} + +// TestRecordedCases replays the answers recorded from the TypeScript +// reference Guardian (scripts/record-ts-cases.sh) through the Go Guardian +// over each evaluator, over the whole request path, and requires the same +// disposition, reason codes, policy references, modifications and +// reasoning. Two declared exceptions: when the engine fails, both +// Guardians deny with evaluation_failed, and the reasoning is each +// Guardian's own sentence around the failure (docs/differences.md); and +// the cases in aheadOfTypeScript. +func TestRecordedCases(t *testing.T) { + rec := readRecording(t) + if d := corpusDifference(rec.Cases, tscases.Corpus()); d != "" { + t.Fatalf("the recording was made from another corpus (%s); run scripts/record-ts-cases.sh", d) + } + signer := testSigner(t) + for name, evaluator := range recordedEvaluators { + t.Run(name, func(t *testing.T) { + g, err := guardian.New(guardian.Config{Engine: engineOver(t, evaluator(t)), Signer: signer}) + if err != nil { + t.Fatal(err) + } + for _, c := range rec.Cases { + t.Run(c.Name, func(t *testing.T) { + for i, got := range replay(t, g, signer, c) { + if reason, ahead := aheadOfTypeScript[c.Name]; ahead { + requireAhead(t, reason, *c.Steps[i].Result, got) + continue + } + if d := difference(*c.Steps[i].Result, got); d != "" { + t.Errorf("step %d: %s, against TypeScript", i, d) + } + } + }) + } + }) + } +} + +// aheadOfTypeScript names the recorded cases where the Go Guardian already +// follows a fix the TypeScript reference has in an open pull request, so the +// recording still holds the old answer. Each entry is deleted when a +// re-record carries the fix; requireAhead fails once it does. +var aheadOfTypeScript = map[string]string{ + "command_not_a_string": "a non-string policy target is denied (#229, 96731db)", + "fetch_url_not_a_string": "a non-string policy target is denied (#229, 96731db)", +} + +func requireAhead(t *testing.T, reason string, recorded, got acs.Decision) { + t.Helper() + if recorded.Disposition != acs.Allow { + t.Fatalf("the recording now answers %s: TypeScript carries the fix (%s); delete the aheadOfTypeScript entry", recorded.Disposition, reason) + } + if got.Disposition != acs.Deny || !slices.Equal(got.ReasonCodes, []string{disposition.ReasonEvaluationFailed}) { + t.Fatalf("got %s %v, want DENY [evaluation_failed]: %s", got.Disposition, got.ReasonCodes, reason) + } +} + +// corpusDifference reports the first case or step whose name, method or +// payload the recording does not share with the corpus, so the answers are +// never replayed against inputs they were not given. +func corpusDifference(recorded, corpus []tscases.Case) string { + if len(recorded) != len(corpus) { + return fmt.Sprintf("%d cases recorded, %d in the corpus", len(recorded), len(corpus)) + } + for i, c := range corpus { + r := recorded[i] + if r.Name != c.Name || len(r.Steps) != len(c.Steps) { + return fmt.Sprintf("case %d is %s with %d steps, the corpus has %s with %d", i, r.Name, len(r.Steps), c.Name, len(c.Steps)) + } + for j, s := range c.Steps { + var got, want any + if r.Steps[j].Method != s.Method || jsonv2.Unmarshal(r.Steps[j].Payload, &got) != nil || jsonv2.Unmarshal(s.Payload, &want) != nil || !equalJSON(got, want) { + return fmt.Sprintf("%s step %d differs", c.Name, j) + } + } + } + return "" +} + +func readRecording(t testing.TB) tscases.Recording { + t.Helper() + b, err := os.ReadFile("testdata/ts-cases.json") + if err != nil { + t.Fatal(err) + } + var rec tscases.Recording + if err := jsonv2.Unmarshal(b, &rec); err != nil { + t.Fatal(err) + } + return rec +} + +// replay sends a recorded case's steps through the Guardian over the whole +// request path, in a new session, and returns each step's decision. +func replay(t *testing.T, g *guardian.Guardian, signer guardian.Signer, c tscases.Case) []acs.Decision { + t.Helper() + ctx := context.Background() + client := openSession(t, g, signer) + decisions := make([]acs.Decision, len(c.Steps)) + for i, step := range c.Steps { + if step.Result == nil { + t.Fatalf("step %d: the TypeScript Guardian answered an error, %+v, which this replay does not compare", i, step.Error) + } + o, err := client.Send(ctx, observedagent.Request{Method: step.Method, Payload: step.Payload}) + if err != nil { + t.Fatal(err) + } + if o.Result == nil { + t.Fatalf("step %d: no decision: %s", i, o.Raw) + } + decisions[i] = o.Result.Decision + } + return decisions +} + +// openSession handshakes a new session with g and returns the client that +// signs its steps. +func openSession(t *testing.T, g *guardian.Guardian, signer guardian.Signer) *observedagent.Client { + t.Helper() + client := &observedagent.Client{ + Transport: func(ctx context.Context, body []byte) ([]byte, error) { return g.Handle(ctx, body), nil }, + Signer: signer, AgentID: "parity", SessionID: observedagent.NewUUID(), + } + if o, err := client.Handshake(context.Background(), observedagent.DefaultHello(handshake.CoreMethods()...)); err != nil || o.Hello == nil { + t.Fatalf("handshake: %v", err) + } + return client +} + +// difference names the first field in which got differs from want, or is +// empty. When the engine failed, the reasoning is each Guardian's own +// sentence around the failure and is not compared. +func difference(want, got acs.Decision) string { + failed := slices.Contains(want.ReasonCodes, disposition.ReasonEvaluationFailed) + switch { + case got.Disposition != want.Disposition: + return fmt.Sprintf("disposition %s, want %s (%s)", got.Disposition, want.Disposition, want.Reasoning) + case !slices.Equal(got.ReasonCodes, want.ReasonCodes): + return fmt.Sprintf("reason codes %v, want %v", got.ReasonCodes, want.ReasonCodes) + case !slices.Equal(got.PolicyReferences, want.PolicyReferences): + return fmt.Sprintf("policy references %+v, want %+v", got.PolicyReferences, want.PolicyReferences) + case !failed && got.Reasoning != want.Reasoning: + return fmt.Sprintf("reasoning\n %q\nwant\n %q", got.Reasoning, want.Reasoning) + case !equalJSON(got.Modifications, want.Modifications): + return fmt.Sprintf("modifications %s, want %s", mustJSON(got.Modifications), mustJSON(want.Modifications)) + } + return "" +} + +func mustJSON(v any) string { + b, _ := jsonv2.Marshal(v, jsonv2.Deterministic(true)) + return string(b) +} + +func equalJSON(a, b any) bool { return mustJSON(a) == mustJSON(b) } + +// TestRecordingIsCurrent fails when the TypeScript tree changed what decides +// a step after the recording was made, so the parity cases cannot silently +// compare against an older reference. Re-record with +// scripts/record-ts-cases.sh. +func TestRecordingIsCurrent(t *testing.T) { + rec := readRecording(t) + trees, err := tscases.DecidingTrees(agtTree) + if err != nil { + t.Fatal(err) + } + if len(rec.TSTrees) != len(tscases.DecidingPaths) { + t.Fatalf("the recording names %d paths and tscases.DecidingPaths %d; run scripts/record-ts-cases.sh", len(rec.TSTrees), len(tscases.DecidingPaths)) + } + for _, p := range tscases.DecidingPaths { + if trees[p] != rec.TSTrees[p] { + t.Fatalf("%s is %s in the TypeScript tree and %s in the recording; run scripts/record-ts-cases.sh", p, trees[p], rec.TSTrees[p]) + } + } +} diff --git a/reference-implementations/agt-go/agtbridge/snapshot.go b/reference-implementations/agt-go/agtbridge/snapshot.go new file mode 100644 index 00000000..1ad00ce7 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/snapshot.go @@ -0,0 +1,115 @@ +package agtbridge + +import ( + "fmt" + "maps" + "slices" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// PolicyTargetLeaf is the argument the snapshot copies a tool's policy +// target into, the leaf the checked-in manifest's pre_tool_call policy_target +// addresses. The TypeScript reference's assemble-snapshot.ts writes the same +// leaf. +const PolicyTargetLeaf = "acs_policy_target" + +// The snapshots below have the shape packages/guardian/src/assemble-snapshot.ts +// builds, member for member, so the policy sees the same input. + +func budgets() map[string]any { + return map[string]any{"tool_call_count": 0.0, "token_count": 0.0, "elapsed_seconds": 0.0, "cost_usd": 0.0} +} + +func ifcInput(labels []string) map[string]any { + source := make([]any, len(labels)) + for i, l := range labels { + source[i] = l + } + return map[string]any{"ifc": map[string]any{"source_labels": source}} +} + +// preToolCallSnapshot copies each argument's value into args, and the policy +// target argument's value into the fixed leaf as well. +func preToolCallSnapshot(req acs.Request, labels []string, argument, policyToolName string) (map[string]any, error) { + var payload acs.ToolCallRequestPayload + if err := jsonv2.Unmarshal(req.Params.Payload, &payload); err != nil { + return nil, err + } + if argument == "" { + return nil, fmt.Errorf("mapping.yaml declares no policy_target_argument for the request gate, so there is no argument to copy to the %q leaf the manifest targets", PolicyTargetLeaf) + } + args := map[string]any{} + for name, a := range payload.Arguments { + var v any + if err := jsonv2.Unmarshal(a.Value, &v); err != nil { + return nil, err + } + args[name] = v + } + if _, clash := args[PolicyTargetLeaf]; clash { + return nil, fmt.Errorf("tool %q sent an argument named %q, which is the leaf this Guardian writes its policy target to", payload.Tool.Name, PolicyTargetLeaf) + } + value, ok := args[argument] + if !ok { + sent := slices.Sorted(maps.Keys(args)) + return nil, fmt.Errorf("mapping.yaml reads tool %q's policy target from argument %q, but this call sent no such argument (it sent: %v)", payload.Tool.Name, argument, sent) + } + // The stock rules guard their targets with is_string; a target of any + // other JSON type would pass every one of them. + if _, isString := value.(string); !isString { + return nil, fmt.Errorf("tool %q sent argument %q as a JSON %s, but its policy target must be a string", payload.Tool.Name, argument, jsonKind(value)) + } + args[PolicyTargetLeaf] = value + raw := "" + if payload.RawCommand != nil { + raw = *payload.RawCommand + } + return map[string]any{ + "envelope": map[string]any{"budgets": budgets()}, + "tool_call": map[string]any{"name": policyToolName, "args": args, "id": req.Params.RequestID, "raw_command": raw}, + "input": ifcInput(labels), + }, nil +} + +func postToolCallSnapshot(req acs.Request, labels []string, policyToolName string) (map[string]any, error) { + var payload acs.ToolCallResultPayload + if err := jsonv2.Unmarshal(req.Params.Payload, &payload); err != nil { + return nil, err + } + outputs := make([]any, len(payload.Outputs)) + for i, o := range payload.Outputs { + var v any + if err := jsonv2.Unmarshal(o.Value, &v); err != nil { + return nil, err + } + outputs[i] = map[string]any{"value": v} + } + return map[string]any{ + "envelope": map[string]any{"budgets": budgets()}, + "tool_call": map[string]any{"name": policyToolName}, + "tool_result": map[string]any{"outputs": outputs}, + "input": ifcInput(labels), + }, nil +} + +// jsonKind names the JSON type of a value that jsonv2 decoded into any. +func jsonKind(v any) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case string: + return "string" + case float64: + return "number" + case []any: + return "array" + case map[string]any: + return "object" + } + return fmt.Sprintf("%T", v) +} diff --git a/reference-implementations/agt-go/agtbridge/testdata/ts-cases.json b/reference-implementations/agt-go/agtbridge/testdata/ts-cases.json new file mode 100644 index 00000000..17048b30 --- /dev/null +++ b/reference-implementations/agt-go/agtbridge/testdata/ts-cases.json @@ -0,0 +1,1492 @@ +{ + "ts_trees": { + "agt.lock": "3367f3166c7b9662c9895bccf351ab11f389b1f8", + "mapping.yaml": "3dac231d12196657ed186e3bce2bd3ef81be290a", + "packages/agt-bridge/package.json": "89dd8140627b2d01abc67fc8f59e531cc0850c40", + "packages/agt-bridge/src": "4cb957b4632fd795c54a9db379399c861cef2e45", + "packages/guardian/src": "e1b5e245737a1ba2497b1ab751d415aa1aa504ee", + "policy": "c6f34f789525f8a89a4c2fc1ba236fb1a0cd70d0" + }, + "cases": [ + { + "name": "argument_named_like_the_leaf", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "acs_policy_target": { + "value": "x" + }, + "command": { + "value": "ls" + } + }, + "raw_command": "ls", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "tool \"Bash\" sent an argument named \"acs_policy_target\", which is the leaf this Guardian writes its policy target to -- one of the two would have to be overwritten, and neither may be", + "reason_codes": [ + "evaluation_failed" + ], + "policy_references": [] + } + } + ] + }, + { + "name": "aws_key_in_command", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "export K=AKIAABCDEFGHIJKLMNOP" + } + }, + "raw_command": "export K=AKIAABCDEFGHIJKLMNOP", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "modify", + "reasoning": "A secret in this step's arguments was replaced before it ran. Policy: redaction_applied, from AGT's stock bundle (agt_stock).", + "reason_codes": [ + "redaction_applied" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "redaction_applied" + } + ], + "modifications": { + "parameter_overrides": { + "command": "export K=[REDACTED]" + } + } + } + } + ] + }, + { + "name": "benign_empty", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "" + } + }, + "raw_command": "", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "benign_install", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "npm install" + } + }, + "raw_command": "npm install", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "benign_listing", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "ls -la" + } + }, + "raw_command": "ls -la", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "command_not_a_string", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": 5 + } + }, + "raw_command": "5", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "destructive_current_directory", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "rm -rf ./" + } + }, + "raw_command": "rm -rf ./", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "destructive_echoed", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "echo rm -rf /" + } + }, + "raw_command": "echo rm -rf /", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 5.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "destructive_flags_reversed", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "rm -fr /" + } + }, + "raw_command": "rm -fr /", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "destructive_parent_directory", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "rm -rf .." + } + }, + "raw_command": "rm -rf ..", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "destructive_root", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "rm -rf /" + } + }, + "raw_command": "rm -rf /", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "destructive_upper_case", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "RM -RF /" + } + }, + "raw_command": "RM -RF /", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "destructive_with_sudo", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "sudo rm -rf / --no-preserve-root" + } + }, + "raw_command": "sudo rm -rf / --no-preserve-root", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 5.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "egress_clone", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "git clone https://example.org/r" + } + }, + "raw_command": "git clone https://example.org/r", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination example.org not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_default_port", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://docs.example.com:443/" + } + }, + "raw_command": "curl https://docs.example.com:443/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_dotted_ipv4", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl http://127.0.0.1/" + } + }, + "raw_command": "curl http://127.0.0.1/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination 127.0.0.1 not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_first_url_counts", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://docs.example.com/?next=https://evil.example.net" + } + }, + "raw_command": "curl https://docs.example.com/?next=https://evil.example.net", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_ftp_ignored", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl ftp://evil.example.net/" + } + }, + "raw_command": "curl ftp://evil.example.net/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_hex_ipv4", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "wget http://0x7f.1/" + } + }, + "raw_command": "wget http://0x7f.1/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination 127.0.0.1 not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_invalid_punycode", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://xn--zz.example.net/" + } + }, + "raw_command": "curl https://xn--zz.example.net/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination unresolved.invalid not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_off_allowlist", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://evil.example.net/x" + } + }, + "raw_command": "curl https://evil.example.net/x", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination evil.example.net not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_on_allowlist", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://docs.example.com/a" + } + }, + "raw_command": "curl https://docs.example.com/a", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_other_port", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://docs.example.com:8443/" + } + }, + "raw_command": "curl https://docs.example.com:8443/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_port_out_of_range", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://docs.example.com:70000/" + } + }, + "raw_command": "curl https://docs.example.com:70000/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination unresolved.invalid not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_quoted", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "echo 'https://docs.example.com/q'" + } + }, + "raw_command": "echo 'https://docs.example.com/q'", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_suffix_confusion", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://docs.example.com.evil.example.net/" + } + }, + "raw_command": "curl https://docs.example.com.evil.example.net/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination docs.example.com.evil.example.net not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_trailing_dot", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://docs.example.com./" + } + }, + "raw_command": "curl https://docs.example.com./", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination docs.example.com. not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "egress_upper_case_host", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl http://DOCS.EXAMPLE.COM/" + } + }, + "raw_command": "curl http://DOCS.EXAMPLE.COM/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_upper_case_scheme", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl HTTPS://evil.example.net/" + } + }, + "raw_command": "curl HTTPS://evil.example.net/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "egress_userinfo", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://user@evil.example.net/" + } + }, + "raw_command": "curl https://user@evil.example.net/", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination unresolved.invalid not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "fetch_lower_case_tool", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "url": { + "value": "https://evil.example.net/" + } + }, + "tool": { + "name": "webfetch" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination evil.example.net not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "fetch_off_allowlist", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "url": { + "value": "https://evil.example.net/" + } + }, + "tool": { + "name": "WebFetch" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination evil.example.net not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "fetch_on_allowlist", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "url": { + "value": "https://docs.example.com/en" + } + }, + "tool": { + "name": "WebFetch" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "fetch_url_not_a_string", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "url": { + "value": 42 + } + }, + "tool": { + "name": "WebFetch" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "fetch_userinfo_soft_spot", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "url": { + "value": "https://docs.example.com@evil.example.net/" + } + }, + "tool": { + "name": "WebFetch" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination docs.example.com@evil.example.net not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + }, + { + "name": "labels_across_steps", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "ls" + } + }, + "raw_command": "ls", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + }, + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": "file" + } + ], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + }, + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "cat file" + } + }, + "raw_command": "cat file", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + }, + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": "TOKEN=ghp_ABCDEF123456" + } + ], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "modify", + "reasoning": "Secrets in this output were replaced before the model saw them. Policy: redaction_applied, from AGT's stock bundle (agt_stock).", + "reason_codes": [ + "redaction_applied" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "redaction_applied" + } + ], + "modifications": { + "redactions": [ + { + "path": "/outputs/0/value", + "replacement": "TOKEN=[REDACTED]" + } + ] + } + } + }, + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "rm -rf /" + } + }, + "raw_command": "rm -rf /", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "destructive_shell_command_blocked" + } + ] + } + } + ] + }, + { + "name": "policy_target_argument_missing", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "cmd": { + "value": "ls" + } + }, + "raw_command": "ls", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "mapping.yaml reads tool \"Bash\"'s policy target from argument \"command\", but this call sent no such argument (it sent: cmd)", + "reason_codes": [ + "evaluation_failed" + ], + "policy_references": [] + } + } + ] + }, + { + "name": "raw_command_absent", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "curl https://evil.example.net/" + } + }, + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "result_aws_key_redacted", + "steps": [ + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": "key AKIAABCDEFGHIJKLMNOP end" + } + ], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "modify", + "reasoning": "Secrets in this output were replaced before the model saw them. Policy: redaction_applied, from AGT's stock bundle (agt_stock).", + "reason_codes": [ + "redaction_applied" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "redaction_applied" + } + ], + "modifications": { + "redactions": [ + { + "path": "/outputs/0/value", + "replacement": "key [REDACTED] end" + } + ] + } + } + } + ] + }, + { + "name": "result_clean", + "steps": [ + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": "total 0" + } + ], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "result_no_outputs", + "steps": [ + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: runtime_error:path_missing, from AGT's stock bundle (agt_stock). AGT reported: Request blocked by Agent Control Specification..", + "reason_codes": [ + "runtime_error:path_missing" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "runtime_error:path_missing" + } + ] + } + } + ] + }, + { + "name": "result_output_is_an_object", + "steps": [ + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": { + "stdout": "ghp_ABCDEF123456" + } + } + ], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "result_secret_in_second_output", + "steps": [ + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": "clean" + }, + { + "value": "ghp_ABCDEF123456" + } + ], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "result_secret_redacted", + "steps": [ + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": "TOKEN=ghp_ABCDEF123456" + } + ], + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "modify", + "reasoning": "Secrets in this output were replaced before the model saw them. Policy: redaction_applied, from AGT's stock bundle (agt_stock).", + "reason_codes": [ + "redaction_applied" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "redaction_applied" + } + ], + "modifications": { + "redactions": [ + { + "path": "/outputs/0/value", + "replacement": "TOKEN=[REDACTED]" + } + ] + } + } + } + ] + }, + { + "name": "result_unregistered_tool", + "steps": [ + { + "method": "steps/toolCallResult", + "payload": { + "exit_status": "success", + "outputs": [ + { + "value": "ok" + } + ], + "tool": { + "name": "Write" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: runtime_error:tool_unknown, from AGT's stock bundle (agt_stock). AGT reported: Request blocked by Agent Control Specification..", + "reason_codes": [ + "runtime_error:tool_unknown" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "runtime_error:tool_unknown" + } + ] + } + } + ] + }, + { + "name": "secret_in_command", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "echo ghp_abcdefgh1234" + } + }, + "raw_command": "echo ghp_abcdefgh1234", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "modify", + "reasoning": "A secret in this step's arguments was replaced before it ran. Policy: redaction_applied, from AGT's stock bundle (agt_stock).", + "reason_codes": [ + "redaction_applied" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "redaction_applied" + } + ], + "modifications": { + "parameter_overrides": { + "command": "echo [REDACTED]" + } + } + } + } + ] + }, + { + "name": "split_flags_not_matched", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "rm -r -f /" + } + }, + "raw_command": "rm -r -f /", + "tool": { + "name": "Bash" + } + }, + "result": { + "decision": "allow" + } + } + ] + }, + { + "name": "unregistered_tool", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "x" + } + }, + "tool": { + "name": "Write" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: runtime_error:tool_unknown, from AGT's stock bundle (agt_stock). AGT reported: Request blocked by Agent Control Specification..", + "reason_codes": [ + "runtime_error:tool_unknown" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "runtime_error:tool_unknown" + } + ] + } + } + ] + }, + { + "name": "url_argument_on_shell", + "steps": [ + { + "method": "steps/toolCallRequest", + "payload": { + "arguments": { + "command": { + "value": "ls" + }, + "url": { + "value": "https://evil.example.net/" + } + }, + "raw_command": "ls", + "tool": { + "name": "bash" + } + }, + "result": { + "decision": "deny", + "reasoning": "This step was decided by AGT's stock policy bundle. Policy: egress_destination_not_allowed, from AGT's stock bundle (agt_stock). AGT reported: destination evil.example.net not in allowlist [\"*.anthropic.com\", \"docs.example.com\"].", + "reason_codes": [ + "egress_destination_not_allowed" + ], + "policy_references": [ + { + "policy_id": "agt_stock", + "rule_id": "egress_destination_not_allowed" + } + ] + } + } + ] + } + ] +} diff --git a/reference-implementations/agt-go/cmd/docs-check/main.go b/reference-implementations/agt-go/cmd/docs-check/main.go new file mode 100644 index 00000000..9c81300d --- /dev/null +++ b/reference-implementations/agt-go/cmd/docs-check/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/docscheck" +) + +func main() { + if err := docscheck.Check("."); err != nil { + fmt.Fprintln(os.Stderr, "docs-check:", err) + os.Exit(1) + } +} diff --git a/reference-implementations/agt-go/cmd/guardian/build_agt_test.go b/reference-implementations/agt-go/cmd/guardian/build_agt_test.go new file mode 100644 index 00000000..aaf10995 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/build_agt_test.go @@ -0,0 +1,10 @@ +//go:build agteval + +package main_test + +// The command under test is built with the AGT evaluator, which +// guardian.yaml selects. +var ( + buildFlags = []string{"-tags", "agteval"} + evaluatorEnv []string +) diff --git a/reference-implementations/agt-go/cmd/guardian/build_noagt_test.go b/reference-implementations/agt-go/cmd/guardian/build_noagt_test.go new file mode 100644 index 00000000..50cdd104 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/build_noagt_test.go @@ -0,0 +1,10 @@ +//go:build !agteval + +package main_test + +// The command under test is built without the AGT evaluator, so it runs +// the Go evaluator in place of the one guardian.yaml selects. +var ( + buildFlags []string + evaluatorEnv = []string{"ACS__POLICY__EVALUATOR=go"} +) diff --git a/reference-implementations/agt-go/cmd/guardian/config_test.go b/reference-implementations/agt-go/cmd/guardian/config_test.go new file mode 100644 index 00000000..090c95b9 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/config_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/base64" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +func TestConfigEnvironmentOverride(t *testing.T) { + configPath, err := filepath.Abs("../../guardian.yaml") + if err != nil { + t.Fatal(err) + } + secretFile := filepath.Join(t.TempDir(), "hmac-secret") + if err := os.WriteFile(secretFile, []byte(base64.StdEncoding.EncodeToString([]byte("01234567890123456789012345678901"))), 0o600); err != nil { + t.Fatal(err) + } + c, err := loadConfig( + []string{"--config", configPath}, + []string{ + "ACS__SECURITY__HMAC_SECRET_FILE=" + secretFile, + "ACS__LIMITS__MAX_DEFERRALS=4", + "ACS__POLICY__ASK_SUBSTITUTION=defer", + "ACS__POLICY__TOOL_ALIASES__shell=run_shell", + }, + ) + if err != nil { + t.Fatal(err) + } + if c.maxDeferrals != 4 { + t.Fatalf("max deferrals = %d, want 4", c.maxDeferrals) + } + if c.askSubstitution != guardian.AskSubstitutionDefer { + t.Fatalf("ask substitution = %q, want defer", c.askSubstitution) + } + if c.toolAliases["shell"] != "run_shell" { + t.Fatalf("tool aliases = %v", c.toolAliases) + } +} + +// TestPolicyPathStaysInsideDeploymentDir covers the refusal documented in +// docs/configuration.md: a manifest or mapping path names a file under +// policy.deployment_dir and cannot leave it. +func TestPolicyPathStaysInsideDeploymentDir(t *testing.T) { + refused := []string{ + "", + ".", + "/etc/acs/manifest.yaml", + "../manifest.yaml", + "policy/../../manifest.yaml", + "policy/./manifest.yaml", + "/", + } + for _, value := range refused { + t.Run("refuses_"+value, func(t *testing.T) { + if got, err := policyPath("policy.manifest", value); err == nil { + t.Fatalf("policyPath(%q) = %q, want a refusal", value, got) + } + }) + } + accepted := map[string]string{ + "manifest.yaml": "manifest.yaml", + "policy/manifest.yaml": "policy/manifest.yaml", + } + for value, want := range accepted { + t.Run("accepts_"+value, func(t *testing.T) { + got, err := policyPath("policy.manifest", value) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("policyPath(%q) = %q, want %q", value, got, want) + } + }) + } +} + +// The audit queue limits reach the command as configured, and +// audit.max_queue_bytes is checked against the configured limits.max_body_bytes. +func TestAuditQueueConfiguration(t *testing.T) { + const body = 4096 + least := guardian.JSONLRecordBytes(body) + for name, tt := range map[string]struct { + queueBytes int64 + wantErr bool + }{ + "holds_one_request": {queueBytes: least}, + "one_byte_short": {queueBytes: least - 1, wantErr: true}, + } { + t.Run(name, func(t *testing.T) { + c, err := loadReferenceConfig(t, "ACS__POLICY__EVALUATOR=go", "ACS__POLICY__OPA_PATH=", + "ACS__LIMITS__MAX_BODY_BYTES="+strconv.Itoa(body), + "ACS__AUDIT__MAX_QUEUE_RECORDS=7", + "ACS__AUDIT__MAX_QUEUE_BYTES="+strconv.FormatInt(tt.queueBytes, 10)) + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "audit.max_queue_bytes must be at least") { + t.Fatalf("error %v, want the audit.max_queue_bytes bound", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if want := (guardian.JSONLQueue{MaxQueueRecords: 7, MaxQueueBytes: tt.queueBytes}); c.auditQueue != want { + t.Fatalf("audit queue %+v, want %+v", c.auditQueue, want) + } + }) + } +} diff --git a/reference-implementations/agt-go/cmd/guardian/evaluator.go b/reference-implementations/agt-go/cmd/guardian/evaluator.go new file mode 100644 index 00000000..3d65eea6 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/evaluator.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "os" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge/goeval" +) + +// The values of policy.evaluator. +const ( + evaluatorAGT = "agt" + evaluatorGo = "go" +) + +func newEvaluator(ctx context.Context, c config) (agtbridge.Evaluator, error) { + if c.evaluator == evaluatorGo { + return goeval.New(ctx, os.DirFS(c.policyDir), c.manifest) + } + return newAGTEvaluator(c) +} diff --git a/reference-implementations/agt-go/cmd/guardian/evaluator_agt.go b/reference-implementations/agt-go/cmd/guardian/evaluator_agt.go new file mode 100644 index 00000000..0f016fc4 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/evaluator_agt.go @@ -0,0 +1,18 @@ +//go:build agteval + +package main + +import ( + "path/filepath" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge/agteval" +) + +func newAGTEvaluator(c config) (agtbridge.Evaluator, error) { + return agteval.New(agteval.Config{ + ManifestPath: filepath.Join(c.policyDir, filepath.FromSlash(c.manifest)), + OPAPath: c.opaPath, + OPATimeout: c.opaTimeout, + }) +} diff --git a/reference-implementations/agt-go/cmd/guardian/evaluator_agt_test.go b/reference-implementations/agt-go/cmd/guardian/evaluator_agt_test.go new file mode 100644 index 00000000..7ad057a8 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/evaluator_agt_test.go @@ -0,0 +1,68 @@ +//go:build agteval + +package main + +import ( + "context" + "os" + "testing" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge/agteval" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +// agtSelection proves the shipped configuration decides in AGT's runtime +// library: the native call counter rises with each decision, and the +// library reports the AGT commit agt-native.lock pins. +func agtSelection(t *testing.T, c config) { + evaluator, err := newEvaluator(context.Background(), c) + if err != nil { + t.Fatal(err) + } + native, ok := evaluator.(*agteval.Evaluator) + if !ok { + t.Fatalf("policy.evaluator agt built %T", evaluator) + } + engine, err := agtbridge.New(os.DirFS(c.policyDir), c.mapping, evaluator, agtbridge.Options{}) + if err != nil { + t.Fatal(err) + } + for command, want := range map[string]acs.Disposition{"ls": acs.Allow, "rm -rf /": acs.Deny} { + before := native.Calls() + payload, err := jsonv2.Marshal(map[string]any{ + "tool": map[string]any{"name": "Bash"}, + "arguments": map[string]any{"command": map[string]any{"value": command}}, + "raw_command": command, + }) + if err != nil { + t.Fatal(err) + } + d, err := engine.Decide(context.Background(), guardian.PolicyInput{Request: acs.Request{ + Method: acs.StepToolCallRequest, + Params: acs.Params{RequestID: "6a22a0f7-5547-448a-add7-3327aed144df", Payload: payload}, + }}) + if err != nil || d.Disposition != want { + t.Fatalf("%s: decision %+v, error %v; want %s", command, d.Decision, err, want) + } + if native.Calls() != before+1 { + t.Fatalf("%s was decided without a call into AGT's runtime library", command) + } + } + lock, err := os.ReadFile("../../agt-native.lock") + if err != nil { + t.Fatal(err) + } + var pin struct { + AGTRef string `json:"agt_ref"` + } + if err := jsonv2.Unmarshal(lock, &pin); err != nil { + t.Fatal(err) + } + if evaluator.AGTVersion() != pin.AGTRef { + t.Fatalf("the loaded library reports AGT %s, and agt-native.lock pins %s", evaluator.AGTVersion(), pin.AGTRef) + } +} diff --git a/reference-implementations/agt-go/cmd/guardian/evaluator_noagt.go b/reference-implementations/agt-go/cmd/guardian/evaluator_noagt.go new file mode 100644 index 00000000..99b34968 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/evaluator_noagt.go @@ -0,0 +1,13 @@ +//go:build !agteval + +package main + +import ( + "errors" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" +) + +func newAGTEvaluator(config) (agtbridge.Evaluator, error) { + return nil, errors.New("policy.evaluator is agt, but this guardian was built without the AGT evaluator: build it with make example-runtime, which builds AGT's runtime library and compiles with cgo and the agteval tag, or set policy.evaluator: go") +} diff --git a/reference-implementations/agt-go/cmd/guardian/evaluator_noagt_test.go b/reference-implementations/agt-go/cmd/guardian/evaluator_noagt_test.go new file mode 100644 index 00000000..6609e953 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/evaluator_noagt_test.go @@ -0,0 +1,15 @@ +//go:build !agteval + +package main + +import ( + "context" + "strings" + "testing" +) + +func agtSelection(t *testing.T, c config) { + if _, err := newEvaluator(context.Background(), c); err == nil || !strings.Contains(err.Error(), "built without the AGT evaluator") { + t.Fatalf("error %v, want a refusal naming the missing AGT evaluator", err) + } +} diff --git a/reference-implementations/agt-go/cmd/guardian/evaluator_test.go b/reference-implementations/agt-go/cmd/guardian/evaluator_test.go new file mode 100644 index 00000000..26c0b8fb --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/evaluator_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// loadReferenceConfig loads guardian.yaml with environment overrides, as the +// command does. +func loadReferenceConfig(t *testing.T, overrides ...string) (config, error) { + t.Helper() + configPath, err := filepath.Abs("../../guardian.yaml") + if err != nil { + t.Fatal(err) + } + secretFile := filepath.Join(t.TempDir(), "hmac-secret") + if err := os.WriteFile(secretFile, []byte(base64.StdEncoding.EncodeToString([]byte("01234567890123456789012345678901"))), 0o600); err != nil { + t.Fatal(err) + } + return loadConfig([]string{"--config", configPath}, append([]string{ + "ACS__SECURITY__HMAC_SECRET_FILE=" + secretFile, + "ACS__POLICY__OPA_PATH=.acs/agt/" + runtime.GOOS + "-" + runtime.GOARCH + "/bin", + }, overrides...)) +} + +// TestEvaluatorSelection: policy.evaluator has no default, the go evaluator +// runs in every build, and agt runs only in a build with the agteval tag; +// agtSelection checks the agt half for this build. +func TestEvaluatorSelection(t *testing.T) { + for name, tt := range map[string]struct { + overrides []string + want string + }{ + "missing": {[]string{"ACS__POLICY__EVALUATOR="}, `policy.evaluator must be "agt" or "go"`}, + "unknown": {[]string{"ACS__POLICY__EVALUATOR=rego"}, `policy.evaluator must be "agt" or "go"`}, + "agt_without_opa": {[]string{"ACS__POLICY__OPA_PATH="}, "policy.opa_path must name the opa executable"}, + "agt_zero_timeout": {[]string{"ACS__POLICY__OPA_TIMEOUT=0s"}, "policy.opa_timeout must be positive"}, + "agt_timeout_equal_to_decision": {[]string{"ACS__POLICY__OPA_TIMEOUT=5s", "ACS__PROTOCOL__DECISION_TIMEOUT=5s"}, "policy.opa_timeout must be shorter than protocol.decision_timeout"}, + "agt_timeout_longer_than_decision": {[]string{"ACS__POLICY__OPA_TIMEOUT=6s", "ACS__PROTOCOL__DECISION_TIMEOUT=5s"}, "policy.opa_timeout must be shorter than protocol.decision_timeout"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := loadReferenceConfig(t, tt.overrides...); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error %v, want %q", err, tt.want) + } + }) + } + t.Run("go", func(t *testing.T) { + c, err := loadReferenceConfig(t, "ACS__POLICY__EVALUATOR=go", "ACS__POLICY__OPA_PATH=") + if err != nil { + t.Fatal(err) + } + if _, err := newEvaluator(context.Background(), c); err != nil { + t.Fatal(err) + } + }) + t.Run("agt", func(t *testing.T) { + c, err := loadReferenceConfig(t) + if err != nil { + t.Fatal(err) + } + if c.evaluator != evaluatorAGT { + t.Fatalf("guardian.yaml selects %q, want the AGT evaluator", c.evaluator) + } + agtSelection(t, c) + }) +} diff --git a/reference-implementations/agt-go/cmd/guardian/main.go b/reference-implementations/agt-go/cmd/guardian/main.go new file mode 100644 index 00000000..c2fc8a8f --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/main.go @@ -0,0 +1,453 @@ +// Command guardian serves the ACS Guardian with AGT as its policy engine, +// over HTTP: POST /acs, GET /healthz and GET /readyz. policy.evaluator +// selects the AGT evaluator, which calls AGT's runtime library and needs +// the agteval build tag, or the Go evaluator. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io/fs" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/go-viper/mapstructure/v2" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env/v2" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/agtbridge" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +type fileConfig struct { + Server struct { + Host string `koanf:"host"` + Port string `koanf:"port"` + ReadHeaderTimeout time.Duration `koanf:"read_header_timeout"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownGrace time.Duration `koanf:"shutdown_grace"` + } `koanf:"server"` + Policy struct { + DeploymentDir string `koanf:"deployment_dir"` + Manifest string `koanf:"manifest"` + Mapping string `koanf:"mapping"` + Evaluator string `koanf:"evaluator"` + OPAPath string `koanf:"opa_path"` + OPATimeout time.Duration `koanf:"opa_timeout"` + AskSubstitution string `koanf:"ask_substitution"` + ToolAliases map[string]string `koanf:"tool_aliases"` + } `koanf:"policy"` + Security struct { + HMACKeyID string `koanf:"hmac_key_id"` + HMACSecretFile string `koanf:"hmac_secret_file"` + } `koanf:"security"` + Storage struct { + Provider string `koanf:"provider"` + } `koanf:"storage"` + Audit struct { + EnvelopeLog string `koanf:"envelope_log"` + EventLog string `koanf:"event_log"` + MaxFileBytes int64 `koanf:"max_file_bytes"` + MaxFiles int `koanf:"max_files"` + MaxQueueRecords int `koanf:"max_queue_records"` + MaxQueueBytes int64 `koanf:"max_queue_bytes"` + } `koanf:"audit"` + Protocol struct { + OnDecisionFailure string `koanf:"on_decision_failure"` + DecisionTimeout time.Duration `koanf:"decision_timeout"` + SkewWindow time.Duration `koanf:"skew_window"` + } `koanf:"protocol"` + Limits struct { + MaxSessions int `koanf:"max_sessions"` + MaxEntries int `koanf:"max_entries"` + MaxReservations int `koanf:"max_reservations"` + MaxSkillApprovals int `koanf:"max_skill_approvals"` + MaxEngineCalls int `koanf:"max_engine_calls"` + MaxPolicyStateBytes int `koanf:"max_policy_state_bytes"` + MaxBodyBytes int64 `koanf:"max_body_bytes"` + MaxDeferrals int `koanf:"max_deferrals"` + SessionRetention time.Duration `koanf:"session_retention"` + } `koanf:"limits"` +} + +type config struct { + path string + host, port string + posture acs.FailurePosture + envelopeLog string + eventLog string + auditRotation guardian.JSONLRotation + auditQueue guardian.JSONLQueue + storageProvider string + keyID string + secret []byte + policyDir string + manifest string + mapping string + evaluator string + opaPath string + opaTimeout time.Duration + askSubstitution guardian.AskSubstitution + toolAliases map[string]string + maxSessions int + maxEntries int + maxReservations int + maxSkillApprovals int + maxEngineCalls int + maxStateBytes int + maxBodyBytes int64 + maxDeferrals int + decisionTimeout time.Duration + skewWindow time.Duration + sessionRetention time.Duration + readHeaderTimeout time.Duration + readTimeout time.Duration + writeTimeout time.Duration + idleTimeout time.Duration + shutdownGrace time.Duration +} + +func main() { + if err := run(os.Args[1:], os.Environ()); err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + fmt.Fprintln(os.Stderr, "guardian:", err) + os.Exit(1) + } +} + +func loadConfig(args, environ []string) (config, error) { + flags := flag.NewFlagSet("guardian", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + configPath := flags.String("config", "", "path to the Guardian YAML configuration") + if err := flags.Parse(args); err != nil { + return config{}, err + } + if flags.NArg() != 0 { + return config{}, fmt.Errorf("unexpected argument %q", flags.Arg(0)) + } + if *configPath == "" { + return config{}, errors.New("--config is required") + } + absConfigPath, err := filepath.Abs(*configPath) + if err != nil { + return config{}, fmt.Errorf("resolve configuration path: %w", err) + } + + k := koanf.New(".") + if err := k.Load(file.Provider(absConfigPath), yaml.Parser()); err != nil { + return config{}, fmt.Errorf("load configuration %q: %w", absConfigPath, err) + } + if err := k.Load(env.Provider(".", env.Opt{ + Prefix: "ACS__", + TransformFunc: func(key, value string) (string, any) { + path := strings.ToLower(strings.TrimPrefix(key, "ACS__")) + return strings.ReplaceAll(path, "__", "."), value + }, + EnvironFunc: func() []string { return environ }, + }), nil); err != nil { + return config{}, fmt.Errorf("load ACS__ environment overrides: %w", err) + } + + var raw fileConfig + decoder := &mapstructure.DecoderConfig{ + Result: &raw, + TagName: "koanf", + ErrorUnused: true, + WeaklyTypedInput: true, + DecodeHook: mapstructure.StringToTimeDurationHookFunc(), + } + if err := k.UnmarshalWithConf("", &raw, koanf.UnmarshalConf{Tag: "koanf", DecoderConfig: decoder}); err != nil { + return config{}, fmt.Errorf("decode configuration %q: %w", absConfigPath, err) + } + + base := filepath.Dir(absConfigPath) + policyDir, err := resolvePath(base, raw.Policy.DeploymentDir) + if err != nil { + return config{}, fmt.Errorf("policy.deployment_dir: %w", err) + } + manifest, err := policyPath("policy.manifest", raw.Policy.Manifest) + if err != nil { + return config{}, err + } + mapping, err := policyPath("policy.mapping", raw.Policy.Mapping) + if err != nil { + return config{}, err + } + var opaPath string + if raw.Policy.OPAPath != "" { + if opaPath, err = resolvePath(base, raw.Policy.OPAPath); err != nil { + return config{}, fmt.Errorf("policy.opa_path: %w", err) + } + } + secretPath, err := resolvePath(base, raw.Security.HMACSecretFile) + if err != nil { + return config{}, fmt.Errorf("security.hmac_secret_file: %w", err) + } + secret, err := guardian.ReadHMACSecretFile(secretPath) + if err != nil { + return config{}, fmt.Errorf("security.hmac_secret_file %q: %w", secretPath, err) + } + envelopeLog, err := resolvePath(base, raw.Audit.EnvelopeLog) + if err != nil { + return config{}, fmt.Errorf("audit.envelope_log: %w", err) + } + eventLog, err := resolvePath(base, raw.Audit.EventLog) + if err != nil { + return config{}, fmt.Errorf("audit.event_log: %w", err) + } + + c := config{ + path: absConfigPath, host: raw.Server.Host, port: raw.Server.Port, + posture: acs.FailurePosture(raw.Protocol.OnDecisionFailure), + envelopeLog: envelopeLog, eventLog: eventLog, + auditRotation: guardian.JSONLRotation{MaxFileBytes: raw.Audit.MaxFileBytes, MaxFiles: raw.Audit.MaxFiles}, + auditQueue: guardian.JSONLQueue{MaxQueueRecords: raw.Audit.MaxQueueRecords, MaxQueueBytes: raw.Audit.MaxQueueBytes}, + storageProvider: raw.Storage.Provider, + keyID: raw.Security.HMACKeyID, secret: secret, + policyDir: policyDir, manifest: manifest, mapping: mapping, + evaluator: raw.Policy.Evaluator, opaPath: opaPath, opaTimeout: raw.Policy.OPATimeout, + askSubstitution: guardian.AskSubstitution(raw.Policy.AskSubstitution), + toolAliases: raw.Policy.ToolAliases, + maxSessions: raw.Limits.MaxSessions, maxEntries: raw.Limits.MaxEntries, maxReservations: raw.Limits.MaxReservations, + maxSkillApprovals: raw.Limits.MaxSkillApprovals, maxEngineCalls: raw.Limits.MaxEngineCalls, + maxStateBytes: raw.Limits.MaxPolicyStateBytes, maxBodyBytes: raw.Limits.MaxBodyBytes, maxDeferrals: raw.Limits.MaxDeferrals, + decisionTimeout: raw.Protocol.DecisionTimeout, skewWindow: raw.Protocol.SkewWindow, + sessionRetention: raw.Limits.SessionRetention, + readHeaderTimeout: raw.Server.ReadHeaderTimeout, readTimeout: raw.Server.ReadTimeout, + writeTimeout: raw.Server.WriteTimeout, idleTimeout: raw.Server.IdleTimeout, shutdownGrace: raw.Server.ShutdownGrace, + } + if err := c.validate(); err != nil { + return config{}, err + } + return c, nil +} + +func resolvePath(base, value string) (string, error) { + if value == "" { + return "", errors.New("must not be empty") + } + if filepath.IsAbs(value) { + return filepath.Clean(value), nil + } + return filepath.Join(base, value), nil +} + +func policyPath(name, value string) (string, error) { + if value == "" { + return "", fmt.Errorf("%s must not be empty", name) + } + path := filepath.ToSlash(value) + if filepath.IsAbs(value) || !fs.ValidPath(path) || path == "." { + return "", fmt.Errorf("%s must be a non-empty path inside policy.deployment_dir", name) + } + return path, nil +} + +// storageMemory selects guardian.MemorySessionContextStore: sessions live in +// the process and are lost when it stops, and one process serves them. +const storageMemory = "memory" + +func (c config) validate() error { + var errs []error + if c.host == "" { + errs = append(errs, errors.New("server.host must not be empty")) + } + if c.port == "" { + errs = append(errs, errors.New("server.port must not be empty")) + } + if c.keyID == "" { + errs = append(errs, errors.New("security.hmac_key_id must not be empty")) + } + if c.storageProvider != storageMemory { + errs = append(errs, fmt.Errorf("storage.provider must be %q, the one provider this command ships; a host that embeds the guardian package passes its own SessionContextStore", storageMemory)) + } + if err := c.auditRotation.Validate(); err != nil { + errs = append(errs, fmt.Errorf("audit: %w", err)) + } + if c.auditQueue.MaxQueueRecords <= 0 { + errs = append(errs, errors.New("audit.max_queue_records must be positive")) + } + if need := guardian.JSONLRecordBytes(c.maxBodyBytes); c.auditQueue.MaxQueueBytes < need { + errs = append(errs, fmt.Errorf("audit.max_queue_bytes must be at least %d to hold one request of limits.max_body_bytes", need)) + } + if c.posture != acs.FailureProceed && c.posture != acs.FailureDeny { + errs = append(errs, fmt.Errorf("protocol.on_decision_failure must be %q or %q", acs.FailureProceed, acs.FailureDeny)) + } + switch c.evaluator { + case evaluatorGo: + case evaluatorAGT: + if c.opaPath == "" { + errs = append(errs, errors.New("policy.opa_path must name the opa executable the agt evaluator runs")) + } + if c.opaTimeout <= 0 { + errs = append(errs, errors.New("policy.opa_timeout must be positive for the agt evaluator")) + } + if c.opaTimeout >= c.decisionTimeout { + errs = append(errs, errors.New("policy.opa_timeout must be shorter than protocol.decision_timeout, or the agent stops waiting before AGT's deny for a slow opa arrives")) + } + default: + errs = append(errs, fmt.Errorf("policy.evaluator must be %q or %q", evaluatorAGT, evaluatorGo)) + } + if c.askSubstitution != guardian.AskSubstitutionNone && c.askSubstitution != guardian.AskSubstitutionDeny && c.askSubstitution != guardian.AskSubstitutionDefer { + errs = append(errs, errors.New("policy.ask_substitution must be none, deny or defer")) + } + if c.decisionTimeout <= 0 { + errs = append(errs, errors.New("protocol.decision_timeout must be positive")) + } + if c.skewWindow <= 0 { + errs = append(errs, errors.New("protocol.skew_window must be positive")) + } + minimumRetention := c.skewWindow + c.skewWindow + if minimumRetention < c.skewWindow || c.sessionRetention < minimumRetention { + errs = append(errs, errors.New("limits.session_retention must be at least twice protocol.skew_window")) + } + if c.writeTimeout <= c.decisionTimeout { + errs = append(errs, errors.New("server.write_timeout must exceed protocol.decision_timeout, or a decision in time is cut off before it is sent")) + } + for name, value := range map[string]int{ + "limits.max_sessions": c.maxSessions, + "limits.max_entries": c.maxEntries, + "limits.max_reservations": c.maxReservations, + "limits.max_skill_approvals": c.maxSkillApprovals, + "limits.max_engine_calls": c.maxEngineCalls, + "limits.max_policy_state_bytes": c.maxStateBytes, + "limits.max_deferrals": c.maxDeferrals, + } { + if value <= 0 { + errs = append(errs, fmt.Errorf("%s must be positive", name)) + } + } + if c.maxBodyBytes <= 0 { + errs = append(errs, errors.New("limits.max_body_bytes must be positive")) + } + for name, value := range map[string]time.Duration{ + "server.read_header_timeout": c.readHeaderTimeout, + "server.read_timeout": c.readTimeout, + "server.idle_timeout": c.idleTimeout, + "server.shutdown_grace": c.shutdownGrace, + } { + if value <= 0 { + errs = append(errs, fmt.Errorf("%s must be positive", name)) + } + } + return errors.Join(errs...) +} + +func run(args, environ []string) (err error) { + c, err := loadConfig(args, environ) + if err != nil { + return err + } + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: c.keyID, Secret: c.secret}) + if err != nil { + return err + } + audit, err := guardian.NewJSONLAuditLog(c.envelopeLog, c.eventLog, c.auditRotation, c.auditQueue, func(err error) { + fmt.Fprintln(os.Stderr, "guardian: audit log:", err) + }) + if err != nil { + return err + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), c.shutdownGrace) + defer cancel() + err = errors.Join(err, audit.Close(ctx)) + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + evaluator, err := newEvaluator(ctx, c) + if err != nil { + return err + } + engine, err := agtbridge.New(os.DirFS(c.policyDir), c.mapping, evaluator, agtbridge.Options{ToolAliases: c.toolAliases}) + if err != nil { + return err + } + g, err := guardian.New(guardian.Config{ + Engine: engine, Signer: signer, AuditLog: audit, + MaxSessions: c.maxSessions, MaxEntries: c.maxEntries, MaxReservations: c.maxReservations, MaxSkillApprovals: c.maxSkillApprovals, SessionRetention: c.sessionRetention, + MaxEngineCalls: c.maxEngineCalls, MaxPolicyStateBytes: c.maxStateBytes, + MaxDeferrals: c.maxDeferrals, + AskSubstitution: c.askSubstitution, + DecisionTimeout: c.decisionTimeout, SkewWindow: c.skewWindow, + OnDecisionFailure: c.posture, MaxBodyBytes: c.maxBodyBytes, + }) + if err != nil { + return err + } + + listener, err := net.Listen("tcp", net.JoinHostPort(c.host, c.port)) + if err != nil { + return err + } + endpoints := &guardian.Endpoints{} + server := &http.Server{ + Handler: endpoints, ReadHeaderTimeout: c.readHeaderTimeout, ReadTimeout: c.readTimeout, + WriteTimeout: c.writeTimeout, IdleTimeout: c.idleTimeout, + } + served := make(chan error, 1) + go func() { served <- server.Serve(listener) }() + endpoints.Ready(g) + fmt.Printf("Guardian listening at http://%s/acs\n", listener.Addr()) + fmt.Printf("Configuration: %s\n", c.path) + fmt.Printf("Policy deployment: %s\n", c.policyDir) + fmt.Printf("Policy evaluator: %s, AGT %s\n", c.evaluator, evaluator.AGTVersion()) + fmt.Printf("Envelope log: %s\n", c.envelopeLog) + fmt.Printf("Event log: %s\n", c.eventLog) + fmt.Printf("Failure posture: %s\n", c.posture) + + select { + case err := <-served: + return err + case <-ctx.Done(): + } + return shutdown(server, g, c.shutdownGrace, c.shutdownGrace) +} + +// shutdown lets decisions in flight finish within decisionGrace, then gives +// their answers, cancellation denials included, deliveryGrace to be written. +func shutdown(server *http.Server, g *guardian.Guardian, decisionGrace, deliveryGrace time.Duration) error { + serverContext, stopServer := context.WithCancel(context.Background()) + closed := make(chan error, 1) + go func() { closed <- server.Shutdown(serverContext) }() + + decisionContext, stopDecisions := context.WithTimeout(context.Background(), decisionGrace) + guardianErr := g.Shutdown(decisionContext) + stopDecisions() + if guardianErr != nil && !errors.Is(guardianErr, context.DeadlineExceeded) { + stopServer() + _ = server.Close() + return guardianErr + } + + deliveryContext, stopDelivery := context.WithTimeout(context.Background(), deliveryGrace) + defer stopDelivery() + select { + case err := <-closed: + stopServer() + return err + case <-deliveryContext.Done(): + stopServer() + if err := server.Close(); err != nil { + return err + } + <-closed + return nil + } +} diff --git a/reference-implementations/agt-go/cmd/guardian/main_test.go b/reference-implementations/agt-go/cmd/guardian/main_test.go new file mode 100644 index 00000000..75e3b44e --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/main_test.go @@ -0,0 +1,317 @@ +package main_test + +import ( + "bufio" + "context" + "encoding/base64" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/handshake" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" +) + +const secret = "0123456789abcdef0123456789abcdef" + +// build compiles the command with the build tags of the test binary. +func build(t *testing.T) string { + t.Helper() + if bin := os.Getenv("ACS_TEST_GUARDIAN_BINARY"); bin != "" { + info, err := os.Stat(bin) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("ACS_TEST_GUARDIAN_BINARY is not a file: %q (%v)", bin, err) + } + return bin + } + bin := filepath.Join(t.TempDir(), "guardian") + if runtime.GOOS == "windows" { + bin += ".exe" + } + out, err := exec.Command("go", append(append([]string{"build"}, buildFlags...), "-o", bin, ".")...).CombinedOutput() + if err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + return bin +} + +type process struct { + cmd *exec.Cmd + url string + logs string + done chan error +} + +func referenceConfig(t *testing.T) string { + t.Helper() + path, err := filepath.Abs("../../guardian.yaml") + if err != nil { + t.Fatal(err) + } + return path +} + +// start runs the binary from dir and waits for its banner. The configuration +// comes from the module, while every mutable test value is an ACS__ override. +func start(t *testing.T, bin, dir string, env ...string) *process { + t.Helper() + logs := t.TempDir() + secretFile := filepath.Join(logs, "hmac-secret") + if err := os.WriteFile(secretFile, []byte(base64.StdEncoding.EncodeToString([]byte(secret))), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(bin, "--config", referenceConfig(t)) + prepareProcess(cmd) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "ACS__SERVER__PORT=0", + "ACS__SECURITY__HMAC_SECRET_FILE="+secretFile, + "ACS__AUDIT__ENVELOPE_LOG="+filepath.Join(logs, "envelopes.jsonl"), + "ACS__AUDIT__EVENT_LOG="+filepath.Join(logs, "events.jsonl"), + ) + cmd.Env = append(cmd.Env, evaluatorEnv...) + cmd.Env = append(cmd.Env, env...) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + p := &process{cmd: cmd, logs: logs, done: make(chan error, 1)} + banner := regexp.MustCompile(`^Guardian listening at (http://\S+)/acs$`) + lines := bufio.NewScanner(stdout) + for lines.Scan() { + if m := banner.FindStringSubmatch(lines.Text()); m != nil { + p.url = m[1] + break + } + } + go func() { + _, _ = io.Copy(io.Discard, stdout) + p.done <- cmd.Wait() + }() + if p.url == "" { + t.Fatal("the Guardian printed no banner") + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + return p +} + +func (p *process) client(t *testing.T) *observedagent.Client { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "2026-10", Secret: []byte(secret)}) + if err != nil { + t.Fatal(err) + } + return &observedagent.Client{ + Signer: signer, AgentID: "agent", SessionID: observedagent.NewUUID(), + Transport: func(ctx context.Context, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url+"/acs", strings.NewReader(string(body))) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) + }, + } +} + +func shell(command string) acs.ToolCallRequestPayload { + return acs.ToolCallRequestPayload{ + Tool: acs.Tool{Name: "Bash"}, + Arguments: map[string]acs.ToolArgument{"command": {Value: []byte(`"` + command + `"`)}}, + RawCommand: &command, + } +} + +// TestServesDecisions runs the command from the module directory and an +// unrelated directory, and asks for the reference policy's two decisions. +func TestServesDecisions(t *testing.T) { + bin := build(t) + for _, dir := range []string{".", t.TempDir()} { + t.Run(filepath.Base(mustAbs(t, dir)), func(t *testing.T) { + p := start(t, bin, dir) + for path, want := range map[string]int{"/healthz": 200, "/readyz": 200, "/nothing": 404} { + resp, err := http.Get(p.url + path) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("%s answered %d, want %d", path, resp.StatusCode, want) + } + } + c := p.client(t) + ctx := context.Background() + if o, err := c.Handshake(ctx, observedagent.DefaultHello(handshake.CoreMethods()...)); err != nil || o.Hello == nil || !o.Verified { + t.Fatalf("handshake: %v %s", err, o.Raw) + } + for command, want := range map[string]acs.Disposition{"echo rm -rf /": acs.Deny, "ls -la": acs.Allow} { + o, err := c.Send(ctx, observedagent.Request{Method: acs.StepToolCallRequest, Payload: shell(command)}) + if err != nil { + t.Fatal(err) + } + if o.Result == nil || !o.Verified || o.Result.Disposition != want || o.Result.ChainHash == "" { + t.Fatalf("%s: got %s", command, o.Raw) + } + } + }) + } +} + +func mustAbs(t *testing.T, dir string) string { + abs, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + return abs +} + +func TestRefusesToStart(t *testing.T) { + bin := build(t) + tests := []struct { + name, secret, want string + args []string + env []string + }{ + {"configuration_required", "", "--config is required", nil, nil}, + {"short_secret", base64.StdEncoding.EncodeToString([]byte("short")), "decodes to 5 bytes, fewer than 32", []string{"--config", referenceConfig(t)}, nil}, + {"secret_not_base64", "not base64!", "is not standard base64", []string{"--config", referenceConfig(t)}, nil}, + {"write_timeout_within_decision", base64.StdEncoding.EncodeToString([]byte(secret)), "server.write_timeout must exceed protocol.decision_timeout", []string{"--config", referenceConfig(t)}, []string{"ACS__SERVER__WRITE_TIMEOUT=5s"}}, + {"non_positive_deferral_limit", base64.StdEncoding.EncodeToString([]byte(secret)), "limits.max_deferrals must be positive", []string{"--config", referenceConfig(t)}, []string{"ACS__LIMITS__MAX_DEFERRALS=0"}}, + {"unknown_ask_substitution", base64.StdEncoding.EncodeToString([]byte(secret)), "policy.ask_substitution must be none, deny or defer", []string{"--config", referenceConfig(t)}, []string{"ACS__POLICY__ASK_SUBSTITUTION=ask"}}, + {"unknown_override", base64.StdEncoding.EncodeToString([]byte(secret)), "invalid keys", []string{"--config", referenceConfig(t)}, []string{"ACS__NO__SUCH__SETTING=value"}}, + {"unknown_storage_provider", base64.StdEncoding.EncodeToString([]byte(secret)), `storage.provider must be "memory"`, []string{"--config", referenceConfig(t)}, []string{"ACS__STORAGE__PROVIDER=redis"}}, + {"negative_audit_rotation", base64.StdEncoding.EncodeToString([]byte(secret)), "audit: max_file_bytes and max_files cannot be negative", []string{"--config", referenceConfig(t)}, []string{"ACS__AUDIT__MAX_FILES=-1"}}, + {"audit_files_without_a_size", base64.StdEncoding.EncodeToString([]byte(secret)), "audit: max_files needs max_file_bytes", []string{"--config", referenceConfig(t)}, []string{"ACS__AUDIT__MAX_FILE_BYTES=0"}}, + {"audit_size_without_files", base64.StdEncoding.EncodeToString([]byte(secret)), "audit: max_file_bytes needs max_files", []string{"--config", referenceConfig(t)}, []string{"ACS__AUDIT__MAX_FILES=0"}}, + {"non_positive_audit_queue_records", base64.StdEncoding.EncodeToString([]byte(secret)), "audit.max_queue_records must be positive", []string{"--config", referenceConfig(t)}, []string{"ACS__AUDIT__MAX_QUEUE_RECORDS=0"}}, + {"audit_queue_smaller_than_a_request", base64.StdEncoding.EncodeToString([]byte(secret)), "audit.max_queue_bytes must be at least 2097408 to hold one request of limits.max_body_bytes", []string{"--config", referenceConfig(t)}, []string{"ACS__AUDIT__MAX_QUEUE_BYTES=2097407"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.Command(bin, tt.args...) + cmd.Env = append(os.Environ(), tt.env...) + if tt.secret != "" { + secretFile := filepath.Join(t.TempDir(), "hmac-secret") + if err := os.WriteFile(secretFile, []byte(tt.secret), 0o600); err != nil { + t.Fatal(err) + } + cmd.Env = append(cmd.Env, "ACS__SECURITY__HMAC_SECRET_FILE="+secretFile) + } + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), tt.want) { + t.Fatalf("exit %v, output %q", err, out) + } + }) + } +} + +// TestLifecycle sends a termination signal while a request is still +// arriving, and expects the request to be answered and the process to exit +// zero. +func TestLifecycle(t *testing.T) { + bin := build(t) + p := start(t, bin, ".") + c := p.client(t) + ctx := context.Background() + if o, err := c.Handshake(ctx, observedagent.DefaultHello(handshake.CoreMethods()...)); err != nil || o.Hello == nil { + t.Fatalf("handshake: %v", err) + } + body, err := c.Envelope(ctx, observedagent.Request{Method: acs.StepToolCallRequest, Payload: shell("ls")}) + if err != nil { + t.Fatal(err) + } + conn, err := net.Dial("tcp", strings.TrimPrefix(p.url, "http://")) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + head := "POST /acs HTTP/1.1\r\nHost: guardian\r\nContent-Type: application/json\r\nContent-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n" + half := len(body) / 2 + if _, err := conn.Write([]byte(head + string(body[:half]))); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + if err := stopProcess(p.cmd); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + if _, err := conn.Write(body[half:]); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatalf("the in-flight request got no answer: %v", err) + } + raw, _ := io.ReadAll(resp.Body) + o, err := c.Read(ctx, raw) + if err != nil || o.Result == nil || o.Result.Disposition != acs.Allow { + t.Fatalf("the in-flight request got %s (%v)", raw, err) + } + select { + case err := <-p.done: + if err != nil { + t.Fatalf("exit: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("the Guardian did not exit after the signal") + } + body, readErr := os.ReadFile(filepath.Join(p.logs, "envelopes.jsonl")) + if readErr != nil { + t.Fatalf("read envelope log: %v", readErr) + } + if records := strings.Count(string(body), "\n"); records != 4 { + t.Fatalf("shutdown flushed %d envelope records, want all 4: %q", records, body) + } + if _, err := os.Stat(filepath.Join(p.logs, "events.jsonl")); err != nil { + t.Fatalf("shutdown did not close the event log: %v", err) + } +} + +// A request whose body never finishes arriving cannot hold the process: at +// the grace the server closes the connection and the Guardian exits. +func TestShutdownClosesAStalledRequest(t *testing.T) { + bin := build(t) + p := start(t, bin, ".", "ACS__SERVER__SHUTDOWN_GRACE=300ms") + conn, err := net.Dial("tcp", strings.TrimPrefix(p.url, "http://")) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.Write([]byte("POST /acs HTTP/1.1\r\nHost: guardian\r\nContent-Length: 1000\r\n\r\n{")); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + signalled := time.Now() + if err := stopProcess(p.cmd); err != nil { + t.Fatal(err) + } + select { + case <-p.done: + // Exiting before the grace would mean the request was never in + // flight, and the test would prove nothing. + if waited := time.Since(signalled); waited < 250*time.Millisecond || waited > 3*time.Second { + t.Fatalf("exited %s after the signal, want the 300ms grace", waited) + } + case <-time.After(5 * time.Second): + t.Fatal("a stalled request body held the Guardian past its grace") + } +} diff --git a/reference-implementations/agt-go/cmd/guardian/process_unix_test.go b/reference-implementations/agt-go/cmd/guardian/process_unix_test.go new file mode 100644 index 00000000..89e912a2 --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/process_unix_test.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main_test + +import ( + "os/exec" + "syscall" +) + +func prepareProcess(cmd *exec.Cmd) {} + +func stopProcess(cmd *exec.Cmd) error { + return cmd.Process.Signal(syscall.SIGTERM) +} diff --git a/reference-implementations/agt-go/cmd/guardian/process_windows_test.go b/reference-implementations/agt-go/cmd/guardian/process_windows_test.go new file mode 100644 index 00000000..6d654bda --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/process_windows_test.go @@ -0,0 +1,55 @@ +package main_test + +import ( + "fmt" + "os" + "os/exec" + "os/signal" + "strconv" + "syscall" + "testing" + + "golang.org/x/sys/windows" +) + +func prepareProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_CONSOLE} +} + +func stopProcess(cmd *exec.Cmd) error { + path, err := os.Executable() + if err != nil { + return err + } + helper := exec.Command(path) + helper.Env = append(os.Environ(), "ACS_TEST_INTERRUPT_PID="+strconv.Itoa(cmd.Process.Pid)) + helper.Stdout = os.Stdout + helper.Stderr = os.Stderr + if err := helper.Run(); err != nil { + return fmt.Errorf("send console interrupt: %w", err) + } + return nil +} + +func TestMain(m *testing.M) { + if value := os.Getenv("ACS_TEST_INTERRUPT_PID"); value != "" { + pid, err := strconv.ParseUint(value, 10, 32) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + kernel := windows.NewLazySystemDLL("kernel32.dll") + _, _, _ = kernel.NewProc("FreeConsole").Call() + if result, _, err := kernel.NewProc("AttachConsole").Call(uintptr(pid)); result == 0 { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + signal.Ignore(os.Interrupt) + if err := windows.GenerateConsoleCtrlEvent(windows.CTRL_C_EVENT, 0); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + os.Exit(m.Run()) +} diff --git a/reference-implementations/agt-go/cmd/guardian/shutdown_test.go b/reference-implementations/agt-go/cmd/guardian/shutdown_test.go new file mode 100644 index 00000000..9fa4e6ab --- /dev/null +++ b/reference-implementations/agt-go/cmd/guardian/shutdown_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "slices" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" +) + +type cancellationEngine struct { + started chan struct{} +} + +const shutdownTestSecret = "0123456789abcdef0123456789abcdef" + +func (e *cancellationEngine) Policy() guardian.PolicyDescription { + return guardian.PolicyDescription{} +} + +func (e *cancellationEngine) Decide(ctx context.Context, _ guardian.PolicyInput) (guardian.PolicyDecision, error) { + close(e.started) + <-ctx.Done() + return guardian.PolicyDecision{}, ctx.Err() +} + +// The decision grace is short, so the engine's decision is cancelled; the +// denial then has guardian.yaml's shutdown_grace to reach the agent. +func TestShutdownDeliversCancellationDenial(t *testing.T) { + c, err := loadReferenceConfig(t, "ACS__POLICY__EVALUATOR=go", "ACS__POLICY__OPA_PATH=") + if err != nil { + t.Fatal(err) + } + for range 5 { + engine := &cancellationEngine{started: make(chan struct{})} + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k1", Secret: []byte(shutdownTestSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: engine, Signer: signer, DecisionTimeout: time.Minute}) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := &http.Server{Handler: g} + served := make(chan error, 1) + go func() { served <- server.Serve(listener) }() + client := &observedagent.Client{ + Signer: signer, AgentID: "agent", SessionID: observedagent.NewUUID(), + Transport: func(ctx context.Context, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+listener.Addr().String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + response, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + return io.ReadAll(response.Body) + }, + } + if outcome, err := client.Handshake(context.Background(), observedagent.DefaultHello(acs.StepToolCallRequest)); err != nil || outcome.Hello == nil { + t.Fatalf("handshake: %v %s", err, outcome.Raw) + } + answer := make(chan observedagent.Outcome, 1) + transportErr := make(chan error, 1) + go func() { + outcome, err := client.Send(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: acs.ToolCallRequestPayload{ + Tool: acs.Tool{Name: "Bash"}, Arguments: map[string]acs.ToolArgument{"command": {Value: []byte(`"ls"`)}}, + }}) + answer <- outcome + transportErr <- err + }() + <-engine.started + if err := shutdown(server, g, 20*time.Millisecond, c.shutdownGrace); err != nil { + t.Fatal(err) + } + if err := <-transportErr; err != nil { + t.Fatalf("request lost during shutdown: %v", err) + } + outcome := <-answer + if outcome.Result == nil || !outcome.Verified || outcome.Result.Disposition != acs.Deny || + !slices.Contains(outcome.Result.ReasonCodes, disposition.ReasonShuttingDown) { + t.Fatalf("shutdown answer: %s", outcome.Raw) + } + if err := <-served; err != nil && err != http.ErrServerClosed { + t.Fatal(err) + } + } +} diff --git a/reference-implementations/agt-go/docs/architecture.md b/reference-implementations/agt-go/docs/architecture.md new file mode 100644 index 00000000..ba12c1b7 --- /dev/null +++ b/reference-implementations/agt-go/docs/architecture.md @@ -0,0 +1,320 @@ +# Architecture + +## What the Guardian is made of + +The Guardian is the core. It implements the ACS rules: the handshake, signature +checks, replay protection, session ordering, the hash chain, the nineteen hooks +and wrapped MCP. Everything ACS leaves open sits behind four +interfaces, which are also the extension points. + +```mermaid +flowchart LR + Codex["Codex"] -->|runs| CH["acs-codex-hook"] + OpenCode["OpenCode"] -->|"plugin acs.ts runs"| OH["acs-opencode-hook"] + CH --> HA["hostadapter.Client, sending through observedagent.Client"] + OH --> HA + HA -->|"POST /acs, signed request"| G["guardian.Guardian"] + G -.->|"signed decision"| HA + G --> S["Signer"] + G --> SC["SessionContextStore"] + G --> AL["AuditLog"] + G --> PE["PolicyEngine"] +``` + +Solid arrows are calls. The dashed arrow is the signed decision coming back. + +### The four interfaces + +| Interface | Declared in | What ACS leaves open | Implementation in this module | +| --- | --- | --- | --- | +| `PolicyEngine` | [`guardian/policy_engine.go`](../guardian/policy_engine.go) | The policy decision (§12.1). | `agtbridge.Engine`, deciding with AGT's runtime library | +| `Signer` | [`guardian/signer.go`](../guardian/signer.go) | Keys and signatures (§10). | `guardian.HMACSigner`, HMAC-SHA256 | +| `SessionContextStore` | [`guardian/session_context_store.go`](../guardian/session_context_store.go) | Where session state and its hash chain live (§8). | `guardian.MemorySessionContextStore`, bounded and in memory | +| `AuditLog` | [`guardian/audit_log.go`](../guardian/audit_log.go) | Where the audit records go. | `guardian.JSONLAuditLog`, JSONL files | + +Your own implementation can replace any of them without changing the core. The Guardian +package does not depend on AGT: `agtbridge` is the engine the standalone command +plugs in. [Extend the Guardian](extending.md) lists what your own implementation of each interface must do. + +### How AGT is used + +The default Go Guardian build decides with AGT's runtime itself. AGT ships that +runtime as a Rust library with a C ABI and no Go binding, so the AGT evaluator +loads the library into the Guardian process and calls it through cgo. The +TypeScript reference reaches the same crate version through AGT's Node package. + +`agtbridge.Engine` holds one projection and one evaluator. The projection is +what a host does around AGT: it keeps the session's labels, assembles the +snapshot, supplies the `egress` annotator and turns each verdict into an ACS +decision through `mapping.yaml`. The evaluator runs AGT's policy for that +snapshot. `policy.evaluator` selects it: + +| Evaluator | Package | What decides | Build | +| --- | --- | --- | --- | +| `agt` | [`agtbridge/agteval`](../agtbridge/agteval/) | AGT's runtime library at the commit [`agt-native.lock`](../agt-native.lock) pins, the same commit `../agt/agt.lock` pins for the policy bundle. At that commit AGT runs each Rego decision in the `opa` program. | cgo and the `agteval` build tag. `make agt-native` builds the library and fetches OPA. Platform toolchains are listed in [Build and package](building.md). | +| `go` | [`agtbridge/goeval`](../agtbridge/goeval/) | A Go reproduction of the part of AGT's runtime the checked-in manifest uses, running the same Rego policies with OPA's Go library. A manifest feature outside that part stops startup. | Pure Go, any platform. | + +```mermaid +flowchart LR + G[guardian.Guardian] --> E[agtbridge.Engine] + E --> A[agteval.Evaluator] + E --> R[goeval.Evaluator] + A -->|C ABI| L[AGT runtime library] + L --> O[opa] + L -->|annotator callback| X[agtbridge.AnnotateEgress] + R --> X + R --> Q[OPA Go library] +``` + +`agtbridge.TestEvaluatorsAgree` runs every recorded case through both +evaluators. A step they decide differently fails the test unless it is listed, +with its reason, for that pair of AGT versions. [Differences](differences.md#agt) +lists the boundary. + +A call into the library cannot be interrupted. When the decision timeout ends +first, the Guardian answers DENY and keeps the call's place among +`limits.max_engine_calls` until it returns. The one runtime handle serves every +call and is never freed. A program that imports `guardian` or `agtbridge` links +no native library unless it imports `agtbridge/agteval`. + +## How a request moves through the Guardian + +Every participant and call on these diagrams is a type or method in this module. + +### The store calls on these diagrams + +| Call | Why | +| --- | --- | +| `Lock` | Only one step of a session runs at a time, across every Guardian sharing the store, until `release`. | +| `Load` | Reads the session while it is locked, so it cannot change underneath. | +| `Negotiate` | Saves a new session's handshake terms. | +| `Append` | Records the step in the chain before it is decided, and rejects a repeated request. | +| `SkillApproved` | Tells whether a skill with this digest was approved at registration. | +| `Conclude` | Keeps what the decision leaves for later steps. Skipped when there is nothing to keep. | + +### The handshake + +Every session starts with `handshake/hello`. The PolicyEngine is not asked. + +```mermaid +sequenceDiagram + participant OA as observedagent.Client + participant G as guardian.Guardian + participant AL as AuditLog + participant S as Signer + participant SC as SessionContextStore + OA->>G: handshake/hello with a ClientHello + G->>AL: Envelope(inbound) + G->>S: Verify + G->>SC: Lock + G->>SC: Load + alt no session yet + Note over G: negotiates the terms from the ClientHello and its own offer + G->>SC: Negotiate: save the ClientHello and the ServerHello + else the session exists + Note over G: the same ClientHello, key and agent get the saved ServerHello back, anything else is refused + end + G->>SC: release + G->>S: Sign + G->>AL: Envelope(outbound) + G-->>OA: ServerHello +``` + +### One step, in order + +Every step takes the same path, in four stages. + +```mermaid +sequenceDiagram + participant OA as observedagent.Client + participant G as guardian.Guardian + participant AL as AuditLog + participant S as Signer + participant SC as SessionContextStore + participant PE as PolicyEngine + rect rgba(128, 128, 128, 0.08) + Note over OA,S: A. Receive and authenticate + OA->>G: signed request + G->>AL: Envelope(inbound) + G->>S: Verify + end + rect rgba(128, 128, 128, 0.16) + Note over G,SC: B. Take the session + G->>SC: Lock + G->>SC: Load + end + rect rgba(128, 128, 128, 0.08) + Note over G,PE: C. Record and decide + Note over G: ACS rules that deny without the policy, see below + G->>SC: Append + opt no ACS rule denied the step + G->>PE: Decide + PE-->>G: PolicyDecision + end + opt the decision leaves something to keep + G->>SC: Conclude + end + G->>SC: release + end + rect rgba(128, 128, 128, 0.16) + Note over OA,S: D. Answer + G->>S: Sign + G->>AL: Envelope(outbound) + G-->>OA: signed decision + end +``` + +### ACS rules that deny without the policy + +```mermaid +flowchart LR + A["before Append"] --> B{"Intent differs from
the one set earlier?"} + B -->|no| C{"chain_hash differs from
the session head?"} + C -->|no| D{"skillLoad without an
approved skillRegister?"} + D -->|no| E["Append, then Decide"] + B -->|yes| X["DENY intent_mismatch"] + C -->|yes| Y["DENY chain_mismatch"] + D -->|yes| Z["DENY skill_unverifiable"] + X & Y & Z --> F["Append, no Decide"] +``` + +Three more steps get their own handling: `steps/sessionEnd` closes the session, a +`steps/postCompact` summary has its lineage checked, and a `protocols/MCP/*` message is +checked by the MCP handler. + +### The skill scenario + +```mermaid +sequenceDiagram + participant OA as observedagent.Client + participant G as guardian.Guardian + participant SC as SessionContextStore + participant PE as PolicyEngine + Note over OA,PE: Install the skill, once + OA->>G: steps/skillRegister(skill_id, digest) + G->>PE: Decide + PE-->>G: ALLOW or DENY + opt ALLOW + G->>SC: Conclude: save the approval of skill_id and digest + end + G-->>OA: ALLOW or DENY + Note over OA,PE: Load the skill, in this or any later session + OA->>G: steps/skillLoad(skill_id, digest) + G->>SC: SkillApproved(skill_id, digest) + alt approved + G->>PE: Decide + PE-->>G: ALLOW or DENY + G-->>OA: ALLOW or DENY + else not approved + G-->>OA: DENY skill_unverifiable + end +``` + +### The ASK scenario + +```mermaid +sequenceDiagram + participant OA as observedagent.Client + participant G as guardian.Guardian + participant SC as SessionContextStore + participant PE as PolicyEngine + OA->>G: step A + G->>PE: Decide + PE-->>G: ASK + G->>SC: Conclude: step A waits for an answer + G-->>OA: ASK + Note over PE: learns the approver's answer by its own means, or from step B + OA->>G: step B, later + G->>PE: Decide + PE-->>G: decision with a Grant that answers step A + G->>SC: Append(intent_extension), when the Grant covers the rest of the session + G-->>OA: decision +``` + +ACS v0.1.0 defines no wire method for the approver's answer (#175). + +### The DEFER scenario + +```mermaid +sequenceDiagram + participant OA as observedagent.Client + participant G as guardian.Guardian + participant SC as SessionContextStore + participant PE as PolicyEngine + OA->>G: step A + G->>PE: Decide + PE-->>G: DEFER + G->>SC: Conclude: count one more deferral in the session + G-->>OA: DEFER + Note over PE: resolves the deferred case by its own means + Note over OA: waits, then sends the same action again + OA->>G: step B, later + G->>PE: Decide + PE-->>G: final decision + G-->>OA: final decision +``` + +ACS v0.1.0 defines no way to tell the Observed Agent that a DEFER is resolved (#14), so the +Observed Agent sends the action again. An engine that has not resolved the case answers DEFER again. A +session gets at most `MaxDeferrals` DEFER answers, 3 by default; the next one is DENY +`deferral_bound_exceeded`. + +### Calls by method + +| Method | `AuditLog` | `Signer` | `SessionContextStore` | `PolicyEngine` | +| --- | --- | --- | --- | --- | +| `handshake/hello` | both envelopes | `Verify`, `Sign` | `Lock`, `Load`, `Negotiate`; `Reserve` for a repeated handshake | `Policy()` only, read once at `guardian.New` | +| a native hook, `protocols/MCP/*` | both envelopes, events | `Verify`, `Sign` | `Lock`, `Load`, `Append`; `Reserve` after sessionEnd; `Conclude`, `SkillApproved` as the step needs | `Decide` | +| `system/ping` | both envelopes | `Verify` and `Sign` only when the ping is signed | none | none | + +- Sessions run in parallel. No interface calls another, and none calls the Guardian. +- The store keeps the negotiated `ServerHello`. + - Every replica uses it for the session's timeout, timestamp window, negotiated methods, + version and approver types. + - A replica with a different configuration applies it to new sessions only. +- Once a step is authenticated and names a negotiated method, every store, engine and + internal failure is answered with a signed DENY. + - An error would leave the Observed Agent to its failure posture, which proceeds by + default. + - The failure's detail goes to the `AuditLog` as a `failure` event; the wire carries a + fixed message. + - If the `Signer` itself cannot sign, the Guardian can only return an unsigned internal + error. + +## Read the code in this order + +1. **Types and interfaces.** [`acs/`](../acs/) holds the ACS wire types: envelopes, + the handshake, decisions, hook payloads and SessionContext objects. The four + interface files are in [the table above](#the-four-interfaces). +2. **The built-in implementations.** + [`guardian/hmac_signer.go`](../guardian/hmac_signer.go) signs the canonical form + built in [`internal/jcs/`](../internal/jcs/) and + [`internal/envelope/`](../internal/envelope/); [canonical form](canonical-form.md) + explains the bytes. + [`guardian/memory_session_context_store.go`](../guardian/memory_session_context_store.go) + keeps sessions and the hash chain from [`internal/chain/`](../internal/chain/), and + [`guardian/guardiantest/`](../guardian/guardiantest/) is the test suite any store + must pass. [`guardian/jsonl_audit_log.go`](../guardian/jsonl_audit_log.go) writes + audit records. +3. **The core.** `Guardian.answer` in [`guardian/guardian.go`](../guardian/guardian.go) + validates, authenticates and routes each request. `handshake` and `step` in + [`guardian/step.go`](../guardian/step.go) take it to a signed answer; + [one step, in order](#one-step-in-order) draws the same path. + The helpers: [`internal/schema/`](../internal/schema/) validates against the + specification's schemas, [`internal/handshake/`](../internal/handshake/) negotiates + the session, [`internal/method/`](../internal/method/) routes the nineteen hooks, + [`internal/disposition/`](../internal/disposition/) checks each decision and + [`internal/mcp/`](../internal/mcp/) reads wrapped MCP messages. + [`cmd/guardian/`](../cmd/guardian/) is the standalone program around the core. +4. **The AGT engine.** [`agtbridge/`](../agtbridge/) with its two evaluators, + [`agtbridge/agteval/`](../agtbridge/agteval/) and + [`agtbridge/goeval/`](../agtbridge/goeval/), described in + [how AGT is used](#how-agt-is-used). +5. **The Observed Agent side.** [`internal/observedagent/`](../internal/observedagent/) is the + bundled Observed Agent client: it signs requests and checks answers, and the + tests drive the Guardian through it. + [`internal/hostadapter/`](../internal/hostadapter/) builds a host integration on + it, and [`examples/codex/`](../examples/codex/) and + [`examples/opencode/`](../examples/opencode/) are the two host adapters. + [`examples/custom-engine/`](../examples/custom-engine/) implements all four + interfaces without AGT. diff --git a/reference-implementations/agt-go/docs/building.md b/reference-implementations/agt-go/docs/building.md new file mode 100644 index 00000000..3cea5706 --- /dev/null +++ b/reference-implementations/agt-go/docs/building.md @@ -0,0 +1,80 @@ +# Build and package the Guardian + +`make build` uses the AGT evaluator. Set `EVALUATOR=go` for the Go evaluator. +`GOOS` and `GOARCH` use Go's standard target names. Both default to the host. + +| Target | Native AGT library | +| --- | --- | +| `linux/amd64` | `.so` | +| `linux/arm64` | `.so` | +| `darwin/amd64` | `.dylib` | +| `darwin/arm64` | `.dylib` | +| `windows/amd64` | `.dll` | + +```bash +make build +make build EVALUATOR=go +make build GOOS=windows GOARCH=amd64 +make build EVALUATOR=go GOOS=darwin GOARCH=arm64 +make run +make run EVALUATOR=go +make test +make test EVALUATOR=go +``` + +The binary is in `.acs/bin/-//`. +Windows binaries have the `.exe` extension. An AGT build includes its library and build record beside the binary. +`OUTPUT_DIR` changes the output directory. Keep the library and record together when copying the binary. + +## Native toolchains + +Install Go, Rust through rustup, a C compiler, Make, Bash, Git, curl, jq, and OpenSSL. +On macOS, install the Xcode command line tools. Native macOS builds require the Apple SDK on a Mac. +On Windows, use the MSYS2 MinGW-w64 shell with GCC, Make, and the listed tools on PATH. +The Rust target is `x86_64-pc-windows-gnu`. The build script installs the pinned Rust target. + +The Go evaluator cross-builds without a C compiler. AGT cross-builds also require the target C compiler and system libraries. +For Windows from Linux, install MinGW-w64. The Makefile selects `x86_64-w64-mingw32-gcc`. +For a Linux architecture change, set `CC` to the target compiler. +Cross-builds do not execute tests. `make run`, `make test`, and `make check` require the host target. + +The native script stores each platform's library and OPA in `.acs/agt/-/`. +Host builds also copy OPA into `.acs/agt/bin/` for the reference YAML. +`policy.opa_path` accepts an executable or its directory. Windows uses `opa.exe`. +OPA 0.70.0 names its Apple Silicon binary `opa_darwin_arm64_static` and its Intel Mac binary `opa_darwin_amd64`. + +## Linux container images + +The image uses AGT by default. Docker Desktop runs the Linux image on Windows and macOS. +The image listens on port 8787 and requires a HMAC secret file: standard base64 that decodes to at least 32 bytes, such as `openssl rand -base64 32` writes. Set `ACS__SECURITY__HMAC_KEY_ID` to the `key_id` the deployment gives it. +The image runs as user 65532. Audit files go to `/data`. + +```bash +make image +make image EVALUATOR=go IMAGE=acs-guardian-go:local +make image PLATFORMS=linux/amd64 IMAGE_OUTPUT=type=docker +docker run --rm -p 8787:8787 --user "$(id -u):$(id -g)" \ + --tmpfs "/data:uid=$(id -u),gid=$(id -g),mode=0700" \ + --mount type=bind,src="$(pwd)/.acs/hmac-secret",dst=/run/secrets/acs-hmac,readonly \ + acs-guardian:local +``` + +`make image` exports an OCI archive to `.acs/acs-guardian.oci.tar` for `linux/amd64` and `linux/arm64`. +Use `IMAGE_OUTPUT=type=docker` to load a host image into Docker. +The build requires a Buildx builder with OCI export and the selected platforms. +The command does not publish the image. Both Linux targets use cross-compilers on the builder's architecture. +Run `make image-check` to test the loaded image through a host-published port. +`IMAGE_PLATFORM=linux/arm64` selects the arm64 image for that check. Execution requires an arm64 host or Docker emulation. +The example uses the host user to read the protected secret file. +Mount writable storage at `/data` to retain audit files after the container stops. + +## Verification limits + +CI runs the complete gate on Linux amd64, Linux arm64, Intel Mac, and Apple Silicon. +CI has no Windows runner, and the Windows build is not tested on Windows. +Cross-builds establish compilation only. +`make check` checks Go's cgo flag rules for all five targets without executing their build commands. +This validation does not link native libraries or run target binaries. +`go list` shows selected flags but does not validate the flags. +The CLI tests accept `ACS_TEST_GUARDIAN_BINARY` to exercise a previously compiled binary on its target. +The variable changes the test artifact only. It does not skip startup, decisions, or shutdown checks. diff --git a/reference-implementations/agt-go/docs/canonical-form.md b/reference-implementations/agt-go/docs/canonical-form.md new file mode 100644 index 00000000..fef6fb06 --- /dev/null +++ b/reference-implementations/agt-go/docs/canonical-form.md @@ -0,0 +1,45 @@ +# Hash and sign the canonical form + +Two Guardians compare chains and verify each other's signatures only if they hash and +sign the same bytes. ACS fixes those bytes as the RFC 8785 JSON Canonicalization Scheme +(JCS) form, and permits no other (§8.2, §10). This page says where the Go Guardian produces +that form and how each use is proved. + +## Canonicalize once, the same way everywhere + +`internal/jcs` produces the JCS form with `jsontext.Value.Canonicalize` from +`github.com/go-json-experiment/json`, the upstream of Go's `encoding/json/v2`, which +implements RFC 8785 directly. Go's `encoding/json` v1 never produces canonical bytes: it +escapes `<`, `>`, `&`, U+2028 and U+2029, and formats numbers its own way. + +`internal/envelope` decodes every request with the same JSON library. It refuses a duplicate member name, invalid UTF-8 +and an unpaired surrogate, and it matches member names case-sensitively. A request two +parsers could read two ways never reaches a signature check, so a signature always covers +exactly one meaning. + +## Where the form is used + +| Use | Bytes | Specification | +| --- | --- | --- | +| Signed input | The request or response envelope with its `signature` member removed: `params.signature` in a request, `result.signature` in a result, `error.signature` in an error. | §10 | +| `request_hash` | Lowercase-hex SHA-256 of the request's `params`, signature included. | §8.1 | +| `entry_hash` | Lowercase-hex SHA-256 of the entry without `entry_hash` and `previous_hash`, then the raw 32 bytes of `previous_hash`, or nothing for a session's first entry. | §8.2 | + +The HMAC-SHA256 signer derives a separate key for each session with HKDF (RFC 5869); its +inputs are listed in [conformance](conformance.md#behavior-that-acs-v010-leaves-undefined). +The signature value is standard base64 with padding. + +## Tests + +The JCS tests protect the bytes used by this module; they do not test the +external library's internal design. An update that changes one number or +Unicode encoding must fail locally before it changes ACS signatures or chain +hashes. The 5,000 compressed number cases occupy 75 KiB. + +| Property | Test | Vectors | +| --- | --- | --- | +| RFC 8785 conformance | `internal/jcs.TestReferenceVectors`, `internal/jcs.TestES6Numbers`, `internal/jcs.TestRFC8785Example` | The RFC author's reference vectors and the first 5,000 of its ES6 number vectors, in `internal/jcs/testdata/`. | +| Non-I-JSON refused | `internal/jcs.TestRefusesNonIJSON`, `internal/envelope.TestRefusesDuplicateMembers` | Written in the tests. | +| §8.1 and §8.2 digests | `internal/chain.TestVector`, `internal/chain.TestRequestHashVector` | `internal/chain/testdata/vectors.json`, computed with Python's `json` and `hashlib`. | +| §10 signed input and HMAC | `internal/envelope.TestSigningInputVectors`, `guardian.TestHMACSignerVectors` | `internal/envelope/testdata/vectors.json`, computed in Python; its HKDF matches RFC 5869 test case 3. | +| Live answers verify | `guardian.TestIndependentVerifier` and every `guardian` test that reads a decision | A verifier with its own canonicalizer and the standard library's HKDF and HMAC, sharing no code with the signer. | diff --git a/reference-implementations/agt-go/docs/configuration.md b/reference-implementations/agt-go/docs/configuration.md new file mode 100644 index 00000000..4d4ae45f --- /dev/null +++ b/reference-implementations/agt-go/docs/configuration.md @@ -0,0 +1,154 @@ +# Standalone Guardian configuration + +`guardian --config PATH` reads one YAML document. The command validates every +required value after loading the file. Unknown YAML keys and unknown `ACS__` +environment variables stop startup. + +`guardian.yaml` is runnable local configuration. A deployment should copy it +to an operator-owned location, replace the secret and audit paths, select its +policy deployment, and choose limits for its workload. + +## Server + +| Key | Meaning | Constraint | +| --- | --- | --- | +| `server.host` | Listener host or address. | Non-empty. | +| `server.port` | Listener port or service name. `0` asks the operating system for a free port. | Non-empty. | +| `server.read_header_timeout` | Maximum time to read HTTP headers. | Positive duration. | +| `server.read_timeout` | Maximum time to read a complete request. | Positive duration. | +| `server.write_timeout` | Maximum time to write an HTTP response. | Must exceed `protocol.decision_timeout`. | +| `server.idle_timeout` | HTTP keep-alive idle limit. | Positive duration. | +| `server.shutdown_grace` | Duration given to each shutdown phase: in-flight work, cancellation-answer delivery, and audit flush. | Positive duration. | + +The command serves `POST /acs`, `GET /healthz` and `GET /readyz`. Readiness is +enabled only after the policy engine and Guardian are ready. + +## Policy + +| Key | Meaning | Constraint | +| --- | --- | --- | +| `policy.deployment_dir` | Root directory of one AGT policy deployment. | Existing absolute path, or relative to the configuration file. | +| `policy.manifest` | AGT manifest within `policy.deployment_dir`. | Non-empty relative path that cannot escape the deployment directory. | +| `policy.mapping` | ACS verdict mapping within `policy.deployment_dir`. | Same path rule as `policy.manifest`. | +| `policy.evaluator` | `agt` uses AGT's runtime library. `go` uses the Go reproduction. | Required: `agt` or `go`. The reference YAML selects `agt`. `make build` includes AGT; `make build EVALUATOR=go` refuses `agt` at startup. | +| `policy.opa_path` | The OPA executable or its directory. | Required for `agt`: absolute or relative to the configuration file. Directory paths select `opa` on Unix and `opa.exe` on Windows. `make agt-native` copies the host executable to `.acs/agt/bin/`. Not read by `go`. | +| `policy.opa_timeout` | The bound AGT puts on each `opa` run; past it, AGT denies the step. | Required for `agt`: a duration such as `4s`, positive, a whole number of milliseconds, and shorter than `protocol.decision_timeout`. AGT's timer starts after `opa` starts, so leave room for process start and signing; `guardian.yaml` leaves one second. Not read by `go`. | +| `policy.ask_substitution` | Endpoint behavior when ASK cannot be completed. | `none`, `deny` or `defer`. | +| `policy.tool_aliases` | Optional map from an Observed Agent's tool name to the manifest's tool name. | Both names must be non-empty. | + +`ask_substitution: none` leaves an ASK from the engine unchanged. `deny` returns DENY +`approver_unavailable`. `defer` returns DEFER with a deny timeout decision. The +setting belongs to this Guardian endpoint; it is not tied to a particular host +product. The reference configuration uses `deny` because its host examples do +not provide an ACS approval channel. + +A tool alias changes only the policy input. The signed ACS request, the +SessionContext chain and audit records keep the original tool name. + +Build selection and evaluator selection are separate. The `agteval` build tag +includes AGT; `policy.evaluator` selects which evaluator runs. +`ACS__POLICY__EVALUATOR=go` selects the Go evaluator in either build. +An AGT build still links the library when the Go evaluator is selected. +`make run EVALUATOR=go` builds without that dependency and selects the Go evaluator. + +The AGT build includes the library and `agt-native.json` beside the binary. +Linux and macOS builds also embed the platform library directory under `.acs/agt/` for tests. +To deploy elsewhere, keep the library and `agt-native.json` in one directory. +Set `LD_LIBRARY_PATH` on Linux or `DYLD_LIBRARY_PATH` on macOS to that directory. +On Windows, keep the DLL beside the executable or add its directory to `PATH`. +The Guardian refuses startup if the build record is missing or its checksum differs from the library. + +## Security and audit + +| Key | Meaning | Constraint | +| --- | --- | --- | +| `security.hmac_key_id` | The `key_id` the deployment gives its secret (§10): an opaque label, such as `2026-10`. A request signed under any other `key_id` is refused with `SIGNATURE_INVALID`. | Non-empty; no default. | +| `security.hmac_secret_file` | File holding the shared HMAC secret, the HKDF input keying material. | Absolute path, or relative to the configuration file; standard base64 that decodes to at least 32 bytes, surrounding whitespace ignored; protect with operating-system permissions. | +| `audit.envelope_log` | JSONL file for inbound and outbound ACS envelopes. | Absolute path, or relative to the configuration file. A symbolic link at the path is refused. On Unix, a file that group or other can access is refused; the Guardian does not change its mode. A missing directory is created with mode `0700`. | +| `audit.event_log` | JSONL file for Guardian audit events. | Absolute path, or relative to the configuration file. A symbolic link at the path is refused. On Unix, a file that group or other can access is refused; the Guardian does not change its mode. A missing directory is created with mode `0700`. | +| `audit.max_file_bytes` | Size at which each audit file rotates: it becomes `.1`, older ones move up. `0` keeps every record, for a deployment that rotates the files with its own tooling. | Zero or positive. `guardian.yaml` ships 268435456 (256 MiB). | +| `audit.max_files` | Rotated files kept per audit file; the one past it is deleted. A log keeps at most `max_file_bytes × (max_files + 1)` bytes, plus one record larger than `max_file_bytes`. | Zero or positive; needs `max_file_bytes`, which in turn needs at least 1, so a rotation never deletes the live log. `guardian.yaml` ships 3, so at most 1 GiB per log. | +| `audit.max_queue_records` | Records waiting for the background writer, the one being written included. A record past it is dropped and counted. | Positive. `guardian.yaml` ships 4096. | +| `audit.max_queue_bytes` | Bytes of records waiting for the writer, the one being written included, so a stalled disk holds at most this many bytes of records. A record past it is dropped and counted. | At least `2 × limits.max_body_bytes + 256`, so one request of the largest body fits. `guardian.yaml` ships 67108864 (64 MiB). | +| `storage.provider` | Where sessions, replay history and policy state live. `memory`: in this process, lost when it stops, one process per endpoint. | `memory`, the one provider this command ships. A host that embeds the `guardian` package passes its own `SessionContextStore` for durability or replicas. | + +Envelope records contain raw requests and answers, including tool arguments +and results. Treat both audit files as sensitive. + +A background writer appends the records, so a slow disk never delays a +decision. When a record would take its queue past `audit.max_queue_records` +or `audit.max_queue_bytes`, it is dropped and counted; logging continues as +space frees. A write failure turns +the log off for the rest of the process, and every later record is dropped +and counted. The first queue-full drop, the first write failure and the first +record offered after shutdown closed the log each print one line to stderr. A +dropped record keeps its `seq`, so the numbers in a file skip it within one +run. Shutdown gives the writer its own `server.shutdown_grace`. The command +exits with an error when a record was dropped or the queue did not flush, and +names how many records were lost; a record offered after that count, by an +engine that ignored cancellation, gets only its stderr line. The Guardian +records a request before it checks the signature, so unsigned requests share +the queue with signed ones and can push them out of a full queue. + +Two standalone processes may share this configuration file, policy directory +and HMAC secret. Give each process its own `server.port`, `audit.envelope_log` +and `audit.event_log`; two processes must not write the same JSONL files. The +standalone command keeps sessions in its own memory. Replicas serving one +endpoint therefore embed the library with a shared `SessionContextStore`. + +## Protocol + +| Key | Meaning | Constraint | +| --- | --- | --- | +| `protocol.on_decision_failure` | Failure posture advertised in `ServerHello`. | `proceed` or `deny`. | +| `protocol.decision_timeout` | Default deadline for a policy decision. | Positive duration and shorter than `server.write_timeout`. | +| `protocol.skew_window` | Accepted timestamp distance in either direction. | Positive duration. | + +`on_decision_failure` tells the Observed Agent what to do when it cannot obtain +or verify a decision. The Guardian cannot enforce the agent's behavior after +communication fails. The `guardian.Config` zero value advertises `proceed`; +`guardian.yaml` explicitly advertises `deny`. + +## Limits + +| Key | Meaning | Constraint | +| --- | --- | --- | +| `limits.max_sessions` | Sessions held by the in-memory store. | Positive integer. | +| `limits.max_entries` | Context entries retained per session. | Positive integer. | +| `limits.max_reservations` | Requests remembered per session for replay protection. Each reservation stores the request ID and its nonce when present. | Positive integer. | +| `limits.max_skill_approvals` | Skill approvals retained across sessions. | Positive integer. | +| `limits.max_engine_calls` | Policy evaluations allowed to run concurrently. Under `agt` each one is an `opa` process. | Positive integer. `guardian.yaml` ships 32, measured in [Load](load.md); the library default is 256. | +| `limits.max_policy_state_bytes` | Per-session engine state size. | Positive integer. | +| `limits.max_body_bytes` | HTTP request-body size. | Positive integer. | +| `limits.max_deferrals` | DEFER decisions allowed in one session. | Positive integer. | +| `limits.session_retention` | Idle time before an in-memory session may be removed. | At least twice `protocol.skew_window`. | + +The two-window retention rule ensures that every accepted future-dated request +is stale before its replay history can be removed. Reaching any store bound is +an explicit refusal; the store never drops a live session to admit another. + +## Environment overrides + +The prefix is `ACS__`. A double underscore enters a nested YAML object; a +single underscore remains part of a key name. Values use the same decoding and +validation as YAML. + +```bash +ACS__SERVER__PORT=8788 \ +ACS__POLICY__DEPLOYMENT_DIR=/opt/acs/policy \ +ACS__POLICY__ASK_SUBSTITUTION=defer \ +ACS__POLICY__TOOL_ALIASES__shell=bash \ +ACS__SECURITY__HMAC_SECRET_FILE=/run/secrets/acs-hmac \ +guardian --config /etc/acs/guardian.yaml +``` + +For example, `ACS__LIMITS__MAX_ENGINE_CALLS` overrides +`limits.max_engine_calls`. Environment variables do not create an alternate +configuration surface: an override for a key absent from the declared schema +is rejected. + +The checked-in configuration uses port `8787` for `make run`. Automated checks +and interactive host examples set `ACS__SERVER__PORT` to `0` and point the audit +logs at a temporary directory; the conformance check also sets its own HMAC key. +They read the chosen address from the Guardian at startup and leave +`guardian.yaml` unchanged. diff --git a/reference-implementations/agt-go/docs/conformance.md b/reference-implementations/agt-go/docs/conformance.md new file mode 100644 index 00000000..ca2639b1 --- /dev/null +++ b/reference-implementations/agt-go/docs/conformance.md @@ -0,0 +1,81 @@ +# Conformance + +This table compares the Go Guardian with ACS-Core as defined on `integration` in +[docs/spec/conformance.md](../../../docs/spec/conformance.md). + +ACS v0.1.0 has no external certification mechanism. The Go Guardian self-declares +`acs-core`; each claimed row names the tests that cover it. `make docs-check` +verifies that those tests and the document's local links exist. It cannot prove +that a test's assertions establish a claim; code review and `make check` provide +that assurance. + +| ACS-Core item | Status | Tests | Note | +| --- | --- | --- | --- | +| Handshake, `handshake/hello` (§4) | Met | `guardian.TestHandshake`
`guardian.TestRecoveredSessionUsesNegotiatedTerms`
`guardian.TestHandshakeRefusals`
`guardian.TestHandshakeReplay`
`guardian.TestHandshakeReplayAfterSessionEnd`
`guardian.TestHandshakeAtCapacity`
`guardian.TestKeyBoundToSession`
`guardian.TestAgentIDBoundToSession`
`internal/handshake.TestSelectVersion`
`internal/handshake.TestEvaluatedMethods`
`internal/handshake.TestPartialObservedAgentIsNegotiated`
`internal/observedagent.TestHandshakeIgnoresUnknownResultFields`
`internal/hostadapter.TestHandshakeErrorIsRefusal`
`internal/hostadapter.TestLoadStateDefaultsAnOmittedFailurePosture` | Negotiates the version, the methods offered by the Observed Agent, the transport, the timeouts, the skew window and the failure posture; refuses with -32001, -32002 and -32000. Under 0.x, versions are compatible only when they share `X.Y`, so a 0.1 Guardian refuses a ClientHello offering only 0.2.0, and a step must carry a compatible `acs_version`. This follows #196 (accepted) ahead of #255; §3 still reads "matching major version". `profiles_accepted` is the intersection this Guardian accepts from `profiles_supported`; a partial Observed Agent may honestly declare only the hooks it emits. The signed handshake binds the session to its `key_id` and `agent_id`; later steps and a restart handshake must carry both values. A second handshake for a negotiated session is refused, because v0.1 defines no renegotiation, unless it carries the same ClientHello under those terms: then it returns the saved ServerHello and changes nothing. Every replica uses those saved terms. A replayed handshake is -32005, before or after session end. A full store refuses a new session rather than drop a live one. | +| Request and response envelopes, JSON-RPC 2.0 (§3) | Met | `guardian.TestEnvelopeGate`
`guardian.TestTransportErrors`
`guardian.TestMalformedSignatureIsNotSent`
`internal/schema.TestSchemasMatchSpecification`
`internal/schema.TestWireTypesMatchSchemas`
`internal/observedagent.TestSendRejectsAResponseForAnotherRequest`
`internal/observedagent.TestSendIgnoresUnknownResultFields`
`internal/observedagent.TestSendRejectsMalformedHandshakeResult`
`guardian.TestBodyCap` | Every request is validated against `request-envelope.json` (-32600), and a request for a method the Guardian handles also against its payload schema (-32602, §17). Every result is validated against `response-envelope.json` as the bytes to be sent, signature included. The bundled Observed Agent validates each answer before honoring it, ignores unknown result fields and binds it to the request method and identifiers. Batches are answered -32600, as §3 permits. | +| Hook taxonomy: sessionStart, userMessage or agentTrigger, toolCallRequest, toolCallResult, agentResponse, sessionEnd, and subagentStart for a subagent-capable client (conformance.md) | Met; ACS leaves part undefined | `guardian.TestCoreFloor`
`guardian.TestSkillLoadNeedsApprovedRegistration`
`guardian.TestPostCompactLineage`
`guardian.TestPostCompactLineageSurvivesEntryArchival`
`guardian.TestTurnIDReachesTheChain`
`guardian.TestUnresolvedRequestIDRefIsRecorded`
`internal/method.TestRoute` | All nineteen native hooks are dispatched and decided; each hook's permitted dispositions follow hooks.md. The Guardian applies the rules hooks give it directly: a skillLoad whose (`skill_id`, digest) no allowed skillRegister approved is DENY `skill_unverifiable`; a postCompact whose `pre_compact_chain_hash` is not the head it was appended to, or whose summary's `derived_from` is not the union of the compacted entries' `provenance_id`s, or whose origin is not `agent_generated`, is recorded with `lineage_mismatch` (the hook permits no DENY); archived entries keep their lineage; `metadata.turn_id` is written onto each entry that carries it; a toolCallResult whose `request_id_ref` names no toolCallRequest of the session is recorded as `request_id_ref_unresolved` and decided by policy, which #272 proposes as a requirement. ACS does not define a `turn_id` member on `context-entry.json`, so the Go Guardian uses the envelope field's name. ACS also asks the sender for a `post_compact_chain_hash` that the sender cannot know; the Guardian publishes the actual hash in its answer instead. A subagentStop without `final_chain_hash` is answered ALLOW and recorded; a missing field is not an integrity failure. The deployment decides which clients are subagent-capable. The Guardian evaluates each subagentStart it receives and cannot detect one that was never sent. Trust monotonicity over a summary's lineage is policy, and is the engine's. | +| Dispositions: ALLOW, DENY, ASK and DEFER as MUST, MODIFY as SHOULD, with their required fields (§6) | Met | `guardian.TestCoreFloor`
`guardian.TestEngineFailuresAreDenials`
`guardian.TestHookPermittedDispositions`
`internal/disposition.TestCheck` | A decision missing a required field is answered DENY. The disposition table below says which dispositions AGT itself emits. | +| MODIFY composition (§6.3) | Met; one case by interpretation (#270) | `internal/disposition.TestCheck`
`internal/observedagent.TestSendFailsClosedForMixedModifyShapes`
`acs-codex-hook.TestRunRecordsAnInvalidModification`
`internal/hostadapter.TestInvalidModificationProceedsWhereTheHookPermitsNoDeny`
`internal/hostadapter.TestModifiedToolCallInput`
`internal/hostadapter.TestModifiedToolCallResult`
`guardian.TestEngineFailuresAreDenials`
`guardian.TestWrappedMCPRefusesOverlappingModifications` | A modification that combines a replacement with edits, or edits overlapping fields, is never sent. The host adapters deny a signed one they receive, or let it proceed unmodified at a hook that permits no DENY, and record `modify_invalid` in their audit log. That ALLOW is an interpretation: §6.3 says to fail closed as DENY, and gives no exception for the five hooks whose Decision line in hooks.md permits no DENY; §6.5 gives one only for postCompact. #270 proposes the §6.3 exception. The Codex and OpenCode examples send only the tool hooks, so only the host adapter library reaches this case. Host adapters apply only changed argument or result values and refuse changes to fields their host cannot apply. Wrapped MCP overrides are checked against their effective paths under `params.arguments`. | +| MODIFY for an Observed Agent that cannot apply it (§6.5) | Met | `guardian.TestClientSubstitutions`
`acs-codex-hook.TestRunTranslatesDecisions`
`acs-opencode-hook.TestRunRefusesAModificationItCannotApply`
`internal/hostadapter.TestRefusalProceedsWhereTheHookPermitsNoDeny` | When the engine reports an Observed Agent that cannot apply MODIFY, the Guardian returns DENY `modify_unsupported`, or ALLOW at postCompact, and records an audit event. The host adapters answer a MODIFY their host cannot apply the same way and record `modify_unsupported` in their audit log. | +| ASK for an Observed Agent that cannot resolve it (§9.2) | Met | `guardian.TestConfiguredAskSubstitution`
`guardian.TestClientSubstitutions` | `Config.AskSubstitution` selects the endpoint-wide substitute. An engine can report a more specific trusted signal in `PolicyDecision.Client`. Both forms return DEFER with `timeout_decision: deny`, or DENY with `approver_unavailable`, with an audit event. | +| ASK approver and intent extension (§9, §9.1) | Met; ACS leaves part undefined | `guardian.TestIntentExtension`
`guardian.TestEngineFailuresAreDenials` | An ASK names an approver type declared in `ServerHello`. A grant must answer a step that received ASK in the same session; otherwise the Guardian returns DENY `grant_invalid`. A session-scoped grant extends `Intent.parsed` and creates an `intent_extension` ContextEntry. That entry commits the approver, capabilities and extension provenance to the chain. The engine authenticates the approver and applies the session's `scope_mode` before returning the grant. ACS v0.1.0 defines no wire method for the approver's answer (#175) and no ContextEntry fields for the grant. The Go Guardian uses `approver` and `intent_extension`, the names from `ask-details.json`. | +| DEFER details and the cascading-deferral bound (§6) | Met; ACS leaves part undefined | `guardian.TestCoreFloor`
`guardian.TestDeferralBound` | `timeout_decision` defaults to deny; a session's deferrals past the configured bound are answered DENY. ACS v0.1.0 defines no way for a resolved DEFER to reach the Observed Agent. The Go Guardian adds no wire method for it. The engine can decide the step when the Observed Agent submits it again. | +| SessionContext and published chain head (§8.2, §8.6) | Met | `guardian.TestChainPublication`
`internal/chain.TestVector`
`internal/chain.TestEntryHashCommitsToEveryMember`
`guardian.TestChainMismatch`
`guardian.TestReserveAndAppendAtomicity` | Every content-bearing step is appended before it is decided, `request_hash` is always written, and `chain_hash` is on every answer and covered by its signature. An Observed Agent that sends a different `chain_hash` receives DENY with `chain_mismatch`. A store orders each session across every Guardian that shares it. A store with an expiring claim cancels `lockCtx` when ownership is lost and fences every mutation against the claim. Each append also checks the expected head. | +| Intent immutability (§8.4) | Met | `guardian.TestIntent` | Intent is established at sessionStart or the first agentTrigger; a step carrying a different Intent is answered DENY with an audit event. | +| Replay protection (§10.3) | Met | `guardian.TestReplayAndSkew`
`guardian.TestRecoveredSessionUsesNegotiatedTerms`
`guardian.TestHandshakeReplay`
`guardian.TestHandshakeReplayAfterSessionEnd`
`guardian.TestCoreFloor`
`guardian.TestWithinSkew`
`guardian.TestReplayHistoryOutlivesEveryAcceptedTimestamp`
`guardian.TestReplayReservationsAreBounded` | A repeated `request_id` or nonce in a session is -32005, including a replayed handshake and a step after sessionEnd. A timestamp outside the session's saved `skew_window_ms` is -32006 with the window in `data`. The default store keeps a session for at least two skew windows after its last write, so an accepted future-dated request is stale before its replay history can be removed. Replay reservations are bounded independently of accepted entries. | +| Baseline integrity: HMAC-SHA256 over the §10 canonical input (§10) | Met; ACS leaves part undefined | `guardian.TestSignatureRequired`
`guardian.TestReplayAndSkew`
`guardian.TestHMACSignerVectors`
`guardian.TestIndependentVerifier`
`guardian.TestKeyBoundToSession`
`guardian.TestAgentIDBoundToSession`
`internal/envelope.TestSigningInputVectors` | Every request but system/ping must be signed. An answer to a request that authenticated is signed at `result.signature` or `error.signature` while the signer is available; a ping's answer is signed only when the ping was signed. A request that did not authenticate has established no key, so its answer is unsigned. A signer failure can only produce an unsigned internal error. ACS does not define where a ServerHello or error signature is stored. ACS also requires HKDF without defining all of its inputs. The exact behavior implemented here is listed below. | +| Decision honoring and `on_decision_failure` (§6.4) | Observed Agent responsibility; the host adapter by interpretation (#32, open) | `guardian.TestHandshake`
`guardian.TestRecoveredSessionUsesNegotiatedTerms`
`guardian.TestStoreFailureIsSignedDenial`
`guardian.TestAuthenticatedStepProviderPanicIsSignedDeny`
`guardian.TestKeyBoundToSession`
`guardian.TestAgentIDBoundToSession`
`guardian.TestEngineCallsAreBounded`
`guardian.TestPolicyStateIsBounded`
`guardian.TestPolicyStateMustBeJSON`
`guardian.TestShutdownBoundsAnEngineThatIgnoresCancellation`
`cmd/guardian.TestShutdownDeliversCancellationDenial`
`guardian.TestTamperedDecisionIsDenied`
`internal/observedagent.TestHonour`
`internal/hostadapter.TestDecisionFailureRecordsItsClass`
`internal/hostadapter.TestHandshakeFailureRecordsItsClass`
`internal/observedagent.TestSendIgnoresUnknownResultFields`
`acs-codex-hook.TestRunRecordsAProceedFailure`
`acs-opencode-hook.TestRunGovernsRequestAndResult`
`acs-opencode-hook.TestRunBlocksEveryNonProceedingDisposition` | The Observed Agent must honor the decision or apply the negotiated failure posture when no usable decision arrives. The test Observed Agent covers that behavior. The host adapter keeps the posture for a Guardian that did not decide: no answer, or a signed -32603. It denies under any posture a signed error of any other code, a result or error whose signature does not verify, and a malformed answer (`internal/observedagent.TestHonour`). This is an interpretation: §6.4 sends every error answer to the posture. [#32](https://github.com/GenAI-Security-Project/agent-control-standard/issues/32) proposes failing closed, and on #37 (13 October) a Spec lead agreed that an invalid signature fails closed under any posture; #32 is still open. One consequence: the Guardian's unsigned `shutting_down` answer is denied under `proceed`. The TypeScript host fails closed on its own Guardian's refusal codes only. The host adapter library records every decision failure under either posture, with its class (`failure_class`; [#37](https://github.com/GenAI-Security-Project/agent-control-standard/issues/37)). At a hook that permits no DENY, such as postCompact, it proceeds. When its audit log cannot be written, the host refuses the step as far as the hook allows, and the record goes to stderr as one JSON line with the final disposition; stderr is a record only where the host keeps it (`internal/hostadapter.TestDecisionFailureProceedsWhereTheHookPermitsNoDeny`, `internal/hostadapter.TestSubstitutedAllowReachesStderrWhenTheAuditLogFails`, `internal/hostadapter.TestRefusalNamesEverySinkThatFailed`). The Codex and OpenCode examples send only the tool hooks, so `sessionEnd.summary.decision_failures` does not apply to them; there a failure whose record cannot be written is denied. Each session keeps its negotiated timeout. The Guardian returns a signed decision for every authenticated step while signing remains available, including an engine failure, store failure, provider panic, identity mismatch, capacity refusal or shutdown. Process shutdown gives cancellation answers their own bounded delivery phase. The [OpenCode plugin callback test](../examples/opencode/plugin.test.mjs) verifies that the real plugin applies decisions to completed and failed tool results. | +| Liveness, `system/ping` (§13; SHOULD) | Met | `guardian.TestPing` | A valid ping is always ALLOW, needs no signature and writes nothing to the SessionContext. A malformed one gets a JSON-RPC error, never an ACS-specific one. | +| Wrapped MCP, `protocols/MCP/*` (conformance.md; MUST for sessions involving MCP) | Met | `guardian.TestWrappedMCP`
`guardian.TestWrappedProtocolRouting`
`guardian.TestWrappedMCPRefusesOverlappingModifications`
`internal/handshake.TestEvaluatedMethods`
`internal/mcp.TestCheck`
`internal/mcp.TestSupports`
`agtbridge.TestWrappedMCPToolCalls`
`agtbridge.TestProjectionReaddress`
`agtbridge.TestProjectionKeepsEveryMCPResultShape`
`agtbridge.TestProjectionRemembersOnlyWhatRuns`
`agtbridge.TestProjectionRefusesAPendingID`
`agtbridge.TestProjectionCanonicalizesMCPIDs` | `protocols/MCP/*` and the explicit-version `wrapped:mcp-/*` both route to the MCP handler, which reads the wrapped JSON-RPC 2.0 message, a request or the MCP response the Observed Agent received, and refuses a malformed one with -32602. An explicit-version method is negotiated only at the version declared in `wrapped_protocols`. The message reaches the engine intact. AGT decides a wrapped `tools/call` with the same tool gates as a direct tool call. The projection preserves text, image, audio, resource, structured content, metadata and error data. A redaction is addressed back into the MCP message. A parameter override names a member of `params.arguments`. A second call under an equivalent MCP id still awaiting its response is denied, so each result meets its own tool's rules. | +| Policy engine interface, `delegate_to` (§12.1) | Met | `guardian.TestEngineFailuresAreDenials` | `delegate_to: agent` is answered DENY with `agent_layer_unavailable`: this Guardian has no agent layer (§12.2 is optional). | + +## Dispositions + +The Guardian supports all five, each covered with a test engine. The default +AGT policy bridge uses the checked-in policy deployment and emits fewer; +running it does not exercise all five. + +| Disposition | Guardian | Checked-in AGT policies | +| --- | --- | --- | +| allow | yes | yes: `allow`, and `warn` with a policy reference | +| deny | yes | yes: `deny`, and every `runtime_error:*` | +| modify | yes | yes: `transform`, the shipped redaction rules | +| ask | yes | only with an approver configured; the checked-in configuration never escalates, and without an approver an escalate is DENY `approver_unavailable` | +| defer | yes | no: mapping.yaml maps no AGT verdict to defer | + +## Expectations for reference implementations + +[#256](https://github.com/GenAI-Security-Project/agent-control-standard/pull/256) states what the project expects of every maintained Guardian reference +implementation. It is not merged; this table follows its text of 14 October 2026, with +`key_id` as #118 settled it. + +| Expectation | Status | Tests | Note | +| --- | --- | --- | --- | +| ACS API | Met | the ACS-Core table above | The handshake declares the methods, profiles and transport the Guardian supports. | +| Actual AGT runtime | Met by `agt` | `cmd/guardian.TestEvaluatorSelection`
`agtbridge.TestAGTLocksPinOneCommit` | `agt` calls AGT's Rust core, `agent_control_specification_core`, through its C ABI, built at the commit `agt/agt.lock` pins. `EVALUATOR=go` reproduces the runtime for builds without cgo and does not meet this expectation; it is not the reference evaluator. | +| Architectural boundaries | Met | `examples/custom-engine` builds a Guardian from four host implementations | Transport (`guardian/http.go`), ACS processing (`Guardian.Handle`, transport-independent), AGT integration (`agtbridge`), signing (`Signer`), state (`SessionContextStore`) and logging (`AuditLog`) are separate; [Architecture](architecture.md). | +| Signing keys and secrets | Met (library); the command holds one key | `guardian.TestHMACSignerVectors`
`guardian.TestSignatureRequired`
`cmd/guardian.TestRefusesToStart` | HKDF-SHA256, no salt, `session_id` as info, 32 bytes. The secret file is standard base64 that decodes to at least 32 bytes. `key_id` comes from configuration with no default; a request under an unknown `key_id` is refused, never verified with the configured secret. To replace the key, the library takes several `HMACKey` values, so a verifier can hold the old and new secret while sessions move to the new `key_id`; a session stays bound to the `key_id` that opened it. `cmd/guardian` takes one `security.hmac_key_id` and one secret, so with it a replacement means a restart, and live sessions under the old `key_id` are then refused. #256 still names `session_id` as `key_id`; #118 replaced that. | +| Configurable storage | Met | `cmd/guardian.TestRefusesToStart`
`guardian.TestReserveAndAppendAtomicity` | `storage.provider` selects the store; this command ships `memory` (lost on restart, one process per endpoint), and a host that embeds the library passes its own `SessionContextStore`. [Configuration](configuration.md) states the guarantees. | +| Concurrency and session consistency | Met | `guardian.TestSessionsRunInParallel`
`guardian.TestReserveAndAppendAtomicity` | A session is serialized under its store lock; replay reservations and the chain head are checked in the same store operation. | +| Bounded processing | Met | `guardian.TestEngineCallsAreBounded`
`guardian.TestBodyCap`
`guardian.TestEngineSlotIsFreeWhenTheOutcomeArrives` | `protocol.decision_timeout`, `policy.opa_timeout`, `limits.max_engine_calls`, `limits.max_body_bytes` and the store limits; [Load](load.md) measures them. | +| Bounded logging | Met | `guardian.TestRotatingFileBoundsTheLog`
`guardian.TestJSONLAuditLogRotates`
`guardian.TestJSONLAuditLogQueueIsBounded`
`guardian.TestJSONLAuditLogCountsEveryLoss` | `audit.max_file_bytes` and `audit.max_files` rotate each audit file; `guardian.yaml` keeps at most 1 GiB per log, plus one record larger than `max_file_bytes`. `audit.max_queue_records` and `audit.max_queue_bytes` bound the writer's queue, 4096 records and 64 MiB in `guardian.yaml`; a record past either limit is dropped and counted, and the command reports the count at shutdown. | +| Verification | Not applicable yet | — | The shared contract test suite does not exist yet. `make check` runs the TypeScript conformance runner's external Guardian probes against this Guardian. | + +## Behavior that ACS v0.1.0 leaves undefined + +ACS v0.1.0 requires each result listed below, but it does not fully define the +wire field or algorithm needed to produce that result. The second column states +the exact behavior implemented here. Two implementations must make the same +choice to interoperate on that point. + +| What ACS v0.1.0 leaves undefined | Behavior implemented here | +| --- | --- | +| ACS-Core requires every answer to be signed, but `response-envelope.json` defines no signature field for an error. | An error answering an authenticated request stores the signature in `error.signature`. This matches the `signature` field of an `AcsResult`, and the error schema permits the additional field. The Python adapters in PR #22 use the same location. An error answering an unauthenticated request is unsigned. | +| §10 requires every response to be signed, but `handshake.json` defines no signature field for `ServerHello`. | `ServerHello` stores the signature in `result.signature`. This matches the `signature` field used by an `AcsResult`, and the `ServerHello` schema permits the additional field. | +| §10 requires HKDF for the per-session key, but does not define the hash function, salt, `info` input or output length. | The signer derives a 32-byte key with HKDF-SHA256. It supplies no salt and uses the UTF-8 bytes of `session_id` as the HKDF `info` input. `salt` and `info` are inputs to HKDF; they are not fields in an ACS message. | +| turnStart requires `turn_id` on every per-step ContextEntry; `context-entry.json` names no member for it. | The entry carries `turn_id`, the envelope field's name, inside its hash. | +| §9.1 requires an `intent_extension` entry to record the approver, the capabilities and the extension's provenance; `context-entry.json` names no members for them. | The entry carries `approver` and `intent_extension`, the names `ask-details.json` uses, inside its hash. | +| ACS v0.1.0 defines no wire method for sending an approver's answer to the Guardian (#175). | The `PolicyEngine` receives the answer by its own means. On a later policy evaluation, the engine authenticates the approver and returns the grant. The Guardian then checks that the grant answers an ASK from the same session. | +| `post-compact.json` asks for `post_compact_chain_hash`, the chain head after the entry that carries it; that entry's `request_hash` commits to the value, so no sender can compute it. | The value is not checked; the answer's `chain_hash` publishes the head. `pre_compact_chain_hash` is checked. | +| §6.3's `parameter_overrides` name tool arguments; nothing says what they name in a wrapped MCP message. | For a wrapped `tools/call`, they name members of `params.arguments`. | +| ACS v0.1.0 defines no way to notify the Observed Agent that a DEFER has been resolved. | The Go Guardian adds no wire method. The Observed Agent may learn of it by other means, outside ACS. The Observed Agent can then submit the step again, and a policy engine that remembers the resolved case can return the final decision. | diff --git a/reference-implementations/agt-go/docs/differences.md b/reference-implementations/agt-go/docs/differences.md new file mode 100644 index 00000000..c201e7f3 --- /dev/null +++ b/reference-implementations/agt-go/docs/differences.md @@ -0,0 +1,100 @@ +# Differences from the TypeScript reference + +This document lists every deliberate difference between the Go Guardian and the +[TypeScript reference](../../agt/), and the reason for each. + +ACS is the authority. For behavior that ACS leaves to the policy engine, the +TypeScript reference decides. Where ACS v0.1.0 is silent, the Go Guardian chooses +the smallest behavior that preserves interoperability. +The tables record only choices a reader cannot derive directly from the public +API. + +## Protocol + +| Behavior | TypeScript reference | Go Guardian | Why | +| --- | --- | --- | --- | +| Signatures | Nothing is signed or verified. | Every request but `system/ping` must carry an HMAC-SHA256 signature over the §10 input, and every answer to a request that authenticated is signed while signing is available, otherwise it is an unsigned internal error; a ping's answer is signed only when the ping was. Where each signature is stored is in [conformance](conformance.md#behavior-that-acs-v010-leaves-undefined). | ACS-Core requires it (§10). An unsigned ping must not obtain the session key's signature over an answer whose identifiers the caller chose. A TypeScript host shim sends no signature, so it cannot talk to this Guardian until it signs. | +| HMAC secret and `key_id` | Not applicable. | The secret file is standard base64 that decodes to at least 32 bytes, read by one function for the Guardian and the host adapters; `key_id` comes from configuration, with no default, and a request under an unknown `key_id` is refused, never verified with the configured secret. | "Signing keys and secrets" in the expectations for reference implementations ([#256](https://github.com/GenAI-Security-Project/agent-control-standard/pull/256)); `key_id` as #118 settled it. | +| Session key binding | Not applicable. | The handshake records its `key_id`; a later step signed with another key that verifies is answered DENY `key_not_bound`, signed with that key, and nothing enters the session. | One key holder must not write into a session another key opened; an error in place of a decision would fall to the agent's failure posture. | +| Partial Observed Agent | The TypeScript host adapter declares only the two tool hooks it can emit. | The Guardian negotiates the subset an Observed Agent offers and refuses an unnegotiated hook with `CAPABILITY_NOT_NEGOTIATED`; it does not require a host that emits only tool hooks to claim every ACS-Core hook. | `methods_implemented` is a capability declaration in §4. ACS-Core is a deployment conformance claim, not a requirement to misrepresent a partial harness. | +| Replay protection | None. | A repeated `request_id` or nonce in a session is -32005, a replayed handshake and a step after sessionEnd included; a timestamp outside `skew_window_ms` is -32006. | §10.3. | +| Chain digest | SHA-256 over a seven-element JSON array of the tree's own fields, from sixty-four zeros. | The §8.2 digest over the RFC 8785 form of a `context-entry.json` entry, from the empty string. | §8.2 permits no other canonicalization; chains from different implementations must compare. | +| Chain head | Written to a log, not sent. | `chain_hash` on every answer for a step with a ContextEntry, covered by the signature. | §8.6. | +| Version compatibility | The ClientHello is not read. | Under 0.x, versions are compatible only when they share `X.Y`; the handshake negotiates such a version or refuses -32001, and a step must carry a compatible `acs_version`. | #196 (accepted), ahead of #255; §3 still says "matching major version". Under 0.x a minor version may break the wire. | +| Host failure resolution | The host adapter fails closed on its Guardian's refusal codes (-32700, -32010, -32011, -32020); everything else, unverified answers included, follows the posture. | The host adapter keeps the posture only for no answer or a signed -32603; a signed error of any other code, an unverified result or error, and a malformed answer deny under any posture. | An interpretation of §6.4 ahead of [#32](https://github.com/GenAI-Security-Project/agent-control-standard/issues/32), which is open: a refusal or a tampered answer is not a Guardian that failed to decide, and failing open on one would let anyone on the path allow a step. The Guardian's unsigned `shutting_down` answer is therefore denied under `proceed`. | +| Handshake | Constants; the ClientHello is not read. | Negotiated: version, the methods offered by the Observed Agent that this Guardian evaluates, transport, timeouts, skew window and posture. `profiles_accepted` is the supported intersection of `profiles_supported`, so a partial Observed Agent need not claim `acs-core`. A second handshake for a negotiated session is refused, unless it repeats the same ClientHello under the session's key, which returns the negotiated ServerHello. | §4; `methods_implemented` and `profiles_supported` declare the Observed Agent's capabilities. v0.1 defines no renegotiation; an Observed Agent that restarts must still learn its session's terms. | +| Hooks evaluated | `steps/toolCallRequest` and `steps/toolCallResult`. | All nineteen native hooks, each held to the dispositions hooks.md permits it. | ACS-Core requires a minimum set, with `subagentStart` for a subagent-capable client; the rest are SHOULD. Evaluating every hook serves any Observed Agent. | +| Rules the hooks give the Guardian | None. | A skillLoad no allowed skillRegister approved is DENY `skill_unverifiable`; a postCompact summary whose lineage is not the compacted entries' is recorded with `lineage_mismatch`; `metadata.turn_id` is written onto each entry. | `skill-load.json`, `post-compact.json` and `turn-start.json` put these on the Guardian. | +| Wrapped MCP | Not implemented. | `protocols/MCP/*` and `wrapped:mcp-/*` are governed, the explicit form at the version the Observed Agent declared. AGT decides a wrapped `tools/call` with the tool gates. The projection preserves every standard MCP result shape and addresses a redaction back into the MCP message. An override names a member of `params.arguments`. MCP identifiers are compared by their canonical JSON values, not their original spelling. A call the decision stops is not remembered, so no MCP response can be answered as though it ran. | ACS-Core requires it for every session that involves MCP. A tool called through MCP must not escape the gates a direct call meets. | +| A disposition a hook does not permit | Sent as the engine returned it. | DENY where the hook permits DENY; otherwise ALLOW with reason code `disposition_not_permitted`, and an audit event. | hooks.md fixes each hook's dispositions; postCompact and the audit-only hooks permit no DENY. | +| Invalid MODIFY at a hook that permits no DENY | Not reached: the host adapter emits only the two tool hooks, which permit DENY. | The Go host adapter library (`internal/hostadapter`) answers ALLOW, applies nothing and records `modify_invalid`. The Codex and OpenCode examples send only the tool hooks, so they do not reach this either. | §6.3 requires DENY, which these hooks do not permit; this reading follows §6.5's postCompact exception, and #270 proposes it for §6.3. | +| Undispatched or undefined method | -32011, from the reserved ACS range. | -32601 for a method ACS does not define, -32003 for one it defines and the session did not negotiate. | §17.1 defines no -32011; a code in the reserved range that the registry does not define extends the standard. | +| Invalid envelope | DENY `envelope_invalid` when addressable, else -32010. | -32600 for an envelope that fails `request-envelope.json`, -32602 for a payload that fails its method's schema. | §17 assigns -32602 to invalid params. The Observed Agent sent a request it must correct, and receives no decision to apply. | +| Oversize body | -32010. | -32600. | Same reason. | +| Guardian defect | -32020. | -32603. | Same reason. | +| Engine calls | Unbounded. | At most `MaxEngineCalls` at once; a call the Guardian stopped waiting for holds its place until the engine returns, and a step that finds none is DENY `engine_saturated`. An engine's per-session state is bounded by `MaxPolicyStateBytes`. | An engine that ignores cancellation, or state an agent can grow, must not exhaust the process. | +| Evaluation failure | DENY `evaluation_failed`, reasoning the failure's message. | DENY `evaluation_failed`, reasoning "the policy engine failed"; the engine's message goes to the audit log as a `failure` event. | The only recorded difference in the replay. A provider's error can name internals, so it stays off the wire. | +| Store failure | Not applicable. | A signed DENY `store_unavailable` for a step, a signed internal error for a handshake; the store's message goes to the audit log. | An authenticated step answered with an error would fall to the failure posture, which proceeds by default. | +| Outbound schema check | Checked and logged; the answer is sent unchanged. | A decision that would fail `response-envelope.json` is replaced by DENY `evaluation_failed` before it is signed, and the signed bytes are checked again before they are sent. | An answer the Observed Agent must reject is denied under any posture ([#32](https://github.com/GenAI-Security-Project/agent-control-standard/issues/32)), so a malformed decision would block a step the policy allowed. A custom `Signer` can return a malformed signature. | +| Schemas | Read at the first request from `../../specification/v0.1.0/` by relative path. | Compiled at start from a byte-identical embedded copy; `internal/schema.TestSchemasMatchSpecification` fails when the copy differs by one byte. | A Go module in a subdirectory is fetched without the rest of the repository, so a library user has no relative path to read. The test keeps the property the relative path gave. | +| Payload validation | The two dispatched hooks. | Every method's payload schema. | The Guardian reads only what validation checked. | +| Concurrency | Steps of one session interleave. | One session's steps are ordered under `SessionContextStore.Lock`; sessions run in parallel. A store with an expiring claim renews it, cancels `lockCtx` if ownership is lost and fences each mutation against the claim. | Otherwise two replicas can write one session out of order. | +| Session store | In memory, with a configurable least-recently-written session cap. | In memory by default, behind `SessionContextStore`. Bounded in sessions, entries, replay reservations and skill approvals; a session is removed only after `SessionRetention` without a write, never sooner than two skew windows, and a full store refuses the new session or step. | The Go store keeps replay history until every accepted timestamp is stale. Your own implementation can provide a durable store without changing the Guardian. | + +## AGT + +[How AGT is used](architecture.md#how-agt-is-used) describes the approach. This table +lists each point where the Go Guardian's AGT engine and the TypeScript reference differ. + +| Behavior | TypeScript reference | Go Guardian | Why | +| --- | --- | --- | --- | +| AGT runtime | AGT's Rust core through `agent-control-specification` on npm, at the version `agt.lock` pins. | `agt` calls the same crate through its C ABI, built from source at the commit `agt-native.lock` pins, which a test holds equal to `agt.lock`. `go` reproduces the runtime for the checked-in manifest. | No Go binding is published for this runtime. The library is built from pinned source. The Go evaluator permits builds without cgo. | +| OPA | The `opa` binary the npm package bundles, with a 5-second timeout. | With `agt`, the same `opa` release as `agt-native.lock` pins and the build checks against AGT's SDK, at `policy.opa_path` with `policy.opa_timeout`, which must be shorter than `protocol.decision_timeout` (#269). With `go`, OPA's Go library at the version `go.mod` requires, in process, under the Guardian's decision timeout. | AGT reads the `opa` path and timeout only from `ACS_OPA_PATH` and `ACS_OPA_TIMEOUT_MS`. The AGT evaluator sets them while it builds the runtime and restores them afterwards. | +| Decision time | Not measured in this tree. | Median 51.1 ms with `agt` and 0.23 ms with `go`, from `go test -tags agteval -run '^$' -bench BenchmarkEvaluators -benchtime=200x ./agtbridge` over the recorded tool-call steps, on an Intel Core i5-13500 on 9 Oct 2026. | The two evaluators run the same policy on the same input, so the gap is the `opa` process AGT starts for each decision. | +| Differences between the two evaluators | Not applicable. | None on the recorded cases. `agtbridge.TestEvaluatorsAgree` rejects any unlisted difference between the evaluators. | Both evaluators implement AGT `81955d4`. An OPA version difference can also change a decision. The exception list names the AGT versions. | +| An escalate verdict | Sent as `ask` without `ask_details`, which fails `response-envelope.json`. | An ASK with `ask_details` when an approver is configured (`agtbridge.Options.Approver`); otherwise DENY with `approver_unavailable`. | An ASK must name its approver (§9); AGT's manifest names none for ACS. | +| An empty AGT reason | No `policy_references`, and `reason_codes` is `[""]`. A deny goes without `reasoning`, which fails `response-envelope.json`. | No `policy_references` and no `reason_codes`. A deny, left without reasoning, is replaced by DENY `evaluation_failed`. A warn is an evaluation failure in both (`agtbridge.TestEmptyVerdictReasonNamesNoRule`). | An empty string names no rule ([#263](https://github.com/GenAI-Security-Project/agent-control-standard/issues/263)), and an empty reason code carries no category. | +| Methods AGT has no configured point for | Not dispatched. | Allowed, with the step in the chain. | AGT does not govern them, and the Guardian still records them. | +| Information-flow labels | Kept in the session store. | Kept in the session store as the engine's `PolicyState`. Labels a verdict returns replace the session's; none leave them unchanged. | The protocol packages know nothing of AGT, and the labels must live and die with the session. AGT serializes a verdict without an empty `result_labels` (`policy-engine/core/src/verdict.rs`, `skip_serializing_if = "Vec::is_empty"`), so an empty array cannot clear the labels in either Guardian. | +| `mapping.yaml` | Read without validation. | Validated at start: every source, wrap, decision and modification rule the translation reads. | A broken table stops the Guardian at start rather than denying at the first step that reaches it. | +| Host tool names | Each host-facing mapping names the tools that host exposes. | `policy.tool_aliases` maps a host tool name to a manifest tool name for policy evaluation; the signed ACS request and audit chain retain the original name. | A deployment can reuse one policy manifest across hosts without misrepresenting the tool identity on the ACS wire. | +| Finding the AGT tree | Anchored to the source file's own path. | `cmd/guardian` reads `policy.deployment_dir`, `policy.manifest` and `policy.mapping` from its required YAML configuration. Paths follow the configuration file, never the process working directory. | A compiled binary must be runnable outside its source checkout. A build with the AGT evaluator also needs AGT's runtime library beside it, as [configuration](configuration.md#policy) describes. | +| `ACS_MANIFEST_PATH` | Selects a manifest, for the drift demo. | `policy.manifest`, optionally overridden by `ACS__POLICY__MANIFEST`, selects a manifest inside the declared deployment directory. | The command reads its configuration from one place; it rejects a manifest path outside its deployment directory. | +| `ACS_OPA_PATH`, `ACS_OPA_TIMEOUT_MS` | Select and bound the `opa` binary. | `policy.opa_path` and `policy.opa_timeout`. | The command reads its configuration from one place. | +| Where an egress URL ends | ECMAScript `\s` (`annotate-egress.ts`): U+00A0, U+FEFF and the other Unicode spaces end a URL. | Only ASCII whitespace ends a URL. A Unicode space inside the authority makes the destination `unresolved.invalid`, which AGT denies. | [#268](https://github.com/GenAI-Security-Project/agent-control-standard/issues/268): `curl https://docs.example.com\uFEFF@evil.example.net/` reads as `docs.example.com` in the TypeScript reference. The TypeScript reference still uses `\s`. | +| A policy target that is not a string | Copied into the snapshot as is (`assemble-snapshot.ts`), so the stock rules guarded by `is_string` pass it. | DENY `evaluation_failed`; the engine error names the tool, the argument and the JSON type (`snapshot.go`). | Such a target skips every rule that checks it, so the call is allowed. The TypeScript reference is to refuse it ([#229](https://github.com/GenAI-Security-Project/agent-control-standard/issues/229)); its source in this tree does not yet, and the recorded `command_not_a_string` case still expects ALLOW. | + +The Go evaluator refuses manifest features outside its implemented subset at startup. +Unsupported features include `extends`, `approval`, Cedar, other annotators, remote bundles and intervention points outside the two tool gates. + +## Operation + +| Behavior | TypeScript reference | Go Guardian | Why | +| --- | --- | --- | --- | +| Envelope log | Synchronous appends on the decision path, unbounded. | A background writer whose queue is bounded by `audit.max_queue_records` and `audit.max_queue_bytes`, 4096 records and 64 MiB in `guardian.yaml`; a record past either limit is dropped, counted and leaves a gap in `seq`, and a write failure turns the log off for the process. The envelope file keeps the TypeScript reference's line shape, and audit events go to a second file. | A slow disk must not delay a decision or grow the process without bound. | +| Audit file size | Unbounded. | Each file rotates at `audit.max_file_bytes` and keeps `audit.max_files` older ones; `guardian.yaml` bounds each log to 1 GiB. | "Bounded logging" in the expectations for reference implementations ([#256](https://github.com/GenAI-Security-Project/agent-control-standard/pull/256)). | +| Audit events | None; the host shim writes its own. | The events §6.5 and §9.2 require, the §8.4 intent rejection, the §8.6 chain mismatch, every replaced disposition, a postCompact lineage mismatch, a toolCallResult whose `request_id_ref` names no toolCallRequest of the session, and each failure of the engine, the store or the signer with its detail. | ACS names them as the Guardian's; the detail of a failure stays off the wire. | +| Readiness | None. | `/healthz` answers while the process runs; `/readyz` and `/acs` answer once the engine is compiled. | A Guardian answering before its engine loads turns every step into a decision failure. | +| Shutdown | Stops the server. | Stops accepting, lets steps in flight finish for one grace period, cancels the remaining work, and allows a second grace period to deliver its DENY answers before forced close. A request still arriving is cancelled too. Audit records receive another full grace period to flush; failure to flush makes the command exit with an error. | A dropped answer is a decision failure, which proceeds by default. Separate bounded phases preserve cancellation answers without letting an unresponsive peer, provider or audit log block shutdown forever. | +| Prototype-key guards | The host adapter refuses `__proto__`, `constructor` and `prototype` as hookmap path segments (`reserved-segments.ts`). | None. | JSON decodes into Go maps and structs, which have no prototype chain. | + +## Measured policy limits + +The Go Guardian runs the same policies as the TypeScript reference, so it inherits their +limits and those of AGT's runtime. The TypeScript README lists +the egress gate's soft spots under "Operational debt"; these were measured +against the Go Guardian with the checked-in policies. Each is recorded, not fixed: fixing +it here alone would make the two trees disagree, and the fix belongs to the +policies or AGT's runtime, for both. + +| Input | Decision | +| --- | --- | +| `curl https://evil.example.net/` | DENY `egress_destination_not_allowed` | +| `curl HTTPS://evil.example.net/` | ALLOW: the extractor matches only a lowercase scheme. | +| `curl ftp://evil.example.net/` | ALLOW: only `http` and `https` are read. | +| `curl https://docs.example.com/ https://evil.example.net/` | ALLOW: only the first destination is read. | +| `curl evil.example.net` | ALLOW: a bare host is not read. | +| A tool result whose second output holds a token | ALLOW: the redaction rule reads `outputs[0]` only. | + +Run the checked-in policies as a demonstration of the protocol, not as an egress +control. diff --git a/reference-implementations/agt-go/docs/extending.md b/reference-implementations/agt-go/docs/extending.md new file mode 100644 index 00000000..d995ad54 --- /dev/null +++ b/reference-implementations/agt-go/docs/extending.md @@ -0,0 +1,157 @@ +# Extend the Guardian + +The four interfaces are the Guardian's extension points. Your own implementation can +replace any of them, with your own engine, key custody, database or audit destination, +and the Guardian keeps everything ACS defines. This page lists what each +implementation must do; +[architecture](architecture.md#how-a-request-moves-through-the-guardian) shows when each one is called. +[`examples/custom-engine`](../examples/custom-engine/) implements all four without AGT. + +## PolicyEngine + +Declared in [`policy_engine.go`](../guardian/policy_engine.go). + +- **`Decide` receives** + - the validated request envelope; + - its payload as raw JSON that passed the method's schema, or its wrapped protocol's + handler; + - the SessionContext after this step's ContextEntry was appended, as its own copy, which + the engine must not change. +- **Timing** + - `Decide` runs under the negotiated decision timeout. + - `ctx` ends when that timeout or the Guardian's shutdown grace expires. The Guardian + stops waiting then, even if `Decide` has not returned. + - Calls for different sessions may run at the same time. +- **What the Guardian does with the answer** + +| The engine returns | The Guardian answers | +| --- | --- | +| an error, a panic, or no answer before the deadline | DENY `evaluation_failed`, or `shutting_down` during a shutdown: the step is already in the chain | +| no call, because `MaxEngineCalls` calls are still running | DENY `engine_saturated`; a call the Guardian stopped waiting for keeps its place until the engine returns | +| a decision missing a field its disposition requires, or a MODIFY breaking §6.3 | DENY `evaluation_failed` | +| `DelegateToAgent` | DENY `agent_layer_unavailable`; this Guardian has no agent layer | +| a disposition the hook does not permit | DENY, or ALLOW where the hook permits no DENY, with reason code `disposition_not_permitted` | +| `Client.CannotApplyModify` with a MODIFY | DENY `modify_unsupported`, or ALLOW at postCompact (§6.5) | +| `Config.AskSubstitution` set to `deny` or `defer` with an ASK | the configured §9.2 substitute for this endpoint | +| `Client.CannotResolveAsk` with an ASK | `Client.AskSubstitute`, DEFER or DENY (§9.2) | +| a DEFER past the session's bound | DENY `deferral_bound_exceeded` | +| a `Grant` for a step the session was not answered ASK for | DENY `grant_invalid` | +| a `Grant` scoped to the session | an `intent_extension` ContextEntry recording the approver, the capabilities and the extension's provenance, and the Intent grown by the capabilities (§9.1) | +| valid JSON in `State` | kept with the session and given back as `SessionContext.PolicyState` on its later steps; invalid JSON is DENY `evaluation_failed`; larger than `MaxPolicyStateBytes` is DENY `policy_state_too_large` | + +- **The engine must** + - **know the Observed Agent's capability itself.** ACS puts neither MODIFY nor ASK capability on + the wire. + - `Config.AskSubstitution` is the endpoint-wide §9.2 choice. + - A more specific trusted signal goes in `PolicyDecision.Client`. + - **authenticate approvals.** ACS v0.1.0 defines no wire method for sending an approver's + answer to the Guardian. + - The engine receives the answer by its own means. + - It authenticates the approver and applies the session's `scope_mode` (§9, §9.1) + before returning a `Grant`. + - **keep state across steps in `State`.** Its own per-session memory is lost to a restart + or another replica; `State` lives and dies with the session. +- `Policy()` is read once, at `guardian.New`, for the ServerHello. + +## Signer + +Declared in [`signer.go`](../guardian/signer.go). + +- The Guardian builds `input`: the RFC 8785 form of the envelope without its `signature` + ([canonical-form.md](canonical-form.md)). + - A `Signer` only checks or signs those bytes. The keys never leave it. +- **`Verify`** + - returns an error wrapping `guardian.ErrSignatureInvalid` for a signature that does not + verify; + - any error is answered -32004; any other error is also recorded as a `failure` event. +- **`Sign`** + - receives the `key_id` that verified the request; + - a symmetric signer may reuse that key; an asymmetric signer chooses its response key and + identifies it in the returned signature; + - a signature that would not pass `response-envelope.json` is never sent. +- **Both** must return when `ctx` ends. Request cancellation and Guardian shutdown use that + context to stop an external key service that is still waiting. +- **Session binding.** The Guardian binds a session to the `key_id` and `metadata.agent_id` + of its handshake. A later step with either value changed is refused before anything is + appended. + +## SessionContextStore + +Declared in [`session_context_store.go`](../guardian/session_context_store.go). + +The Guardian computes every `entry_hash`. The store decides only where things are kept and +who may write them. + +- **`Lock`** orders a session across every Guardian that shares the store. + - A step uses the returned `lockCtx` from before `Load` until its answer is built, the + policy engine included. + - An expiring claim must be renewed while the Guardian holds it, and every mutation must + check that the claim is still owned and unexpired. + - Cancel `lockCtx` with `guardian.ErrSessionLockLost` when ownership is lost. + The Guardian stops waiting for the policy engine when `lockCtx` ends, so a lost claim + never waits on an engine that cannot stop. + - Do not hold a database connection for the full engine call. +- **`Load`** returns a copy. Nothing the caller changes reaches the store. +- **`Append`** wholly succeeds or changes nothing: + - reserve the `request_id` and nonce (`ErrReplayDetected` when either was seen in the + session); + - check the head equals `ExpectedHead` (`ErrHeadMoved` when it does not, or when the + session is gone); + - append the entry and make its `entry_hash` the head; + - record the step's lineage, set the Intent when given, close the session when asked. + - The Guardian answers DENY `session_contended` when the expected head changed. +- **`Negotiate` and `Reserve`** refuse replays too. + - A replayed handshake is `ErrReplayDetected`, not `ErrAlreadyNegotiated`. + - `Reserve` consumes a `request_id` for a step answered without an entry, a step after + sessionEnd. +- **`Conclude`** is one operation. + - It checks the head, then records the ASK step, the deferral, the engine's state and, + for an allowed skillRegister, the skill approval, all or nothing. + - The approval spans sessions: a skill registered in one loads in a later one + (`skill-load.json`). Key it by `skill_id` and digest. +- **Bounds** + - A store at capacity returns `ErrStoreFull`, and the Guardian refuses the session or + denies the step. + - Never drop a session to make room within the skew window: its handshake could be + replayed into a new one. +- **Retention** + - A session, and the `request_id`s and nonces it reserved, stay at least twice its saved + `ServerHello.skew_window_ms` after its last write. A request accepted at the future + edge of the window must become stale before its replay history can be removed. + - A store may archive `Session.Entries` and keep fewer entries than it appended (§8.5), + but `Session.Head` must always be the last `entry_hash`. + - `Session.Lineage` keeps a key for every appended `step_id`, including steps with no + provenance, so `steps/postCompact` can validate `entries_compacted`. +- A store may write its own record beside each ContextEntry in the same transaction, its + own audit chain for example. +- Run `guardiantest.TestSessionContextStore` against your store. An in-memory store cannot + prove a database's atomicity; that test run against your real database can. + +## AuditLog + +Declared in [`audit_log.go`](../guardian/audit_log.go). + +- Both methods return nothing and must not block or panic: a slow audit log must never turn + a governed step into an ungoverned one. + - Calls may run concurrently. Queue, write in the background, and report your own + failures. +- **`Envelope`** receives its own copy of every request that parsed and every answer, as + sent. +- **`Event`** receives + - the audit events the standard requires of the Guardian: `modify_unsupported`, + `ask_substituted`, `intent_mutation_rejected`, `chain_mismatch`; + - and `disposition_not_permitted`, `lineage_mismatch` and `failure`, which the Guardian + records so no decision changes silently and no failure goes unrecorded. + +## Add behavior around the Guardian + +The Guardian has no callback system. Add surrounding behavior at the existing boundaries: + +- **Around the transport:** the Guardian is an `http.Handler`, so ordinary Go middleware + wraps it, and the host serves its own routes beside it on its own mux. +- **Around the decision:** `PolicyEngine` is an interface, so an engine wraps another, to + consult a second source or to remember a case it will answer when the same step arrives + again. +- **After the answer:** `AuditLog.Envelope` receives every answer exactly as it was sent, + the Guardian's own substitutions included. A host that must act on a decision reads the + decision the Observed Agent received, not the one the engine proposed. diff --git a/reference-implementations/agt-go/docs/load.md b/reference-implementations/agt-go/docs/load.md new file mode 100644 index 00000000..a601552c --- /dev/null +++ b/reference-implementations/agt-go/docs/load.md @@ -0,0 +1,116 @@ +# Load + +`make load` sends signed `toolCallRequest` steps from many concurrent clients through +the Guardian in process, over the evaluator `EVALUATOR` selects (`agt` by default, or +`go`), and reports decisions per second, latency, `engine_saturated` refusals, the `opa` +processes alive at once and their memory, and the audit log's size on disk. The `agt` +evaluator runs one `opa` process for each decision; `go` runs none. It is not part of +`make check`. `ACS_LOAD_CLIENTS`, `ACS_LOAD_STEPS` (steps per client, all in one +session), `ACS_LOAD_MAX_ENGINE_CALLS`, `ACS_LOAD_AUDIT_MAX_FILE_BYTES` and +`ACS_LOAD_AUDIT_MAX_FILES` select the run; `agtbridge/load_test.go` defines them. The test +fails if the audit log reports an error, splits or drops a record it keeps, or exceeds its +rotation bound. The `opa` process count and memory are +read from `/proc`, so they are measured on Linux only and read 0 elsewhere. + +```bash +make load +make load EVALUATOR=go ACS_LOAD_CLIENTS=32,256 ACS_LOAD_MAX_ENGINE_CALLS=32 +``` + +## Measured on 14 October 2026 + +Linux amd64, 20 cores, 125 GiB, shared with other work (load average 18 to 27 before +the runs). 20 steps per client, the policy branches of the parity corpus, `opa_timeout` +5s. Decided/s counts every answer except `engine_saturated` refusals, which are counted +apart; it includes the Guardian's own DENY when the decision timeout ends a call. + +With `limits.max_engine_calls` 256, the library default: + +| Clients | Decided/s | p50 | p99 | Max | `engine_saturated` | `opa` at once | Their memory | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | 20 | 51ms | 52ms | 62ms | 0 | 1 | 32 MiB | +| 8 | 99 | 82ms | 94ms | 113ms | 0 | 8 | 236 MiB | +| 16 | 128 | 119ms | 168ms | 188ms | 0 | 16 | 455 MiB | +| 32 | 129 | 222ms | 488ms | 589ms | 0 | 30 | 902 MiB | +| 64 | 133 | 440ms | 1.211s | 1.479s | 0 | 61 | 1731 MiB | +| 128 | 135 | 920ms | 1.799s | 2.218s | 0 | 126 | 3608 MiB | +| 256 | 129 | 1.757s | 4.12s | 5s | 0 | 253 | 7011 MiB | +| 512 | 122 | 830ms | 5.628s | 8.508s | 5817 | 236 | 6931 MiB | + +With `limits.max_engine_calls` 32, as `guardian.yaml` ships: + +| Clients | Decided/s | p50 | p99 | Max | `engine_saturated` | `opa` at once | Their memory | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 32 | 128 | 238ms | 447ms | 527ms | 0 | 30 | 845 MiB | +| 64 | 126 | 29ms | 451ms | 708ms | 840 | 23 | 680 MiB | +| 256 | 111 | 79ms | 745ms | 1.093s | 4542 | 28 | 742 MiB | +| 512 | 116 | 241ms | 1.008s | 1.434s | 9283 | 25 | 632 MiB | + +Decisions per second stop rising near 16 concurrent decisions on this host; past that, +each added decision adds latency and about 28 MiB, not decisions. At 256, decisions +reach the 5s decision timeout, where an agent stops waiting and applies its failure +posture. With 32, no step is refused while clients do not exceed the limit, a step past +it is refused at once with a signed DENY `engine_saturated`, and every decision stays +under 1.5s. + +`limits.max_engine_calls` is a property of the host. Run `make load` on the deployment's +hardware and set it above the point where decisions per second stop rising, and below +the point where the slowest decision nears the decision timeout. + +### The go evaluator + +Same host on 14 October 2026 (load average 9 before the run), `limits.max_engine_calls` +256. OPA's Go library decides in process, so no process starts per decision: + +| Clients | Decided/s | p50 | p99 | Max | `engine_saturated` | +| --- | --- | --- | --- | --- | --- | +| 1 | 656 | 1ms | 2ms | 2ms | 0 | +| 8 | 2438 | 3ms | 6ms | 7ms | 0 | +| 32 | 5420 | 5ms | 15ms | 22ms | 0 | +| 64 | 5856 | 7ms | 31ms | 48ms | 0 | +| 128 | 8000 | 10ms | 49ms | 89ms | 0 | +| 256 | 9462 | 17ms | 81ms | 114ms | 0 | +| 512 | 9910 | 29ms | 170ms | 257ms | 0 | + +With `limits.max_engine_calls` 32, as `guardian.yaml` ships, `go` answered 256 clients +with no refusal and 512 clients with 26 refusals in 10240 steps, max 259ms. The shipped 32 +therefore suits both evaluators; the library default of 256 is sized for in-process +engines such as `go`. + +## Audit log growth + +Each step writes about 1.6 KB to the two audit files, under either evaluator. At the 130 +decisions per second `agt` sustains on this host, that is about 12 MiB a minute; at the +9900 per second `go` sustains, about 0.9 GiB a minute. `audit.max_file_bytes` and +`audit.max_files` rotate each file and bound it: `guardian.yaml` keeps at most 1 GiB per +log ([Configuration](configuration.md)). The writer's queue is bounded by +`audit.max_queue_records` and `audit.max_queue_bytes`, 4096 records and 64 MiB in +`guardian.yaml`, so a stalled disk does not grow it further; records past either limit +are dropped and counted. At 1.6 KB per step, 4096 records take about 6.5 MiB. A write +failure turns the log off for the rest of the process. + +Rotation under load, 15 October 2026, same host, `go` evaluator, 256 clients of 500 +steps, `max_engine_calls` 32, 32 MiB × 3 files: + +```bash +make load EVALUATOR=go ACS_LOAD_CLIENTS=256 ACS_LOAD_STEPS=500 ACS_LOAD_MAX_ENGINE_CALLS=32 \ + ACS_LOAD_AUDIT_MAX_FILE_BYTES=33554432 ACS_LOAD_AUDIT_MAX_FILES=3 +``` + +| Log | Records written | Kept, consecutive and newest | Files | Bytes kept | +| --- | --- | --- | --- | --- | +| `envelopes.jsonl` | 256512 | 165225 | 4 | 121616951 | +| `events.jsonl` | 11634 | 11634 | 1 | 4091082 | + +No record was dropped from the queue, split across files or kept out of order. A run with +the shipped 256 MiB × 3 filled all three archives to 256 MiB within minutes. + +## Session length + +Each step copies the session's context entries twice: when the store returns the session, +and when the Guardian builds the engine's input. A step's cost therefore grows with the +session's length, up to `limits.max_entries` (10000 in `guardian.yaml`). With 8 clients +under `go`, 20 steps per session give about 9900 decisions per second; 500 and 2000 steps +per session gave 511 and 705, measured under a CPU profiler. The tables above use 20 steps per +session; the rotation run above used 500, which is why it decided about 2600 per +second. diff --git a/reference-implementations/agt-go/docs/regression-matrix.md b/reference-implementations/agt-go/docs/regression-matrix.md new file mode 100644 index 00000000..32ab150c --- /dev/null +++ b/reference-implementations/agt-go/docs/regression-matrix.md @@ -0,0 +1,43 @@ +# Regression matrix + +This matrix records the external ACS findings checked against the Go Guardian. +It links each independently described failure to the test that exercises the +same observable behavior here. The external data is not copied into this tree. + +## Open reference-implementation findings + +Checked against the open issues on 2026-10-09. All six remain open. + +| Finding | Required behavior | Go tests | +| --- | --- | --- | +| [#160](https://github.com/GenAI-Security-Project/agent-control-standard/issues/160) | Reject a method the session did not negotiate. | `guardian.TestMethodRouting` | +| [#163](https://github.com/GenAI-Security-Project/agent-control-standard/issues/163) | Classify malformed JSON as `PARSE_ERROR` and a non-JSON-RPC request as `INVALID_REQUEST`. | `guardian.TestTransportErrors`, `guardian.TestInvalidJSONRPCPrecedesSignatureVerification` | +| [#164](https://github.com/GenAI-Security-Project/agent-control-standard/issues/164) | Reject hook traffic before a successful handshake. | `guardian.TestMethodRouting` | +| [#165](https://github.com/GenAI-Security-Project/agent-control-standard/issues/165) | Select a transport offered by both peers, or refuse the session. | `guardian.TestHandshakeRefusals`, `internal/handshake.TestEvaluatedMethods` | +| [#166](https://github.com/GenAI-Security-Project/agent-control-standard/issues/166) | Bind a negotiated session to its signing key and `agent_id`. The standard leaves the meaning of `user_context` to deployment policy, so the Guardian passes it to the policy engine instead of treating it as an immutable identity. | `guardian.TestKeyBoundToSession`, `guardian.TestAgentIDBoundToSession` | +| [#167](https://github.com/GenAI-Security-Project/agent-control-standard/issues/167) | Dispatch `steps/sessionStart` through the policy engine. | `guardian.TestCoreFloor` | + +## ACS-Core vectors + +The [ACS-Core negative conformance suite](https://github.com/probityai/agent-evidence-vectors/tree/main/vectors-acs-core) +contains 32 declarations. Nineteen cite a requirement assigned to the Guardian. +Twelve of those exercise behavior implemented by the Guardian itself. + +| External vector | Required behavior | Go tests | +| --- | --- | --- | +| `v412653087b92cb07`, `v78927805373a6c06`, `vae2037ee96435463` | Negotiate a compatible `0.1.x` version and reject `0.2.x`, `0.9.x` and another major version. | `guardian.TestHandshakeRefusals`, `internal/handshake.TestSelectVersion` | +| `vb87b86b4930665ca`, `vd67cd207a4eb6798` | Reject an invalid signature over the complete canonical request. | `guardian.TestSignatureRequired`, `internal/envelope.TestSigningInputCoversEverythingButTheSignature` | +| `v3e946bcfde26bbe2`, `vef655ce2a45f618b` | Accept a fresh request and reject a repeated `request_id`. | `guardian.TestReplayAndSkew` | +| `va6d6e952d417c5a1`, `vb10df32610db1173` | Accept a timestamp inside the negotiated window and reject one outside it. | `guardian.TestReplayAndSkew`, `guardian.TestWithinSkew` | +| `v25f01d8fe1e91bf5`, `v7b0b32fb369136c1` | Publish the signed chain head and reject a stale Observed Agent head. | `guardian.TestChainPublication`, `guardian.TestChainMismatch` | +| `va200b64093301e14` | Do not return `ASK` when the Observed Agent cannot resolve it. | `guardian.TestConfiguredAskSubstitution`, `guardian.TestClientSubstitutions` | + +`v82b6d110b4d68e7c` declares its timestamp-revocation case unmeasurable and +therefore creates no executable expectation. + +The remaining six Guardian-labelled vectors concern approver authentication or +strict-scope policy. In the Go Guardian those decisions belong to the +injected `guardian.PolicyEngine`; the Guardian validates the returned ACS shape +and maintains the session chain, but it must not invent deployment identity or +authorization policy. `guardian.TestIntentExtension` and +`guardian.TestEngineFailuresAreDenials` cover that interface boundary. diff --git a/reference-implementations/agt-go/docs/test-parity.md b/reference-implementations/agt-go/docs/test-parity.md new file mode 100644 index 00000000..e2c5dfa1 --- /dev/null +++ b/reference-implementations/agt-go/docs/test-parity.md @@ -0,0 +1,76 @@ +# TypeScript test parity + +This table accounts for the TypeScript reference's 69 test files. Where the +same behavior exists in the Go Guardian, it names the corresponding Go +tests. Otherwise it records why the test does not apply. The table does not +claim identical test structure or general behavioral equivalence. + +Go tests are named `.`, run from this directory with +`go test ./ -run `. + +## Guardian and AGT bridge + +| TypeScript test | Go counterpart | +| --- | --- | +| `packages/guardian/test/server.test.ts` | `guardian.TestCoreFloor`, `guardian.TestEnvelopeGate`, `guardian.TestTransportErrors`, `guardian.TestBodyCap`, `guardian.TestEngineFailuresAreDenials`, `guardian.TestMethodRouting`, `agtbridge.TestDecisions`, `agtbridge.TestResultRedaction`, `agtbridge.TestRecordedCases` | +| `packages/guardian/test/validate-envelope.test.ts` | `internal/schema.TestValidateRequest`, `internal/schema.TestValidatePayload`, `guardian.TestEnvelopeGate` | +| `packages/guardian/test/deny-on-invalid-envelope.test.ts` | `guardian.TestEnvelopeGate`; an unaddressable envelope gets -32600 (differences.md) | +| `packages/guardian/test/check-response.test.ts` | `guardian.TestHandshake` (the ServerHello against `response-envelope.json`), `guardian.TestEngineFailuresAreDenials` (an unfit decision is never sent) | +| `packages/guardian/test/handshake.test.ts` | `guardian.TestHandshake`, `guardian.TestHandshakeRefusals`, `guardian.TestNewRefusesIncompleteConfig` (an unknown posture stops the Guardian) | +| `packages/guardian/test/session-context.test.ts` | `internal/chain.TestVector`, `internal/chain.TestEntryHashCommitsToEveryMember`, `guardian.TestChainPublication`; the digest differs by design (differences.md) | +| `packages/guardian/test/ifc-labels.test.ts` | `agtbridge.TestLabelsRoundTrip`, `agtbridge.TestEmptyLabelsLeaveSessionState`, `agtbridge/goeval.TestNormalizeResultLabels`, `guardian.TestPolicyStateFollowsTheSession` | +| `packages/guardian/test/map-verdict.test.ts` | `agtbridge.TestDecisions` (reasoning, reason codes, references), `agtbridge.TestResultRedaction`, `agtbridge.TestEscalate`, `agtbridge.TestRecordedCases` | +| `packages/guardian/test/assemble-snapshot.test.ts` | `agtbridge.TestRecordedCases` (`policy_target_argument_missing`, `argument_named_like_the_leaf`, `raw_command_absent`, the result cases) | +| `packages/guardian/test/annotate-egress.test.ts` | `agtbridge.TestWhatwgOrigin`, `agtbridge.TestRecordedCases` (every `egress_*` and `fetch_*` case) | +| `packages/guardian/test/envelope-log-sink.test.ts` | `guardian.TestJSONLAuditLog`, `guardian.TestJSONLAuditLogRefusesAnUnwritablePath` | +| `packages/guardian/test/envelope-log-sink-wiring.test.ts` | `guardian.TestAuditLogReceivesEveryEnvelope` | +| `packages/agt-bridge/test/bridge.test.ts` | `agtbridge.TestDecisions`, `agtbridge.TestNewRefusesAnUnsupportedManifest`, `agtbridge/agteval.TestFailuresAreVerdicts`, `agtbridge/agteval.TestEgressAnnotatorRunsInTheCallback` | +| `packages/agt-bridge/test/opa-path.test.ts` | `agtbridge/agteval.TestOPATimeoutReachesAGT`, `agtbridge/agteval.TestNewRefuses` (`missing_opa`). The Go evaluator runs OPA in process. | + +## Whole-tree tests + +| TypeScript test | Go counterpart | +| --- | --- | +| `test/dispositions.test.ts` | `guardian.TestCoreFloor` (all five through the Guardian), `agtbridge.TestEscalate` (escalate), `agtbridge.TestLabelsRoundTrip` (warn), `agtbridge.TestResultRedaction` (transform) | +| `test/redaction.test.ts` | `agtbridge.TestResultRedaction`, `agtbridge.TestRecordedCases` (`result_*` cases) | +| `test/ifc-round-trip.test.ts` | `agtbridge.TestLabelsRoundTrip`, `agtbridge.TestRecordedCases` (`labels_across_steps`) | +| `test/handshake-declares-what-it-evaluates.test.ts` | `guardian.TestHandshake` (the declared hooks exactly), `guardian.TestCoreFloor` (every hook evaluated), `internal/handshake.TestEvaluatedMethods` | +| `test/invariants.test.ts` | `agtbridge.TestAGTConfinedToAgtbridge`; the host-side gates do not apply | +| `test/path-dialects.test.ts` | `agtbridge/goeval.TestPathDialectsAgree` | +| `test/session-context-roundtrip.test.ts` | `guardian.TestReserveAndAppendAtomicity`, `guardian.TestMemoryStoreRefusesWhenFull` | +| `test/envelope-log-sink-roundtrip.test.ts` | `guardian.TestJSONLAuditLog` (the Inspector's line shape) | +| `test/readme-captures.test.ts` | Does not apply. The Go command's runnable configuration and commands are documented directly in `README.md`. | +| `test/pin.test.ts` | Does not apply: the Go Guardian copies no policy files; it reads `../agt/policy/lib/`, whose version that test fixes. | +| `test/audit-sink-roundtrip.test.ts` | Does not apply: the host adapter's audit log. | + +## Conformance harness + +The existing TypeScript command still measures what ACS can express of AGT. +When `ACS_CONFORMANCE_GUARDIAN_URL` is set, the same command also sends fixed, +signed ACS requests to an external Guardian. `make conformance` starts the Go +Guardian on a free port and runs that external mode. `make docs-check` checks +the Go Guardian's ACS-Core table and its references to Go tests. + +| TypeScript test | Go counterpart | +| --- | --- | +| `packages/conformance/test/external-guardian.test.ts` | `make conformance` verifies the independent JCS, HKDF and HMAC vectors, then runs the shared signed wire checks against a live Go Guardian. | +| `packages/conformance/test/failure-domains.test.ts` | `guardian.TestEngineFailuresAreDenials`, `guardian.TestShutdownAnswersInFlightSteps` | +| `packages/conformance/test/exit-code.test.ts` | `internal/docscheck.TestConformanceRequiresTestsForClaims` | +| `packages/conformance/test/policy-input-schema.test.ts` | Not ported: it fetches AGT's schema from GitHub. The recorded cases prove the policy input decides as AGT's does. | +| `packages/conformance/test/diff-surfaces.test.ts`, `fetch-upstream.test.ts`, `identity.test.ts`, `intervention-points.test.ts`, `main.test.ts`, `merge-cells.test.ts`, `render-coverage-matrix.test.ts`, `render-mapping-table.test.ts`, `render-upstream-diff.test.ts`, `surfaces.test.ts`, `tools-registry.test.ts`, `trace-pillar.test.ts`, `upstream-schema-check.test.ts`, `upstream-watch.test.ts`, `verdicts.test.ts` | Do not apply: the harness's coverage matrix, upstream watch and Trace rows. | + +## Hosts, host adapter and Inspector + +The Go Guardian ships Codex `PreToolUse` and OpenCode V2 `execute.before` and +`execute.after` examples. `internal/hostadapter` owns their shared signed session, +handshake, timeout, failure-posture and audit behavior; each example contains only the +host event conversion and decision application. `internal/observedagent` is the lower +level Observed Agent shared by those examples and the Guardian tests. + +| TypeScript tests | Go counterpart | +| --- | --- | +| `hosts/claude-code/test/*.test.ts` (4 files) | Do not apply: no Claude Code example is shipped. | +| `hosts/opencode/test/*.test.ts` (6 files) | The TypeScript adapter targets OpenCode V1. The current V2 request and result gates are covered by `acs-opencode-hook.TestRunGovernsRequestAndResult`, `acs-opencode-hook.TestRunBlocksEveryNonProceedingDisposition`, `acs-opencode-hook.TestProjectPluginConfiguration` and the [plugin callback test](../examples/opencode/plugin.test.mjs). | +| Codex `PreToolUse` | `examples/codex/cmd/acs-codex-hook.TestRunSendsSignedDecisionAndReusesHandshake`, `examples/codex/cmd/acs-codex-hook.TestRunTranslatesDecisions`, `internal/hostadapter.TestSessionUUIDIsStableAndAgentScoped`, `examples/codex/cmd/acs-codex-hook.TestProjectHookConfiguration` | +| `packages/host-adapter/test/*.test.ts` (11 files) | The Go adapter is deliberately smaller. Its shared behavior is covered by `internal/hostadapter.TestDecisionTimeoutUsesMethodOverride`, `internal/hostadapter.TestLoadStateDefaultsAnOmittedFailurePosture`, `internal/hostadapter.TestSessionUUIDIsStableAndAgentScoped`, `internal/observedagent.TestHonour`, and the Codex and OpenCode adapter command tests. | +| `packages/inspector/test/*.test.ts` (4 files) | Do not apply. The envelope file keeps the line shape the Inspector reads (`guardian.TestJSONLAuditLog`). | diff --git a/reference-implementations/agt-go/examples/codex/README.md b/reference-implementations/agt-go/examples/codex/README.md new file mode 100644 index 00000000..36ca3b07 --- /dev/null +++ b/reference-implementations/agt-go/examples/codex/README.md @@ -0,0 +1,56 @@ +# Codex example + +This example sends Codex `PreToolUse` events for `Bash` to the Go Guardian as signed +`steps/toolCallRequest` messages. ACS ALLOW completes the hook, ACS DENY becomes +Codex's native `permissionDecision: deny`, and ACS MODIFY supplies `updatedInput`. +ASK and DEFER become DENY because Codex does not support either response at +`PreToolUse`. + +The checked-in [`.codex`](../../.codex) configuration belongs to this Go module. Codex +loads it when it starts from the module root. It does not change the user's Codex +configuration or the configuration at the repository root. + +Prerequisites are the Guardian's, listed in the [module README](../../README.md#quick-start), +and an installed, authenticated `codex` command. This example was tested with Codex CLI 0.154.0. + +Run from `reference-implementations/agt-go`: + +```bash +cd agent-control-standard/reference-implementations/agt-go +make codex-example +``` + +The command creates a local development HMAC secret on first run, builds the Guardian +and adapter, starts the Guardian on a free local port, and opens Codex. The Guardian +stops when Codex exits, including an error or interrupt. +Each invocation has its own temporary Guardian logs, so this example can run +at the same time as the OpenCode example. +You can use the Codex session normally. The command uses the account already configured +by `codex login`; it does not supply an API key or create separate model billing. +The target enables this checkout's project hook without a trust prompt and grants +workspace network access so the hook can reach the loopback Guardian. Both settings +apply only to the Codex process started by this command. Network access is not +limited to the hook: it also opens the network to the commands Codex itself +runs in that session, so the policy alone decides what may leave the machine. +Inspect [`.codex/hooks.json`](../../.codex/hooks.json) before running it. + +The hook reads `ACS_GUARDIAN_URL` when it is set and otherwise uses +`http://127.0.0.1:8787/acs`. `make codex-example` sets the temporary endpoint +for the Codex process it starts. + +Ask Codex to run `ls -la`; the checked-in policy allows it. Ask Codex to run +`echo rm -rf /`; the checked-in policy denies it before Bash runs. + +For a deployment, provide a separate Guardian configuration and HMAC secret. The hook +state contains only the negotiated ServerHello. It holds no keying material. The hook +appends a local event to `.acs/codex-audit.jsonl` when: + +- a negotiated `proceed` failure posture lets a tool call continue without a verified + decision; +- it denies a modification it cannot apply. + +If that write fails, the tool call is denied, the event goes to the hook's stderr as one +JSON line with `"disposition":"deny"`, and the reason says so. + +If the initial handshake produces no ServerHello, this example uses the ACS startup +posture `refuse` and denies the tool call. diff --git a/reference-implementations/agt-go/examples/codex/cmd/acs-codex-hook/main.go b/reference-implementations/agt-go/examples/codex/cmd/acs-codex-hook/main.go new file mode 100644 index 00000000..771cceff --- /dev/null +++ b/reference-implementations/agt-go/examples/codex/cmd/acs-codex-hook/main.go @@ -0,0 +1,192 @@ +// Command acs-codex-hook translates Codex PreToolUse input into a signed ACS step. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/hostadapter" +) + +const ( + defaultGuardianURL = "http://127.0.0.1:8787/acs" + defaultAgentID = "codex" + defaultTimeout = 10 * time.Second + defaultMaxInput = 1 << 20 +) + +type options struct { + guardianURL string + keyID string + secretFile string + stateDir string + auditLog string + agentID string + timeout time.Duration + maxInputBytes int64 +} + +type hookInput struct { + SessionID string `json:"session_id"` + TurnID string `json:"turn_id"` + ToolUseID string `json:"tool_use_id"` + Event string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` +} + +type hookOutput struct { + HookSpecificOutput struct { + Event string `json:"hookEventName"` + Decision string `json:"permissionDecision,omitempty"` + Reason string `json:"permissionDecisionReason,omitempty"` + Updated any `json:"updatedInput,omitempty"` + } `json:"hookSpecificOutput"` +} + +func main() { + os.Exit(execute(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func execute(args []string, input io.Reader, output, errOutput io.Writer) int { + if err := run(args, input, output, errOutput); err == nil { + return 0 + } else { + fmt.Fprintln(errOutput, "acs-codex-hook:", err) + } + if err := writeDecision(output, acs.Decision{Disposition: acs.Deny, Reasoning: "the ACS policy adapter could not evaluate this tool call"}, "PreToolUse", nil); err != nil { + fmt.Fprintln(errOutput, "acs-codex-hook: write denial:", err) + return 2 + } + return 0 +} + +func run(args []string, input io.Reader, output, errOutput io.Writer) error { + opts, err := parseOptions(args) + if err != nil { + return err + } + event, err := readEvent(input, opts.maxInputBytes) + if err != nil { + return err + } + payload, err := hostadapter.ToolCallRequest(event.ToolName, event.ToolInput) + if err != nil { + return err + } + client, err := hostadapter.New(hostadapter.Config{ + GuardianURL: opts.guardianURL, HMACKeyID: opts.keyID, HMACSecretFile: opts.secretFile, + StateDir: opts.stateDir, AuditLog: opts.auditLog, AgentID: opts.agentID, + Methods: []string{acs.StepToolCallRequest}, Timeout: opts.timeout, MaxBodyBytes: opts.maxInputBytes, + Stderr: errOutput, + }) + if err != nil { + return err + } + step := hostadapter.Step{ + SessionKey: event.SessionID, + TurnID: event.TurnID, + CallID: event.ToolUseID, + Method: acs.StepToolCallRequest, + Payload: payload, + } + evaluation, err := client.Evaluate(context.Background(), step) + if err != nil { + return err + } + var update any + if evaluation.Decision.Disposition == acs.Modify { + var modified map[string]any + modified, err = hostadapter.ModifiedToolCallInput(evaluation.ModifiedPayload, payload) + if err == nil { + if _, ok := modified["command"].(string); !ok { + err = errors.New("a Codex Bash update requires a string command") + } + } + update = modified + if err != nil { + evaluation.Decision = client.RefuseModification(step, "the Codex hook cannot apply the Guardian modification") + } + } + return writeDecision(output, evaluation.Decision, event.Event, update) +} + +func parseOptions(args []string) (options, error) { + flags := flag.NewFlagSet("acs-codex-hook", flag.ContinueOnError) + flags.SetOutput(io.Discard) + options := options{} + guardianURL := os.Getenv("ACS_GUARDIAN_URL") + if guardianURL == "" { + guardianURL = defaultGuardianURL + } + flags.StringVar(&options.guardianURL, "guardian-url", guardianURL, "ACS Guardian endpoint") + flags.StringVar(&options.keyID, "hmac-key-id", "", "key_id the deployment gave the HMAC secret") + flags.StringVar(&options.secretFile, "hmac-secret-file", "", "file holding the shared HMAC secret, base64") + flags.StringVar(&options.stateDir, "state-dir", ".acs/codex", "directory for negotiated session state") + flags.StringVar(&options.auditLog, "audit-log", ".acs/codex-audit.jsonl", "file for local audit events: fail-open decisions and refused modifications") + flags.StringVar(&options.agentID, "agent-id", defaultAgentID, "ACS agent identity") + flags.DurationVar(&options.timeout, "timeout", defaultTimeout, "maximum handshake round trip") + flags.Int64Var(&options.maxInputBytes, "max-input-bytes", defaultMaxInput, "largest hook input accepted") + if err := flags.Parse(args); err != nil { + return options, err + } + if flags.NArg() != 0 { + return options, fmt.Errorf("unexpected argument %q", flags.Arg(0)) + } + if options.keyID == "" || options.secretFile == "" { + return options, errors.New("--hmac-key-id and --hmac-secret-file are required") + } + if options.guardianURL == "" || options.stateDir == "" || options.auditLog == "" || options.agentID == "" { + return options, errors.New("--guardian-url, --state-dir, --audit-log and --agent-id must not be empty") + } + if options.timeout <= 0 || options.maxInputBytes <= 0 { + return options, errors.New("--timeout and --max-input-bytes must be positive") + } + return options, nil +} + +func readEvent(input io.Reader, maxBytes int64) (hookInput, error) { + var event hookInput + data, err := io.ReadAll(io.LimitReader(input, maxBytes+1)) + if err != nil { + return event, fmt.Errorf("read hook input: %w", err) + } + if int64(len(data)) > maxBytes { + return event, fmt.Errorf("hook input exceeds %d bytes", maxBytes) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&event); err != nil { + return event, fmt.Errorf("read hook input: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return event, errors.New("hook input must contain one JSON object") + } + if event.SessionID == "" || event.TurnID == "" || event.ToolUseID == "" || event.Event != "PreToolUse" || event.ToolName == "" || len(event.ToolInput) == 0 { + return event, errors.New("hook input requires session_id, turn_id, tool_use_id, hook_event_name PreToolUse, tool_name and tool_input") + } + return event, nil +} + +func writeDecision(output io.Writer, decision acs.Decision, event string, updated any) error { + response := hookOutput{} + response.HookSpecificOutput.Event = event + response.HookSpecificOutput.Reason = decision.Reasoning + switch decision.Disposition { + case acs.Allow: + response.HookSpecificOutput.Reason = "" + case acs.Modify: + response.HookSpecificOutput.Decision = "allow" + response.HookSpecificOutput.Updated = updated + default: + response.HookSpecificOutput.Decision = "deny" + } + return json.NewEncoder(output).Encode(response) +} diff --git a/reference-implementations/agt-go/examples/codex/cmd/acs-codex-hook/main_test.go b/reference-implementations/agt-go/examples/codex/cmd/acs-codex-hook/main_test.go new file mode 100644 index 00000000..addd0e93 --- /dev/null +++ b/reference-implementations/agt-go/examples/codex/cmd/acs-codex-hook/main_test.go @@ -0,0 +1,349 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/envelope" +) + +const ( + testSecret = "0123456789abcdef0123456789abcdef" + testKeyID = "2026-10" +) + +type testEngine struct { + decision acs.Decision + policy guardian.PolicyDescription + requests atomic.Int64 +} + +func (e *testEngine) Policy() guardian.PolicyDescription { return e.policy } + +func (e *testEngine) Decide(_ context.Context, _ guardian.PolicyInput) (guardian.PolicyDecision, error) { + e.requests.Add(1) + return guardian.PolicyDecision{Decision: e.decision}, nil +} + +func TestRunSendsSignedDecisionAndReusesHandshake(t *testing.T) { + engine := &testEngine{decision: acs.Decision{Disposition: acs.Allow}} + url, calls := testGuardian(t, engine) + secretFile := writeSecret(t) + stateDir := t.TempDir() + for _, toolUseID := range []string{"tool-1", "tool-2"} { + var output bytes.Buffer + if err := run(optionsFor(url, secretFile, stateDir), bytes.NewBufferString(event(toolUseID)), &output, io.Discard); err != nil { + t.Fatal(err) + } + assertOutput(t, output.Bytes(), "", "", "") + } + if got := calls.Load(); got != 3 { + t.Fatalf("Guardian calls = %d, want handshake + two steps", got) + } + if got := engine.requests.Load(); got != 2 { + t.Fatalf("policy calls = %d, want 2", got) + } +} + +func TestRunTranslatesDecisions(t *testing.T) { + tests := []struct { + name, wantDecision, wantReason, wantCommand string + disposition acs.Disposition + modifications *acs.Modifications + }{ + {name: "allow", disposition: acs.Allow}, + {name: "deny", disposition: acs.Deny, wantDecision: "deny", wantReason: "policy decision"}, + {name: "ask", disposition: acs.Ask, wantDecision: "deny", wantReason: "policy decision"}, + {name: "modify", disposition: acs.Modify, wantDecision: "allow", wantReason: "policy decision", wantCommand: "printf rewritten", modifications: &acs.Modifications{ParameterOverrides: map[string]json.RawMessage{"command": json.RawMessage(`"printf rewritten"`)}}}, + {name: "modify_raw_command", disposition: acs.Modify, wantDecision: "deny", wantReason: "the Codex hook cannot apply the Guardian modification", modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/raw_command"}}}}, + {name: "modify_non_string_command", disposition: acs.Modify, wantDecision: "deny", wantReason: "the Codex hook cannot apply the Guardian modification", modifications: &acs.Modifications{ParameterOverrides: map[string]json.RawMessage{"command": json.RawMessage(`42`)}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decision := acs.Decision{Disposition: tt.disposition, Reasoning: "policy decision", Modifications: tt.modifications} + if tt.disposition == acs.Ask { + decision.AskDetails = &acs.AskDetails{Approver: acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"}, Question: "approve?", TimeoutSeconds: 1} + } + engine := &testEngine{decision: decision} + if tt.disposition == acs.Ask { + engine.policy.ApproverTypes = []acs.ApproverType{acs.ApproverHuman} + } + url, _ := testGuardian(t, engine) + stateDir := t.TempDir() + var output bytes.Buffer + if err := run(optionsFor(url, writeSecret(t), stateDir), bytes.NewBufferString(event("tool-1")), &output, io.Discard); err != nil { + t.Fatal(err) + } + assertOutput(t, output.Bytes(), tt.wantDecision, tt.wantReason, tt.wantCommand) + audit := readAudit(t, stateDir) + refused := tt.wantReason == "the Codex hook cannot apply the Guardian modification" + if refused != strings.Contains(audit, `"event":"modify_unsupported"`) || + refused != strings.Contains(audit, `"reason_codes":["modify_unsupported"]`) { + t.Fatalf("audit log = %q", audit) + } + }) + } +} + +func TestRunDeniesAHandshakeRefusal(t *testing.T) { + engine := &testEngine{policy: guardian.PolicyDescription{RequiresProvenance: true}} + url, _ := testGuardian(t, engine) + var output bytes.Buffer + if err := run(optionsFor(url, writeSecret(t), t.TempDir()), bytes.NewBufferString(event("tool-1")), &output, io.Discard); err != nil { + t.Fatal(err) + } + assertOutput(t, output.Bytes(), "deny", "the Guardian did not accept this ACS session", "") +} + +func TestRunRecordsAProceedFailure(t *testing.T) { + engine := &testEngine{decision: acs.Decision{Disposition: acs.Allow}} + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: testKeyID, Secret: []byte(testSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: engine, Signer: signer, OnDecisionFailure: acs.FailureProceed}) + if err != nil { + t.Fatal(err) + } + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + g.ServeHTTP(w, r) + return + } + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + stateDir := t.TempDir() + var output bytes.Buffer + if err := run(optionsFor(server.URL, writeSecret(t), stateDir), bytes.NewBufferString(event("tool-1")), &output, io.Discard); err != nil { + t.Fatal(err) + } + assertOutput(t, output.Bytes(), "", "", "") + if audit := readAudit(t, stateDir); !strings.Contains(audit, `"event":"decision_failure"`) { + t.Fatalf("audit event = %s", audit) + } +} + +// A refused modification stays denied when its audit record cannot be written, +// and the reason says so. +func TestRunDeniesWhenTheAuditRecordFails(t *testing.T) { + engine := &testEngine{decision: acs.Decision{Disposition: acs.Modify, Reasoning: "policy decision", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/raw_command"}}}}} + url, _ := testGuardian(t, engine) + blocker := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatal(err) + } + args := append(optionsFor(url, writeSecret(t), t.TempDir()), "--audit-log", filepath.Join(blocker, "audit.jsonl")) + var output bytes.Buffer + if err := run(args, bytes.NewBufferString(event("tool-1")), &output, io.Discard); err != nil { + t.Fatal(err) + } + assertOutput(t, output.Bytes(), "deny", "the Codex hook cannot apply the Guardian modification; the audit log could not be written, and the record went to stderr", "") +} + +// §6.3: a signed MODIFY combining a replacement with edits is DENY, and the +// substitution is recorded. +func TestRunRecordsAnInvalidModification(t *testing.T) { + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: testKeyID, Secret: []byte(testSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: &testEngine{}, Signer: signer, OnDecisionFailure: acs.FailureProceed}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + var request acs.Request + if err := json.Unmarshal(body, &request); err != nil { + t.Error(err) + return + } + if request.Method != acs.StepToolCallRequest { + r.Body = io.NopCloser(bytes.NewReader(body)) + g.ServeHTTP(w, r) + return + } + unsigned := `{"jsonrpc":"2.0","id":` + string(request.ID) + `,"result":{"type":"final","acs_version":"0.1.0","request_id":"` + request.Params.RequestID + `","decision":"modify","reasoning":"rewrite command","modifications":{"modified_content":"{}","parameter_overrides":{}}}}` + input, _, err := envelope.ResponseSigningInput([]byte(unsigned)) + if err != nil { + t.Error(err) + return + } + signature, err := signer.Sign(r.Context(), request.Params.Metadata.SessionID, "", input) + if err != nil { + t.Error(err) + return + } + signed, err := json.Marshal(signature) + if err != nil { + t.Error(err) + return + } + _, _ = w.Write([]byte(strings.TrimSuffix(unsigned, "}}") + `,"signature":` + string(signed) + `}}`)) + })) + t.Cleanup(server.Close) + stateDir := t.TempDir() + var output bytes.Buffer + if err := run(optionsFor(server.URL, writeSecret(t), stateDir), bytes.NewBufferString(event("tool-1")), &output, io.Discard); err != nil { + t.Fatal(err) + } + assertOutput(t, output.Bytes(), "deny", "rewrite command", "") + if audit := readAudit(t, stateDir); !strings.Contains(audit, `"event":"modify_invalid"`) { + t.Fatalf("audit log = %q", audit) + } +} + +func TestExecuteDeniesAdapterFailure(t *testing.T) { + var output, errOutput bytes.Buffer + status := execute([]string{"--hmac-secret-file", "/missing"}, bytes.NewBufferString(`not JSON`), &output, &errOutput) + if status != 0 { + t.Fatalf("exit status = %d, want 0", status) + } + assertOutput(t, output.Bytes(), "deny", "the ACS policy adapter could not evaluate this tool call", "") + if errOutput.Len() == 0 { + t.Fatal("expected diagnostic on stderr") + } +} + +func TestProjectHookConfiguration(t *testing.T) { + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate test source") + } + path := filepath.Join(filepath.Dir(source), "..", "..", "..", "..", ".codex", "hooks.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var config struct { + Hooks map[string][]struct { + Matcher string `json:"matcher"` + Hooks []struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout *int `json:"timeout"` + } `json:"hooks"` + } `json:"hooks"` + } + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatal(err) + } + preToolUse := config.Hooks["PreToolUse"] + if len(preToolUse) != 1 || preToolUse[0].Matcher != "^Bash$" || len(preToolUse[0].Hooks) != 1 { + t.Fatalf("PreToolUse configuration = %s", raw) + } + hook := preToolUse[0].Hooks[0] + if hook.Type != "command" || !strings.Contains(hook.Command, ".acs/bin/acs-codex-hook") || strings.Contains(hook.Command, "127.0.0.1:8787") { + t.Fatalf("PreToolUse command = %q", hook.Command) + } + if hook.Timeout != nil { + t.Fatal("Codex hook imposes a deadline outside the negotiated ACS timeout") + } +} + +func TestGuardianURLComesFromTheEnvironment(t *testing.T) { + t.Setenv("ACS_GUARDIAN_URL", "http://127.0.0.1:43210/acs") + got, err := parseOptions([]string{"--hmac-key-id", testKeyID, "--hmac-secret-file", "secret"}) + if err != nil { + t.Fatal(err) + } + if got.guardianURL != "http://127.0.0.1:43210/acs" { + t.Fatalf("guardian URL = %q", got.guardianURL) + } +} + +func testGuardian(t *testing.T, engine guardian.PolicyEngine) (string, *atomic.Int64) { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: testKeyID, Secret: []byte(testSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: engine, Signer: signer, OnDecisionFailure: acs.FailureDeny}) + if err != nil { + t.Fatal(err) + } + calls := &atomic.Int64{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + g.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + return server.URL, calls +} + +func writeSecret(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "hmac-secret") + if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString([]byte(testSecret))), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func optionsFor(url, secretFile, stateDir string) []string { + return []string{"--guardian-url", url, "--hmac-key-id", testKeyID, "--hmac-secret-file", secretFile, "--state-dir", stateDir, "--audit-log", filepath.Join(stateDir, "audit.jsonl")} +} + +// readAudit returns the audit log optionsFor names, or "" when nothing was recorded. +func readAudit(t *testing.T, stateDir string) string { + t.Helper() + audit, err := os.ReadFile(filepath.Join(stateDir, "audit.jsonl")) + if errors.Is(err, fs.ErrNotExist) { + return "" + } + if err != nil { + t.Fatal(err) + } + return string(audit) +} + +func event(toolUseID string) string { + return `{"session_id":"thr_example","turn_id":"turn-1","tool_use_id":"` + toolUseID + `","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"printf original"}}` +} + +func assertOutput(t *testing.T, raw []byte, decision, reason, command string) { + t.Helper() + var output struct { + HookSpecificOutput struct { + Decision string `json:"permissionDecision"` + Reason string `json:"permissionDecisionReason"` + Updated map[string]any `json:"updatedInput"` + } `json:"hookSpecificOutput"` + } + if err := json.Unmarshal(raw, &output); err != nil { + t.Fatal(err) + } + if output.HookSpecificOutput.Decision != decision || output.HookSpecificOutput.Reason != reason { + t.Fatalf("output = %s", raw) + } + if command == "" { + if output.HookSpecificOutput.Updated != nil { + t.Fatalf("unexpected updated input: %s", raw) + } + return + } + if output.HookSpecificOutput.Updated["command"] != command { + t.Fatalf("updated input = %s", raw) + } +} diff --git a/reference-implementations/agt-go/examples/custom-engine/README.md b/reference-implementations/agt-go/examples/custom-engine/README.md new file mode 100644 index 00000000..101f9a3e --- /dev/null +++ b/reference-implementations/agt-go/examples/custom-engine/README.md @@ -0,0 +1,23 @@ +# Custom engine example + +This standalone module embeds the Guardian without AGT. It demonstrates the +four interfaces with its own implementations: + +- `PolicyEngine`: denies commands containing `rm -rf` or `mkfs`; +- `Signer`: looks up HMAC keying material by `key_id`; +- `SessionContextStore`: wraps the bounded in-memory store; +- `AuditLog`: receives Guardian audit events. + +From this directory, provide at least 32 bytes of shared keying material and +run the example: + +```bash +ACS_HMAC_KEY_ID=2026-10 ACS_HMAC_SECRET="$(openssl rand -base64 32)" go run . +``` + +The default endpoint is `http://127.0.0.1:8788/acs`. Use `--listen` to select +another address. + +This module uses a local `replace` directive so it tests the current checkout. +For what each implementation must do, read +[extend the Guardian](../../docs/extending.md). diff --git a/reference-implementations/agt-go/examples/custom-engine/go.mod b/reference-implementations/agt-go/examples/custom-engine/go.mod new file mode 100644 index 00000000..809f99b4 --- /dev/null +++ b/reference-implementations/agt-go/examples/custom-engine/go.mod @@ -0,0 +1,15 @@ +module github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/examples/custom-engine + +go 1.27.3 + +require ( + github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go v0.0.0 + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 +) + +require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + golang.org/x/text v0.42.0 // indirect +) + +replace github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go => ../.. diff --git a/reference-implementations/agt-go/examples/custom-engine/go.sum b/reference-implementations/agt-go/examples/custom-engine/go.sum new file mode 100644 index 00000000..9caaf1a3 --- /dev/null +++ b/reference-implementations/agt-go/examples/custom-engine/go.sum @@ -0,0 +1,8 @@ +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= diff --git a/reference-implementations/agt-go/examples/custom-engine/main.go b/reference-implementations/agt-go/examples/custom-engine/main.go new file mode 100644 index 00000000..c1fedd1d --- /dev/null +++ b/reference-implementations/agt-go/examples/custom-engine/main.go @@ -0,0 +1,156 @@ +// Command custom-engine runs the Guardian with a host's own implementation +// of each of the four interfaces, and no AGT in its build: a policy engine +// that denies listed commands, a Signer over keys the host holds, a +// SessionContextStore that counts what it keeps, and an AuditLog that +// prints to standard error. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync/atomic" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +// denyListEngine denies a tool call whose raw command contains a listed +// fragment, and allows every other step. +type denyListEngine struct{ fragments []string } + +func (denyListEngine) Policy() guardian.PolicyDescription { return guardian.PolicyDescription{} } + +func (e denyListEngine) Decide(_ context.Context, in guardian.PolicyInput) (guardian.PolicyDecision, error) { + allow := guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}} + if in.Request.Method != acs.StepToolCallRequest { + return allow, nil + } + var call acs.ToolCallRequestPayload + if err := json.Unmarshal(in.Request.Params.Payload, &call); err != nil { + return guardian.PolicyDecision{}, err + } + if call.RawCommand == nil { + return allow, nil + } + for _, f := range e.fragments { + if strings.Contains(*call.RawCommand, f) { + return guardian.PolicyDecision{Decision: acs.Decision{ + Disposition: acs.Deny, + Reasoning: fmt.Sprintf("the command contains %q, which this deployment forbids", f), + ReasonCodes: []string{"deny_listed_command"}, + }}, nil + } + } + return allow, nil +} + +// vaultSigner stands for a host's key custody: it looks the secret up by +// key_id at each call and never hands it out. +type vaultSigner struct { + vault map[string][]byte + // signing is the key_id that signs an answer to an unsigned request. + signing string +} + +func (vaultSigner) Algorithms() []string { return []string{acs.SignatureAlgorithmHMACSHA256} } + +func (v vaultSigner) signer(keyID string) (*guardian.HMACSigner, error) { + secret, ok := v.vault[keyID] + if !ok { + return nil, fmt.Errorf("%w: no key %q in the vault", guardian.ErrSignatureInvalid, keyID) + } + return guardian.NewHMACSigner(guardian.HMACKey{ID: keyID, Secret: secret}) +} + +func (v vaultSigner) Verify(ctx context.Context, sessionID string, input []byte, sig acs.Signature) error { + s, err := v.signer(sig.KeyID) + if err != nil { + return err + } + return s.Verify(ctx, sessionID, input, sig) +} + +func (v vaultSigner) Sign(ctx context.Context, sessionID, keyID string, input []byte) (acs.Signature, error) { + if keyID == "" { + keyID = v.signing + } + s, err := v.signer(keyID) + if err != nil { + return acs.Signature{}, err + } + return s.Sign(ctx, sessionID, keyID, input) +} + +// countingStore keeps sessions in the default store and counts appends; a +// host's database store implements the same methods. +type countingStore struct { + *guardian.MemorySessionContextStore + appends atomic.Int64 +} + +func (s *countingStore) Append(ctx context.Context, sessionID string, a guardian.Append) error { + err := s.MemorySessionContextStore.Append(ctx, sessionID, a) + if err == nil { + s.appends.Add(1) + } + return err +} + +// stderrAuditLog prints each audit event. +type stderrAuditLog struct{} + +func (stderrAuditLog) Envelope(context.Context, guardian.EnvelopeRecord) {} + +func (stderrAuditLog) Event(_ context.Context, e guardian.AuditEvent) { + fmt.Fprintf(os.Stderr, "audit %s session=%s request=%s: %s\n", e.Kind, e.SessionID, e.RequestID, e.Message) +} + +func newGuardian(keyID string, secret []byte) (*guardian.Guardian, *countingStore, error) { + if keyID == "" { + return nil, nil, errors.New("the key_id must not be empty") + } + if len(secret) < guardian.MinHMACSecretBytes { + return nil, nil, errors.New("the secret must be at least 32 bytes") + } + memory, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{ + MaxSessions: guardian.DefaultMaxSessions, + MaxEntries: guardian.DefaultMaxEntries, + MaxReservations: guardian.DefaultMaxReservations, + MaxSkillApprovals: guardian.DefaultMaxSkillApprovals, + Retention: guardian.DefaultSessionRetention, + }) + if err != nil { + return nil, nil, err + } + store := &countingStore{MemorySessionContextStore: memory} + g, err := guardian.New(guardian.Config{ + Engine: denyListEngine{fragments: []string{"rm -rf", "mkfs"}}, + Signer: vaultSigner{vault: map[string][]byte{keyID: secret}, signing: keyID}, + Store: store, + AuditLog: stderrAuditLog{}, + }) + return g, store, err +} + +func main() { + addr := flag.String("listen", "127.0.0.1:8788", "address to serve POST /acs on") + flag.Parse() + secret, err := guardian.DecodeHMACSecret([]byte(os.Getenv("ACS_HMAC_SECRET"))) + if err != nil { + log.Fatal(err) + } + g, _, err := newGuardian(os.Getenv("ACS_HMAC_KEY_ID"), secret) + if err != nil { + log.Fatal(err) + } + http.Handle("/acs", g) + log.Printf("Guardian with a custom engine listening at http://%s/acs", *addr) + log.Fatal(http.ListenAndServe(*addr, nil)) +} diff --git a/reference-implementations/agt-go/examples/custom-engine/main_test.go b/reference-implementations/agt-go/examples/custom-engine/main_test.go new file mode 100644 index 00000000..d72f37d7 --- /dev/null +++ b/reference-implementations/agt-go/examples/custom-engine/main_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-json-experiment/json/jsontext" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian/guardiantest" +) + +const ( + secret = "0123456789abcdef0123456789abcdef" + keyID = "2026-10" +) + +// send signs a request with the host's own Signer and posts it. +func send(t *testing.T, url string, signer guardian.Signer, method, session string, payload any) acs.Response { + t.Helper() + p, _ := json.Marshal(payload) + var b [16]byte + _, _ = rand.Read(b[:]) + b[6], b[8] = b[6]&0x0f|0x40, b[8]&0x3f|0x80 + requestID := fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) + req := acs.Request{JSONRPC: acs.JSONRPCVersion, Method: method, ID: json.RawMessage(`"` + requestID + `"`), Params: acs.Params{ + ACSVersion: acs.Version, RequestID: requestID, Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Metadata: acs.Metadata{AgentID: "example", SessionID: session}, Payload: p, + }} + body, _ := json.Marshal(req) + canonical := jsontext.Value(body) + if err := canonical.Canonicalize(); err != nil { + t.Fatal(err) + } + sig, err := signer.Sign(context.Background(), session, keyID, canonical) + if err != nil { + t.Fatal(err) + } + req.Params.Signature = &sig + body, _ = json.Marshal(req) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out acs.Response + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out +} + +func TestServesAGovernedStep(t *testing.T) { + g, store, err := newGuardian(keyID, []byte(secret)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(g) + defer server.Close() + signer := vaultSigner{vault: map[string][]byte{keyID: []byte(secret)}, signing: keyID} + session := "5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30" + hello := acs.ClientHello{ + ACSVersionsSupported: []string{acs.Version}, + MethodsImplemented: []string{acs.StepSessionStart, acs.StepUserMessage, acs.StepToolCallRequest, acs.StepToolCallResult, acs.StepAgentResponse, acs.StepSessionEnd}, + TransportsSupported: []string{acs.TransportHTTP}, + ProvenanceProducer: acs.ProvenanceNone, + ProfilesSupported: []string{acs.ProfileCore}, + } + if r := send(t, server.URL, signer, acs.MethodHandshakeHello, session, hello); r.Error != nil { + t.Fatalf("handshake: %+v", r.Error) + } + command := "rm -rf /tmp/x" + r := send(t, server.URL, signer, acs.StepToolCallRequest, session, acs.ToolCallRequestPayload{ + Tool: acs.Tool{Name: "shell"}, Arguments: map[string]acs.ToolArgument{"command": {Value: json.RawMessage(`"` + command + `"`)}}, RawCommand: &command, + }) + var result acs.Result + if r.Error != nil || json.Unmarshal(r.Result, &result) != nil || result.Disposition != acs.Deny || result.ChainHash == "" || result.Signature == nil { + t.Fatalf("got %+v %s", r.Error, r.Result) + } + if store.appends.Load() != 1 { + t.Fatalf("the host's store saw %d appends, want 1", store.appends.Load()) + } +} + +// TestStoreContract holds the host's store to the contract every +// SessionContextStore meets. +func TestStoreContract(t *testing.T) { + guardiantest.TestSessionContextStore(t, func(t *testing.T) guardian.SessionContextStore { + memory, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{MaxSessions: 8, MaxEntries: 64, MaxReservations: 128, MaxSkillApprovals: 8, Retention: time.Hour}) + if err != nil { + t.Fatal(err) + } + return &countingStore{MemorySessionContextStore: memory} + }) +} diff --git a/reference-implementations/agt-go/examples/opencode/README.md b/reference-implementations/agt-go/examples/opencode/README.md new file mode 100644 index 00000000..c1082605 --- /dev/null +++ b/reference-implementations/agt-go/examples/opencode/README.md @@ -0,0 +1,53 @@ +# OpenCode example + +This example sends OpenCode V2 `execute.before` and `execute.after` tool events to +the Go Guardian as signed `steps/toolCallRequest` and `steps/toolCallResult` +messages. The checked-in [plugin](../../.opencode/plugins/acs.ts) is local to this +module and does not change the user's OpenCode configuration. + +Prerequisites are the Guardian's, listed in the [module README](../../README.md#quick-start), +and an installed `opencode` command with an available model. The model may come from a connected +provider or from the free models in OpenCode's catalog. This example was tested +with OpenCode 2.0.11. + +Run from `reference-implementations/agt-go`: + +```bash +cd agent-control-standard/reference-implementations/agt-go +make opencode-example +``` + +The command creates a local development HMAC secret on first run, builds the +Guardian and adapter, starts the Guardian on a free local port, and opens +OpenCode. The Guardian stops when OpenCode exits, including an error or +interrupt. Each invocation has its own temporary Guardian logs, so this +example can run at the same time as the Codex example. No JavaScript package +installation is required. + +Ask OpenCode to run `printf ACS_OPENCODE_OK`; the checked-in policy allows it. +Ask it to run `echo rm -rf /`; the checked-in policy denies it before the shell +runs. + +The request gate applies ALLOW, DENY and MODIFY before a tool executes. ASK and +DEFER block because OpenCode has no ACS approval or deferral channel. The result +gate sends completed and failed results to the Guardian before the model sees them. +DENY, ASK or DEFER replaces the result or error with the Guardian's reason. MODIFY +is applied only when its replacement fits the original result status; an unusable +replacement fails closed. + +To connect the plugin to a Guardian already running elsewhere, build the adapter +and set the endpoint for the OpenCode process: + +```bash +go build -o .acs/bin/acs-opencode-hook ./examples/opencode/cmd/acs-opencode-hook +ACS_GUARDIAN_URL=http://127.0.0.1:8788/acs ACS_OPENCODE_HMAC_KEY_ID=2026-10 opencode +``` + +`ACS_OPENCODE_HMAC_KEY_ID` names the `key_id` of the shared secret and has no default: +without it the plugin blocks every tool call. It does not refuse at load, because +OpenCode skips a plugin whose setup throws and runs tools ungoverned. +`make opencode-example` sets it to `2026-10`, the value `guardian.yaml` uses. `ACS_OPENCODE_HMAC_SECRET_FILE` selects the secret file. +The other defaults are `http://127.0.0.1:8787/acs` and `.acs/hmac-secret`, the values +`guardian.yaml` and `make dev-secret` use. +If the initial handshake produces no ServerHello, this example uses the ACS startup +posture `refuse` and blocks the tool event. diff --git a/reference-implementations/agt-go/examples/opencode/cmd/acs-opencode-hook/main.go b/reference-implementations/agt-go/examples/opencode/cmd/acs-opencode-hook/main.go new file mode 100644 index 00000000..3c92e048 --- /dev/null +++ b/reference-implementations/agt-go/examples/opencode/cmd/acs-opencode-hook/main.go @@ -0,0 +1,204 @@ +// Command acs-opencode-hook translates OpenCode tool events into signed ACS steps. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/hostadapter" +) + +const ( + beforeEvent = "execute.before" + afterEvent = "execute.after" + defaultGuardianURL = "http://127.0.0.1:8787/acs" + defaultAgentID = "opencode" + defaultTimeout = 10 * time.Second + defaultMaxInput = 1 << 20 +) + +type options struct { + guardianURL string + keyID string + secretFile string + stateDir string + auditLog string + agentID string + timeout time.Duration + maxInputBytes int64 +} + +type hookInput struct { + Event string `json:"event"` + SessionID string `json:"session_id"` + MessageID string `json:"message_id"` + CallID string `json:"call_id"` + Tool string `json:"tool"` + Input json.RawMessage `json:"input"` + Status string `json:"status,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error json.RawMessage `json:"error,omitempty"` +} + +type hookOutput struct { + Decision acs.Disposition `json:"decision"` + Reasoning string `json:"reasoning,omitempty"` + UpdatedInput any `json:"updated_input,omitempty"` + UpdatedResult any `json:"updated_result,omitempty"` +} + +func main() { + os.Exit(execute(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func execute(args []string, input io.Reader, output, errOutput io.Writer) int { + if err := run(args, input, output, errOutput); err == nil { + return 0 + } else { + fmt.Fprintln(errOutput, "acs-opencode-hook:", err) + } + if err := writeOutput(output, hookOutput{Decision: acs.Deny, Reasoning: "the ACS policy adapter could not evaluate this tool event"}); err != nil { + fmt.Fprintln(errOutput, "acs-opencode-hook: write denial:", err) + return 2 + } + return 0 +} + +func run(args []string, input io.Reader, output, errOutput io.Writer) error { + opts, err := parseOptions(args) + if err != nil { + return err + } + event, err := readEvent(input, opts.maxInputBytes) + if err != nil { + return err + } + payload, method, err := payloadFor(event, opts.agentID) + if err != nil { + return err + } + client, err := hostadapter.New(hostadapter.Config{ + GuardianURL: opts.guardianURL, HMACKeyID: opts.keyID, HMACSecretFile: opts.secretFile, + StateDir: opts.stateDir, AuditLog: opts.auditLog, AgentID: opts.agentID, + Methods: []string{acs.StepToolCallRequest, acs.StepToolCallResult}, + Timeout: opts.timeout, MaxBodyBytes: opts.maxInputBytes, + Stderr: errOutput, + }) + if err != nil { + return err + } + step := hostadapter.Step{ + SessionKey: event.SessionID, + TurnID: event.MessageID, + CallID: event.CallID, + Method: method, + Payload: payload, + } + evaluation, err := client.Evaluate(context.Background(), step) + if err != nil { + return err + } + response := hookOutput{Decision: evaluation.Decision.Disposition, Reasoning: evaluation.Decision.Reasoning} + if evaluation.Decision.Disposition == acs.Modify { + switch method { + case acs.StepToolCallRequest: + response.UpdatedInput, err = hostadapter.ModifiedToolCallInput(evaluation.ModifiedPayload, payload.(acs.ToolCallRequestPayload)) + case acs.StepToolCallResult: + response.UpdatedResult, err = hostadapter.ModifiedToolCallResult(evaluation.ModifiedPayload, payload.(acs.ToolCallResultPayload)) + } + if err != nil { + refusal := client.RefuseModification(step, "the OpenCode plugin cannot apply the Guardian modification") + response = hookOutput{Decision: refusal.Disposition, Reasoning: refusal.Reasoning} + } + } + return writeOutput(output, response) +} + +func parseOptions(args []string) (options, error) { + flags := flag.NewFlagSet("acs-opencode-hook", flag.ContinueOnError) + flags.SetOutput(io.Discard) + options := options{} + flags.StringVar(&options.guardianURL, "guardian-url", defaultGuardianURL, "ACS Guardian endpoint") + flags.StringVar(&options.keyID, "hmac-key-id", "", "key_id the deployment gave the HMAC secret") + flags.StringVar(&options.secretFile, "hmac-secret-file", "", "file holding the shared HMAC secret, base64") + flags.StringVar(&options.stateDir, "state-dir", ".acs/opencode", "directory for negotiated session state") + flags.StringVar(&options.auditLog, "audit-log", ".acs/opencode-audit.jsonl", "file for local audit events: fail-open decisions and refused modifications") + flags.StringVar(&options.agentID, "agent-id", defaultAgentID, "ACS agent identity") + flags.DurationVar(&options.timeout, "timeout", defaultTimeout, "maximum handshake round trip") + flags.Int64Var(&options.maxInputBytes, "max-input-bytes", defaultMaxInput, "largest hook input accepted") + if err := flags.Parse(args); err != nil { + return options, err + } + if flags.NArg() != 0 { + return options, fmt.Errorf("unexpected argument %q", flags.Arg(0)) + } + if options.keyID == "" || options.secretFile == "" { + return options, errors.New("--hmac-key-id and --hmac-secret-file are required") + } + if options.guardianURL == "" || options.stateDir == "" || options.auditLog == "" || options.agentID == "" { + return options, errors.New("--guardian-url, --state-dir, --audit-log and --agent-id must not be empty") + } + if options.timeout <= 0 || options.maxInputBytes <= 0 { + return options, errors.New("--timeout and --max-input-bytes must be positive") + } + return options, nil +} + +func readEvent(input io.Reader, maxBytes int64) (hookInput, error) { + var event hookInput + data, err := io.ReadAll(io.LimitReader(input, maxBytes+1)) + if err != nil { + return event, fmt.Errorf("read hook input: %w", err) + } + if int64(len(data)) > maxBytes { + return event, fmt.Errorf("hook input exceeds %d bytes", maxBytes) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&event); err != nil { + return event, fmt.Errorf("read hook input: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return event, errors.New("hook input must contain one JSON object") + } + if event.SessionID == "" || event.MessageID == "" || event.CallID == "" || event.Tool == "" || len(event.Input) == 0 { + return event, errors.New("hook input requires session_id, message_id, call_id, tool and input") + } + if event.Event != beforeEvent && event.Event != afterEvent { + return event, fmt.Errorf("unsupported OpenCode event %q", event.Event) + } + if event.Event == afterEvent && event.Status != "completed" && event.Status != "error" { + return event, errors.New("execute.after requires status completed or error") + } + return event, nil +} + +func payloadFor(event hookInput, agentID string) (any, string, error) { + if event.Event == beforeEvent { + payload, err := hostadapter.ToolCallRequest(event.Tool, event.Input) + return payload, acs.StepToolCallRequest, err + } + exitStatus := "success" + output := event.Result + if event.Status == "error" { + exitStatus = "failure" + output = event.Error + } + if len(output) == 0 { + output = json.RawMessage("null") + } + sessionID := hostadapter.SessionUUID(agentID, event.SessionID) + requestID := hostadapter.RequestUUID(sessionID, acs.StepToolCallRequest, event.CallID) + return hostadapter.ToolCallResult(event.Tool, requestID, exitStatus, output), acs.StepToolCallResult, nil +} + +func writeOutput(output io.Writer, response hookOutput) error { + return json.NewEncoder(output).Encode(response) +} diff --git a/reference-implementations/agt-go/examples/opencode/cmd/acs-opencode-hook/main_test.go b/reference-implementations/agt-go/examples/opencode/cmd/acs-opencode-hook/main_test.go new file mode 100644 index 00000000..5ca7143d --- /dev/null +++ b/reference-implementations/agt-go/examples/opencode/cmd/acs-opencode-hook/main_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +const ( + testSecret = "0123456789abcdef0123456789abcdef" + testKeyID = "2026-10" +) + +type testEngine struct { + decisions []acs.Decision + requests atomic.Int64 +} + +func (*testEngine) Policy() guardian.PolicyDescription { + return guardian.PolicyDescription{ApproverTypes: []acs.ApproverType{acs.ApproverHuman}} +} + +func (e *testEngine) Decide(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + index := int(e.requests.Add(1)) - 1 + if index >= len(e.decisions) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}}, nil + } + return guardian.PolicyDecision{Decision: e.decisions[index]}, nil +} + +func TestRunGovernsRequestAndResult(t *testing.T) { + engine := &testEngine{decisions: []acs.Decision{ + { + Disposition: acs.Modify, + Reasoning: "rewrite command", + Modifications: &acs.Modifications{ParameterOverrides: map[string]json.RawMessage{ + "command": json.RawMessage(`"printf rewritten"`), + }}, + }, + { + Disposition: acs.Modify, + Reasoning: "redact output", + Modifications: &acs.Modifications{Redactions: []acs.Redaction{ + {Path: "/outputs/0/value/content"}, + }}, + }, + }} + url, calls := testGuardian(t, engine) + args := optionsFor(url, writeSecret(t), t.TempDir()) + + before := runEvent(t, args, `{"event":"execute.before","session_id":"session","message_id":"message","call_id":"call","tool":"bash","input":{"command":"printf original"}}`) + if before.Decision != acs.Modify || before.UpdatedInput.(map[string]any)["command"] != "printf rewritten" { + t.Fatalf("before response = %+v", before) + } + after := runEvent(t, args, `{"event":"execute.after","session_id":"session","message_id":"message","call_id":"call","tool":"bash","input":{"command":"printf original"},"status":"completed","result":{"content":"secret","metadata":{"safe":true}}}`) + result := after.UpdatedResult.(map[string]any) + if after.Decision != acs.Modify || result["content"] != "[REDACTED]" { + t.Fatalf("after response = %+v", after) + } + if calls.Load() != 3 { + t.Fatalf("Guardian calls = %d, want handshake and two steps", calls.Load()) + } +} + +func TestRunBlocksEveryNonProceedingDisposition(t *testing.T) { + for _, disposition := range []acs.Disposition{acs.Deny, acs.Ask, acs.Defer} { + t.Run(string(disposition), func(t *testing.T) { + decision := acs.Decision{Disposition: disposition, Reasoning: "blocked"} + switch disposition { + case acs.Ask: + decision.AskDetails = &acs.AskDetails{Approver: acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"}, Question: "allow?", TimeoutSeconds: 1} + case acs.Defer: + decision.DeferDetails = &acs.DeferDetails{Reason: acs.DeferPendingDependency, ResolutionMethod: acs.ResolveTimeout, ResolutionTimeoutMS: 1, TimeoutDecision: acs.Deny} + } + engine := &testEngine{decisions: []acs.Decision{decision}} + url, _ := testGuardian(t, engine) + response := runEvent(t, optionsFor(url, writeSecret(t), t.TempDir()), `{"event":"execute.before","session_id":"session","message_id":"message","call_id":"call","tool":"bash","input":{"command":"echo safe"}}`) + if response.Decision != disposition || response.Reasoning != "blocked" { + t.Fatalf("response = %+v", response) + } + }) + } +} + +// §6.5: whether the shared adapter or the OpenCode projection refuses the +// MODIFY, the answer is DENY modify_unsupported and the audit log records it. +func TestRunRefusesAModificationItCannotApply(t *testing.T) { + tests := []struct { + name, wantReason string + modifications acs.Modifications + }{ + {name: "override_of_a_missing_argument", wantReason: "the host cannot apply the Guardian modification", modifications: acs.Modifications{ParameterOverrides: map[string]json.RawMessage{"timeout": json.RawMessage(`5`)}}}, + {name: "redaction_of_the_tool_name", wantReason: "the OpenCode plugin cannot apply the Guardian modification", modifications: acs.Modifications{Redactions: []acs.Redaction{{Path: "/tool/name"}}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine := &testEngine{decisions: []acs.Decision{{Disposition: acs.Modify, Reasoning: "modify", Modifications: &tt.modifications}}} + url, _ := testGuardian(t, engine) + stateDir := t.TempDir() + response := runEvent(t, optionsFor(url, writeSecret(t), stateDir), `{"event":"execute.before","session_id":"session","message_id":"message","call_id":"call","tool":"bash","input":{"command":"echo safe"}}`) + if response.Decision != acs.Deny || response.Reasoning != tt.wantReason { + t.Fatalf("response = %+v", response) + } + audit, err := os.ReadFile(filepath.Join(stateDir, "audit.jsonl")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(audit), `"event":"modify_unsupported"`) || !strings.Contains(string(audit), `"reason_codes":["modify_unsupported"]`) { + t.Fatalf("audit log = %s", audit) + } + }) + } +} + +func TestExecuteFailsClosed(t *testing.T) { + var output, diagnostics bytes.Buffer + status := execute([]string{"--hmac-secret-file", "/missing"}, bytes.NewBufferString("not JSON"), &output, &diagnostics) + if status != 0 { + t.Fatalf("status = %d", status) + } + var response hookOutput + if err := json.Unmarshal(output.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Decision != acs.Deny || diagnostics.Len() == 0 { + t.Fatalf("response = %+v, diagnostics = %q", response, diagnostics.String()) + } +} + +func TestFailedEventKeepsTheCompleteOpenCodeError(t *testing.T) { + payload, method, err := payloadFor(hookInput{ + Event: "execute.after", SessionID: "session", MessageID: "message", CallID: "call", Tool: "bash", + Input: json.RawMessage(`{"command":"false"}`), Status: "error", + Error: json.RawMessage(`{"message":"failed","error":{"token":"secret"},"metadata":{"exit":1}}`), + }, "opencode") + if err != nil { + t.Fatal(err) + } + if method != acs.StepToolCallResult { + t.Fatalf("method = %s", method) + } + result := payload.(acs.ToolCallResultPayload) + if len(result.Outputs) != 1 || !strings.Contains(string(result.Outputs[0].Value), `"token":"secret"`) { + t.Fatalf("failed result payload = %+v", result) + } +} + +func TestProjectPluginConfiguration(t *testing.T) { + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate test source") + } + path := filepath.Join(filepath.Dir(source), "..", "..", "..", "..", ".opencode", "plugins", "acs.ts") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, required := range []string{`ctx.tool.hook("execute.before"`, `ctx.tool.hook("execute.after"`, ".acs/bin/acs-opencode-hook"} { + if !strings.Contains(text, required) { + t.Fatalf("plugin does not contain %q", required) + } + } + if strings.Contains(text, "setTimeout(") { + t.Fatal("plugin imposes a process deadline outside the negotiated ACS timeout") + } +} + +func runEvent(t *testing.T, args []string, event string) hookOutput { + t.Helper() + var output bytes.Buffer + if err := run(args, bytes.NewBufferString(event), &output, io.Discard); err != nil { + t.Fatal(err) + } + var response hookOutput + if err := json.Unmarshal(output.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response +} + +func testGuardian(t *testing.T, engine guardian.PolicyEngine) (string, *atomic.Int64) { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: testKeyID, Secret: []byte(testSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: engine, Signer: signer, OnDecisionFailure: acs.FailureDeny}) + if err != nil { + t.Fatal(err) + } + calls := &atomic.Int64{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + g.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + return server.URL, calls +} + +func writeSecret(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "hmac-secret") + if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString([]byte(testSecret))), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func optionsFor(url, secretFile, stateDir string) []string { + return []string{"--guardian-url", url, "--hmac-key-id", testKeyID, "--hmac-secret-file", secretFile, "--state-dir", stateDir, "--audit-log", filepath.Join(stateDir, "audit.jsonl")} +} diff --git a/reference-implementations/agt-go/examples/opencode/plugin.test.mjs b/reference-implementations/agt-go/examples/opencode/plugin.test.mjs new file mode 100644 index 00000000..19b19f41 --- /dev/null +++ b/reference-implementations/agt-go/examples/opencode/plugin.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import test from "node:test" + +import plugin, { applyAfterReply } from "../../.opencode/plugins/acs.ts" + +test("the shipped plugin governs real callbacks through the running Guardian", async () => { + const callbacks = {} + await plugin.setup({ + tool: { + hook: async (name, callback) => { + callbacks[name] = callback + }, + }, + }) + + const allowed = { + sessionID: "opencode-plugin-test", + messageID: "message-1", + id: "call-allow", + tool: "shell", + input: { command: "printf ACS_OPENCODE_OK" }, + } + await callbacks["execute.before"](allowed) + assert.deepEqual(allowed.input, { command: "printf ACS_OPENCODE_OK" }) + + await assert.rejects( + callbacks["execute.before"]({ + sessionID: "opencode-plugin-test", + messageID: "message-1", + id: "call-deny", + tool: "shell", + input: { command: "echo rm -rf /" }, + }), + /destructive|denied|deny/i, + ) + + const completed = { + sessionID: "opencode-plugin-test", + messageID: "message-1", + id: "call-allow", + tool: "shell", + input: allowed.input, + status: "completed", + result: { content: "ACS_OPENCODE_OK", metadata: { exit: 0 } }, + } + await callbacks["execute.after"](completed) + assert.deepEqual(completed.result, { content: "ACS_OPENCODE_OK", metadata: { exit: 0 } }) +}) + +test("completed results honor allow, modify, and blocking decisions", () => { + const allowed = { status: "completed", result: { content: "original" } } + applyAfterReply(allowed, { decision: "allow" }) + assert.deepEqual(allowed.result, { content: "original" }) + + const modified = { status: "completed", result: { content: "original" } } + applyAfterReply(modified, { decision: "modify", updated_result: { content: "redacted" } }) + assert.deepEqual(modified.result, { content: "redacted" }) + + const invalid = { status: "completed", result: { content: "original" } } + applyAfterReply(invalid, { decision: "modify", updated_result: "not an OpenCode result" }) + assert.deepEqual(invalid.result, { + content: "The Guardian modification could not be applied", + metadata: { acs: { decision: "deny" } }, + }) + + const denied = { status: "completed", result: { content: "secret" } } + applyAfterReply(denied, { decision: "deny", reasoning: "blocked" }) + assert.deepEqual(denied.result, { content: "blocked", metadata: { acs: { decision: "deny" } } }) +}) + +test("failed results honor allow, modify, and blocking decisions", () => { + const allowed = { status: "error", error: { message: "original", metadata: { code: 1 } } } + applyAfterReply(allowed, { decision: "allow" }) + assert.deepEqual(allowed.error, { message: "original", metadata: { code: 1 } }) + + const modified = { status: "error", error: { message: "secret", error: { token: "secret" }, metadata: { code: 1 } } } + applyAfterReply(modified, { decision: "modify", updated_result: { message: "[REDACTED]", error: { token: "[REDACTED]" }, metadata: { code: 1 } } }) + assert.deepEqual(modified.error, { message: "[REDACTED]", error: { token: "[REDACTED]" }, metadata: { code: 1 } }) + + const denied = { status: "error", error: { message: "secret", metadata: { code: 1 } } } + applyAfterReply(denied, { decision: "deny", reasoning: "blocked" }) + assert.deepEqual(denied.error, { message: "blocked", metadata: { acs: { decision: "deny" } } }) +}) + +test("an unusable modification fails closed", () => { + const completed = { status: "completed", result: { content: "secret" } } + applyAfterReply(completed, { decision: "modify" }) + assert.deepEqual(completed.result, { + content: "The Guardian modification could not be applied", + metadata: { acs: { decision: "deny" } }, + }) + + const failed = { status: "error", error: { message: "secret" } } + applyAfterReply(failed, { decision: "modify", updated_result: { content: "not an OpenCode error" } }) + assert.deepEqual(failed.error, { + message: "The Guardian modification could not be applied", + metadata: { acs: { decision: "deny" } }, + }) +}) + +test("without ACS_OPENCODE_HMAC_KEY_ID the plugin blocks a tool call", () => { + const pluginURL = new URL("../../.opencode/plugins/acs.ts", import.meta.url).href + const script = ` + const { default: plugin } = await import(${JSON.stringify(pluginURL)}) + const callbacks = {} + await plugin.setup({ tool: { hook: async (name, callback) => { callbacks[name] = callback } } }) + if (!callbacks["execute.before"] || !callbacks["execute.after"]) throw new Error("hooks not registered") + console.log("hooks registered") + await callbacks["execute.before"]({ sessionID: "s", messageID: "m", id: "c", tool: "shell", input: { command: "true" } }) + ` + const { ACS_OPENCODE_HMAC_KEY_ID: _, ...unset } = process.env + for (const env of [unset, { ...unset, ACS_OPENCODE_HMAC_KEY_ID: "" }]) { + const child = spawnSync(process.execPath, ["-e", script], { env, encoding: "utf8" }) + assert.match(child.stdout, /hooks registered/) + assert.notEqual(child.status, 0) + assert.match(child.stderr, /ACS_OPENCODE_HMAC_KEY_ID must name the key_id/) + } +}) diff --git a/reference-implementations/agt-go/go.mod b/reference-implementations/agt-go/go.mod new file mode 100644 index 00000000..fe247048 --- /dev/null +++ b/reference-implementations/agt-go/go.mod @@ -0,0 +1,64 @@ +module github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go + +go 1.27.3 + +require ( + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/knadh/koanf/parsers/yaml v1.1.1 + github.com/knadh/koanf/providers/env/v2 v2.0.1 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.3.4 + github.com/open-policy-agent/opa v1.20.2 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/net v0.60.0 + golang.org/x/sys v0.48.0 + golang.org/x/text v0.42.0 +) + +require ( + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.3.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect + github.com/lestrrat-go/jwx/v3 v3.2.0 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect + github.com/valyala/fastjson v1.6.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.36 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect + golang.org/x/tools v0.50.0 // indirect + golang.org/x/vuln v1.8.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + honnef.co/go/tools v0.8.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +tool ( + golang.org/x/vuln/cmd/govulncheck + honnef.co/go/tools/cmd/staticcheck +) diff --git a/reference-implementations/agt-go/go.sum b/reference-implementations/agt-go/go.sum new file mode 100644 index 00000000..54f0691a --- /dev/null +++ b/reference-implementations/agt-go/go.sum @@ -0,0 +1,174 @@ +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgraph-io/badger/v4 v4.9.6 h1:IQqMPVGLNCQr1b4Mu8lHkYm/xyqFRsyKaFEtyLi9CCQ= +github.com/dgraph-io/badger/v4 v4.9.6/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= +github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= +github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= +github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA= +github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/open-policy-agent/opa v1.20.2 h1:dlX5VEGA5M62W8+X1nRPEaQIh/CMRn5FcD1mebpKWKg= +github.com/open-policy-agent/opa v1.20.2/go.mod h1:yYKTQAlkbefoXSYChT4ek8gc8WCtScL7L7OSZxFVe0s= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.8.0 h1:clG4qBU6zH5VKjti8n5j8BBuYzoSha392xXMkXS351U= +golang.org/x/vuln v1.8.0/go.mod h1:Fzm4XK3Hbl1ZvZ7JpNTEWb7CJWOZ7m2LX0GLu4Fsrwo= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/reference-implementations/agt-go/guardian.yaml b/reference-implementations/agt-go/guardian.yaml new file mode 100644 index 00000000..6c9fabbb --- /dev/null +++ b/reference-implementations/agt-go/guardian.yaml @@ -0,0 +1,52 @@ +# Runnable local configuration. +# Sessions are in memory; audit files contain raw ACS envelopes. +server: + host: 127.0.0.1 + port: "8787" + read_header_timeout: 10s + read_timeout: 30s + write_timeout: 30s + idle_timeout: 2m + shutdown_grace: 10s + +policy: + deployment_dir: ../agt + manifest: policy/manifest.yaml + mapping: mapping.yaml + evaluator: agt + opa_path: .acs/agt/bin + opa_timeout: 4s + ask_substitution: deny + tool_aliases: + shell: bash + +security: + hmac_key_id: "2026-10" + hmac_secret_file: .acs/hmac-secret + +storage: + provider: memory + +audit: + envelope_log: .acs/envelopes.jsonl + event_log: .acs/guardian-events.jsonl + max_file_bytes: 268435456 # 256 MiB; with max_files 3, at most 1 GiB per log + max_files: 3 + max_queue_records: 4096 + max_queue_bytes: 67108864 # 64 MiB of records waiting for a stalled disk + +protocol: + on_decision_failure: deny + decision_timeout: 5s + skew_window: 5m + +limits: + max_sessions: 1024 + max_entries: 10000 + max_reservations: 20000 + max_skill_approvals: 4096 + max_engine_calls: 32 + max_policy_state_bytes: 65536 + max_body_bytes: 1048576 + max_deferrals: 3 + session_retention: 1h diff --git a/reference-implementations/agt-go/guardian/audit_log.go b/reference-implementations/agt-go/guardian/audit_log.go new file mode 100644 index 00000000..65772346 --- /dev/null +++ b/reference-implementations/agt-go/guardian/audit_log.go @@ -0,0 +1,89 @@ +package guardian + +import ( + "context" + "encoding/json" + "time" +) + +// AuditLog receives every envelope the Guardian reads or sends and every +// audit event the standard requires of it (§6.5, §8.4, §8.5, §9.2), which +// the standard leaves to "the deployment's own audit log". +// +// Its methods return nothing and must not block or panic: logging must never +// be able to turn a governed step into an ungoverned one. An implementation +// that cannot write reports its own failure. Calls may be concurrent. An +// implementation that retains a record must copy it before returning. +type AuditLog interface { + Envelope(ctx context.Context, r EnvelopeRecord) + Event(ctx context.Context, e AuditEvent) +} + +// Direction says which way an envelope crossed the wire. +type Direction string + +// The two directions of an envelope. +const ( + Inbound Direction = "inbound" + Outbound Direction = "outbound" +) + +// EnvelopeRecord is one envelope as it crossed the wire. +type EnvelopeRecord struct { + Direction Direction + // Method is the request's method, empty when the request never parsed + // far enough to name one. + Method string + // Body is the envelope. An inbound body that is not JSON is never + // recorded; only the response to it is. + Body json.RawMessage + RecordedAt time.Time +} + +// AuditEventKind names an audit event the standard requires. +type AuditEventKind string + +// The audit events the Guardian records. +const ( + // EventModifyUnsupported: a Modify was substituted for a client that + // cannot apply it (§6.5). + EventModifyUnsupported AuditEventKind = "modify_unsupported" + // EventAskSubstituted: an Ask was substituted for a client that cannot + // resolve it (§9.2). + EventAskSubstituted AuditEventKind = "ask_substituted" + // EventIntentMutationRejected: a step tried to change an established + // Intent other than through an approver's extension (§8.4). + EventIntentMutationRejected AuditEventKind = "intent_mutation_rejected" + // EventChainMismatch: the Observed Agent's chain_hash disagreed with the + // Guardian's head (§8.6). + EventChainMismatch AuditEventKind = "chain_mismatch" + // EventDispositionNotPermitted: a decision the hook does not permit + // (hooks.md) was replaced, DENY where the hook permits it and ALLOW + // where it permits only ALLOW. The standard names no event for this; + // the Guardian records it so no decision is changed silently. + EventDispositionNotPermitted AuditEventKind = "disposition_not_permitted" + // EventLineageMismatch: a postCompact summary's derived_from is not the + // union of the provenance_ids of the entries it compacted, or its origin + // is not agent_generated (post-compact.json). The hook permits no DENY, + // so the step is answered ALLOW and the mismatch is recorded. + EventLineageMismatch AuditEventKind = "lineage_mismatch" + // EventRequestIDRefUnresolved: a toolCallResult's request_id_ref names no + // toolCallRequest among the session's entries this Guardian holds. hooks.md + // requires the record; whether to deny is the policy's. + EventRequestIDRefUnresolved AuditEventKind = "request_id_ref_unresolved" + // EventFailure: a store, signer or engine failed, or the Guardian did. + // The Message carries the detail the wire does not. + EventFailure AuditEventKind = "failure" +) + +// AuditEvent is one audit event, tied to the step that caused it. +type AuditEvent struct { + Kind AuditEventKind + SessionID string + RequestID string + Method string + // Message names what happened in words, for example the modification + // that could not be delivered. + Message string + RecordedAt time.Time +} diff --git a/reference-implementations/agt-go/guardian/config.go b/reference-implementations/agt-go/guardian/config.go new file mode 100644 index 00000000..78bace9b --- /dev/null +++ b/reference-implementations/agt-go/guardian/config.go @@ -0,0 +1,229 @@ +package guardian + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// Defaults a zero Config field takes. +const ( + // DefaultMaxSessions bounds the default store, as the TypeScript + // reference bounds its own. + DefaultMaxSessions = 1024 + // DefaultDecisionTimeout is the decision timeout the ServerHello + // declares, the TypeScript reference's value. + DefaultDecisionTimeout = 5 * time.Second + // DefaultMaxDeferrals bounds the Defer decisions one session receives + // (§6). + DefaultMaxDeferrals = 3 + // DefaultMaxBodyBytes caps a request body read over HTTP: sixteen times + // a tool call carrying a 64 KiB file, the TypeScript reference's cap. + DefaultMaxBodyBytes = 1 << 20 + // DefaultMaxEntries bounds the ContextEntries of one session in the + // default store. + DefaultMaxEntries = 10_000 + // DefaultMaxReservations bounds the requests remembered for replay + // detection in one session. + DefaultMaxReservations = 20_000 + // DefaultMaxSkillApprovals bounds the skill approvals the default store + // holds. + DefaultMaxSkillApprovals = 4096 + // DefaultSessionRetention is how long the default store keeps a session + // after its last write when it needs the place. + DefaultSessionRetention = time.Hour + // DefaultMaxEngineCalls bounds the policy engine calls running at once, + // an abandoned call that has not returned included. + DefaultMaxEngineCalls = 256 + // DefaultMaxPolicyStateBytes bounds the state an engine keeps per + // session. + DefaultMaxPolicyStateBytes = 64 << 10 +) + +// AskSubstitution is the decision a Guardian returns instead of ASK for an +// endpoint whose Observed Agent cannot route approvals (§9.2). +type AskSubstitution string + +const ( + // AskSubstitutionNone keeps ASK unchanged for an approver-capable endpoint. + AskSubstitutionNone AskSubstitution = "none" + // AskSubstitutionDeny returns DENY when an ASK cannot be routed. + AskSubstitutionDeny AskSubstitution = "deny" + // AskSubstitutionDefer returns DEFER when an ASK cannot be routed. + AskSubstitutionDefer AskSubstitution = "defer" +) + +// Config is what a deployment chooses. Engine and Signer are required: a +// decision needs an engine and a signature needs a key. +type Config struct { + Engine PolicyEngine + Signer Signer + // Store keeps session state; nil selects a MemorySessionContextStore + // bounded by MaxSessions. + Store SessionContextStore + // AuditLog receives envelopes and audit events; nil records nothing. + AuditLog AuditLog + + // MaxSessions, MaxEntries, MaxReservations, MaxSkillApprovals and SessionRetention + // bound the default store (MemoryStoreLimits); zero means the Default + // of the same name. They are ignored when Store is set. + MaxSessions int + MaxEntries int + MaxReservations int + MaxSkillApprovals int + SessionRetention time.Duration + // DecisionTimeout bounds each engine call and is declared in the + // ServerHello as timeout_config.default_ms; zero means + // DefaultDecisionTimeout. + DecisionTimeout time.Duration + // SkewWindow is the §10.3 timestamp window, declared as skew_window_ms; + // zero means DefaultSkewWindow. + SkewWindow time.Duration + // OnDecisionFailure is the posture the ServerHello declares (§6.4); + // empty means proceed, the specification's default. + OnDecisionFailure acs.FailurePosture + // AskSubstitution is the deployment-defined §9.2 choice for this + // endpoint. Empty means AskSubstitutionNone. + AskSubstitution AskSubstitution + // MaxDeferrals bounds the Defer decisions of one session; a Defer past + // it is answered DENY. Zero means DefaultMaxDeferrals. + MaxDeferrals int + // Transport is the transport the Guardian is served on, selected in + // every ServerHello; empty means http. + Transport string + // MaxBodyBytes caps a request body read by ServeHTTP; zero means + // DefaultMaxBodyBytes. + MaxBodyBytes int64 + // MaxEngineCalls bounds the engine calls running at once. A call the + // Guardian stopped waiting for holds its place until the engine returns, + // so an engine that ignores cancellation cannot grow without bound; a + // step that finds no place is answered DENY. Zero means + // DefaultMaxEngineCalls. + MaxEngineCalls int + // MaxPolicyStateBytes bounds PolicyDecision.State; a larger state is + // not kept, and the step is answered DENY. Zero means + // DefaultMaxPolicyStateBytes. + MaxPolicyStateBytes int + // Now is the Guardian's clock; nil means time.Now. + Now func() time.Time +} + +func (c Config) withDefaults() (Config, error) { + if c.Engine == nil { + return c, errors.New("guardian: Config.Engine is required") + } + if c.Signer == nil { + return c, errors.New("guardian: Config.Signer is required") + } + switch { + case c.MaxSessions < 0: + return c, fmt.Errorf("guardian: MaxSessions is %d, it cannot be negative", c.MaxSessions) + case c.MaxEntries < 0: + return c, fmt.Errorf("guardian: MaxEntries is %d, it cannot be negative", c.MaxEntries) + case c.MaxReservations < 0: + return c, fmt.Errorf("guardian: MaxReservations is %d, it cannot be negative", c.MaxReservations) + case c.MaxSkillApprovals < 0: + return c, fmt.Errorf("guardian: MaxSkillApprovals is %d, it cannot be negative", c.MaxSkillApprovals) + case c.SessionRetention < 0: + return c, fmt.Errorf("guardian: SessionRetention is %s, it cannot be negative", c.SessionRetention) + case c.DecisionTimeout < 0: + return c, fmt.Errorf("guardian: DecisionTimeout is %s, it cannot be negative", c.DecisionTimeout) + case c.SkewWindow < 0: + return c, fmt.Errorf("guardian: SkewWindow is %s, it cannot be negative", c.SkewWindow) + case c.MaxDeferrals < 0: + return c, fmt.Errorf("guardian: MaxDeferrals is %d, it cannot be negative", c.MaxDeferrals) + case c.MaxBodyBytes < 0: + return c, fmt.Errorf("guardian: MaxBodyBytes is %d, it cannot be negative", c.MaxBodyBytes) + case c.MaxEngineCalls < 0: + return c, fmt.Errorf("guardian: MaxEngineCalls is %d, it cannot be negative", c.MaxEngineCalls) + case c.MaxPolicyStateBytes < 0: + return c, fmt.Errorf("guardian: MaxPolicyStateBytes is %d, it cannot be negative", c.MaxPolicyStateBytes) + } + switch c.OnDecisionFailure { + case "": + c.OnDecisionFailure = acs.FailureProceed + case acs.FailureProceed, acs.FailureDeny: + default: + return c, fmt.Errorf("guardian: OnDecisionFailure is %q, it must be proceed or deny", c.OnDecisionFailure) + } + switch c.AskSubstitution { + case "": + c.AskSubstitution = AskSubstitutionNone + case AskSubstitutionNone, AskSubstitutionDeny, AskSubstitutionDefer: + default: + return c, fmt.Errorf("guardian: AskSubstitution is %q, it must be none, deny or defer", c.AskSubstitution) + } + switch c.Transport { + case "": + c.Transport = acs.TransportHTTP + case acs.TransportHTTP, acs.TransportHTTPS, acs.TransportStdio: + default: + return c, fmt.Errorf("guardian: Transport is %q, it must be http, https or stdio", c.Transport) + } + if c.MaxSessions == 0 { + c.MaxSessions = DefaultMaxSessions + } + if c.MaxEntries == 0 { + c.MaxEntries = DefaultMaxEntries + } + if c.MaxReservations == 0 { + c.MaxReservations = DefaultMaxReservations + } + if c.MaxSkillApprovals == 0 { + c.MaxSkillApprovals = DefaultMaxSkillApprovals + } + if c.SessionRetention == 0 { + c.SessionRetention = DefaultSessionRetention + } + if c.DecisionTimeout == 0 { + c.DecisionTimeout = DefaultDecisionTimeout + } + if c.SkewWindow == 0 { + c.SkewWindow = DefaultSkewWindow + } + if c.MaxDeferrals == 0 { + c.MaxDeferrals = DefaultMaxDeferrals + } + if c.MaxBodyBytes == 0 { + c.MaxBodyBytes = DefaultMaxBodyBytes + } + if c.MaxEngineCalls == 0 { + c.MaxEngineCalls = DefaultMaxEngineCalls + } + if c.MaxPolicyStateBytes == 0 { + c.MaxPolicyStateBytes = DefaultMaxPolicyStateBytes + } + if c.Now == nil { + c.Now = time.Now + } + if c.AuditLog == nil { + c.AuditLog = noAuditLog{} + } + if c.Store == nil { + minimumRetention := c.SkewWindow + c.SkewWindow + if minimumRetention < c.SkewWindow || c.SessionRetention < minimumRetention { + return c, fmt.Errorf("guardian: SessionRetention %s must be at least twice SkewWindow %s, so every accepted timestamp expires before its replay history is removed", c.SessionRetention, c.SkewWindow) + } + store, err := NewMemorySessionContextStore(MemoryStoreLimits{ + MaxSessions: c.MaxSessions, + MaxEntries: c.MaxEntries, + MaxReservations: c.MaxReservations, + MaxSkillApprovals: c.MaxSkillApprovals, + Retention: c.SessionRetention, + Now: c.Now, + }) + if err != nil { + return c, fmt.Errorf("guardian: %w", err) + } + c.Store = store + } + return c, nil +} + +type noAuditLog struct{} + +func (noAuditLog) Envelope(context.Context, EnvelopeRecord) {} +func (noAuditLog) Event(context.Context, AuditEvent) {} diff --git a/reference-implementations/agt-go/guardian/doc.go b/reference-implementations/agt-go/guardian/doc.go new file mode 100644 index 00000000..7fcc634e --- /dev/null +++ b/reference-implementations/agt-go/guardian/doc.go @@ -0,0 +1,12 @@ +// Package guardian implements an ACS Guardian. It validates and authenticates +// requests, maintains each session's SessionContext, obtains policy decisions +// and signs every response to an authenticated request. An unsigned +// system/ping response and errors produced before authentication remain +// unsigned. +// +// Everything the standard defines is fixed inside the package. What the +// standard leaves to the deployment is behind four interfaces: +// PolicyEngine decides (§12.1), Signer holds the keys (§10), +// SessionContextStore keeps session state (§8) and AuditLog receives the +// envelopes and the audit events the standard requires. +package guardian diff --git a/reference-implementations/agt-go/guardian/engine_slot_internal_test.go b/reference-implementations/agt-go/guardian/engine_slot_internal_test.go new file mode 100644 index 00000000..109b9a61 --- /dev/null +++ b/reference-implementations/agt-go/guardian/engine_slot_internal_test.go @@ -0,0 +1,44 @@ +package guardian + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +type slotEngine struct{ panics bool } + +func (slotEngine) Policy() PolicyDescription { return PolicyDescription{} } + +func (e slotEngine) Decide(context.Context, PolicyInput) (PolicyDecision, error) { + if e.panics { + panic("engine defect") + } + return PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}}, nil +} + +// TestEngineSlotIsFreeWhenTheOutcomeArrives: callEngine gives its engine +// slot back before it delivers the outcome, on the normal path and when the +// engine panics. A slot given back later is still taken when the answer +// lets the client send its next step, which is refused engine_saturated +// with no call running (docs/load.md measured 3 to 14 such refusals among +// 640 steps at 32 clients on 32 slots). +func TestEngineSlotIsFreeWhenTheOutcomeArrives(t *testing.T) { + for name, engine := range map[string]slotEngine{"decides": {}, "panics": {panics: true}} { + t.Run(name, func(t *testing.T) { + g := &Guardian{cfg: Config{Engine: engine}, engineCalls: make(chan struct{}, 1)} + for i := range 50000 { + _, err := g.callEngine(context.Background(), time.Second, PolicyInput{}) + if errors.Is(err, errEngineSaturated) { + t.Fatalf("call %d was refused with no call running", i) + } + if taken := len(g.engineCalls); taken != 0 { + t.Fatalf("call %d delivered its outcome with %d slot still taken", i, taken) + } + } + }) + } +} diff --git a/reference-implementations/agt-go/guardian/failure_test.go b/reference-implementations/agt-go/guardian/failure_test.go new file mode 100644 index 00000000..b4edaad2 --- /dev/null +++ b/reference-implementations/agt-go/guardian/failure_test.go @@ -0,0 +1,626 @@ +package guardian_test + +import ( + "context" + "encoding/json" + "errors" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" +) + +// failingStore wraps the memory store and fails the operation named in +// failing with a message a deployment would not want on the wire. +type failingStore struct { + *guardian.MemorySessionContextStore + failing string + appendCalls int + failAppendAt int +} + +type archivedStore struct { + guardian.SessionContextStore + archive bool +} + +func (s *archivedStore) Load(ctx context.Context, sessionID string) (guardian.Session, bool, error) { + session, ok, err := s.SessionContextStore.Load(ctx, sessionID) + if s.archive { + session.Entries = nil + } + return session, ok, err +} + +type panickingStore struct { + guardian.SessionContextStore + panicOnLoad bool + panicOnConclude bool +} + +type panickingAuditLog struct{} + +func (panickingAuditLog) Envelope(context.Context, guardian.EnvelopeRecord) { panic("envelope") } +func (panickingAuditLog) Event(context.Context, guardian.AuditEvent) { panic("event") } + +func (s *panickingStore) Load(ctx context.Context, sessionID string) (guardian.Session, bool, error) { + if s.panicOnLoad { + panic("load failed") + } + return s.SessionContextStore.Load(ctx, sessionID) +} + +func (s *panickingStore) Conclude(ctx context.Context, sessionID string, conclusion guardian.Conclusion) error { + if s.panicOnConclude { + panic("conclude failed") + } + return s.SessionContextStore.Conclude(ctx, sessionID, conclusion) +} + +var errSecretStore = errors.New("pq: connection to 10.0.0.7:5432 refused for user acs_admin") + +func (s *failingStore) fail(op string) error { + if s.failing == op { + return errSecretStore + } + return nil +} + +func (s *failingStore) Lock(ctx context.Context, id string) (context.Context, func(), error) { + if err := s.fail("lock"); err != nil { + return nil, nil, err + } + return s.MemorySessionContextStore.Lock(ctx, id) +} + +func (s *failingStore) Load(ctx context.Context, id string) (guardian.Session, bool, error) { + if err := s.fail("load"); err != nil { + return guardian.Session{}, false, err + } + return s.MemorySessionContextStore.Load(ctx, id) +} + +func (s *failingStore) Append(ctx context.Context, id string, a guardian.Append) error { + s.appendCalls++ + if s.failAppendAt == s.appendCalls { + return errSecretStore + } + if err := s.fail("append"); err != nil { + return err + } + return s.MemorySessionContextStore.Append(ctx, id, a) +} + +func TestIntentExtensionStoreFailurePublishesLastCommittedHead(t *testing.T) { + memory, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{MaxSessions: 8, MaxEntries: 64, MaxReservations: 128, MaxSkillApprovals: 8, Retention: time.Hour}) + if err != nil { + t.Fatal(err) + } + store := &failingStore{MemorySessionContextStore: memory} + h := newHarness(t, func(c *guardian.Config) { c.Store = store }) + c := h.session() + approver := acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"} + h.engine.answer(acs.Decision{Disposition: acs.Ask, Reasoning: "approval required", AskDetails: &acs.AskDetails{Approver: approver, Question: "allow curl?", TimeoutSeconds: 60}}) + asked := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")) + + source := "reviewer" + grant := guardian.IntentGrant{ + Approver: approver, + Extension: acs.IntentExtension{Capabilities: []acs.Capability{{Tool: "curl"}}, Scope: acs.ScopeSession, + Provenance: &acs.Provenance{ProvenanceID: "grant-1", Origin: "user_input", SourceID: &source}}, + AskStepID: asked.RequestID, + } + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}, Grant: &grant}, nil + }) + store.failAppendAt = store.appendCalls + 2 + r := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")) + if r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonStoreUnavailable) || r.ChainHash == "" { + t.Fatalf("an intent-extension append failure got %+v", r) + } + sess, _, err := store.Load(context.Background(), c.SessionID) + if err != nil { + t.Fatal(err) + } + last := sess.Entries[len(sess.Entries)-1] + if r.ChainHash != last.EntryHash { + t.Fatalf("published head %q, want committed entry hash %q", r.ChainHash, last.EntryHash) + } +} + +func TestAuthenticatedStepProviderPanicIsSignedDeny(t *testing.T) { + var store *panickingStore + h := newHarness(t, func(c *guardian.Config) { + store = &panickingStore{SessionContextStore: c.Store} + c.Store = store + }) + c := h.session() + store.panicOnLoad = true + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Result == nil || o.Result.Disposition != acs.Deny || !o.Verified { + t.Fatalf("provider panic got %s", o.Raw) + } + if !slices.Contains(o.Result.ReasonCodes, disposition.ReasonEvaluationFailed) { + t.Fatalf("provider panic reason codes %v", o.Result.ReasonCodes) + } +} + +func TestProviderPanicAfterAppendPublishesCommittedHead(t *testing.T) { + var store *panickingStore + h := newHarness(t, func(c *guardian.Config) { + store = &panickingStore{SessionContextStore: c.Store} + c.Store = store + }) + c := h.session() + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{ + Decision: acs.Decision{Disposition: acs.Allow}, + State: json.RawMessage(`{"reviewed":true}`), + }, nil + }) + store.panicOnConclude = true + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Result == nil || o.Result.Disposition != acs.Deny || !o.Verified || o.Result.ChainHash == "" { + t.Fatalf("provider panic after append got %s", o.Raw) + } + if !slices.Contains(o.Result.ReasonCodes, disposition.ReasonEvaluationFailed) { + t.Fatalf("provider panic reason codes %v", o.Result.ReasonCodes) + } + session, ok, err := store.Load(context.Background(), c.SessionID) + if err != nil || !ok || len(session.Entries) == 0 { + t.Fatalf("load committed session: ok=%v err=%v", ok, err) + } + if want := session.Entries[len(session.Entries)-1].EntryHash; o.Result.ChainHash != want { + t.Fatalf("published head %q, want committed entry hash %q", o.Result.ChainHash, want) + } +} + +func TestProviderPanicAfterGrantPublishesCommittedHead(t *testing.T) { + var store *panickingStore + h := newHarness(t, func(c *guardian.Config) { + store = &panickingStore{SessionContextStore: c.Store} + c.Store = store + }) + c := h.session() + approver := acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"} + h.engine.answer(acs.Decision{Disposition: acs.Ask, Reasoning: "approval required", AskDetails: &acs.AskDetails{ + Approver: approver, Question: "allow curl?", TimeoutSeconds: 60, + }}) + asked := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")) + source := "reviewer" + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{ + Decision: acs.Decision{Disposition: acs.Allow}, + State: json.RawMessage(`{"reviewed":true}`), + Grant: &guardian.IntentGrant{ + Approver: approver, + Extension: acs.IntentExtension{Capabilities: []acs.Capability{{Tool: "curl"}}, Scope: acs.ScopeSession, + Provenance: &acs.Provenance{ProvenanceID: "grant-1", Origin: "user_input", SourceID: &source}}, + AskStepID: asked.RequestID, + }, + }, nil + }) + store.panicOnConclude = true + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("curl x")}) + if o.Result == nil || o.Result.Disposition != acs.Deny || !o.Verified { + t.Fatalf("provider panic after grant got %s", o.Raw) + } + session, ok, err := store.Load(context.Background(), c.SessionID) + if err != nil || !ok || len(session.Entries) == 0 { + t.Fatalf("load committed session: ok=%v err=%v", ok, err) + } + if want := session.Entries[len(session.Entries)-1].EntryHash; o.Result.ChainHash != want { + t.Fatalf("published head %q, want committed intent-extension hash %q", o.Result.ChainHash, want) + } +} + +func TestAuditLogPanicDoesNotChangeDecision(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.AuditLog = panickingAuditLog{} }) + r := h.decision(h.session(), acs.StepToolCallRequest, toolCall("ls")) + if r.Disposition != acs.Allow { + t.Fatalf("audit panic changed the decision: %+v", r) + } +} + +func (s *failingStore) Conclude(ctx context.Context, id string, c guardian.Conclusion) error { + if err := s.fail("conclude"); err != nil { + return err + } + return s.MemorySessionContextStore.Conclude(ctx, id, c) +} + +// An authenticated step whose store fails is answered with a signed DENY, +// never an error the Observed Agent's default posture would proceed on, and +// the store's own message stays in the audit log. +func TestStoreFailureIsSignedDenial(t *testing.T) { + for _, op := range []string{"lock", "load", "append", "conclude"} { + t.Run(op, func(t *testing.T) { + memory, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{MaxSessions: 8, MaxEntries: 64, MaxReservations: 128, MaxSkillApprovals: 8, Retention: time.Hour}) + if err != nil { + t.Fatal(err) + } + store := &failingStore{MemorySessionContextStore: memory} + h := newHarness(t, func(c *guardian.Config) { c.Store = store }) + c := h.session() + h.engine.answer(acs.Decision{Disposition: acs.Defer, Reasoning: "wait", DeferDetails: &acs.DeferDetails{Reason: acs.DeferPendingDependency, ResolutionMethod: acs.ResolveHumanApproval, ResolutionTimeoutMS: 1}}) + store.failing = op + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Result == nil || !o.Verified || o.Result.Disposition != acs.Deny || !slices.Contains(o.Result.ReasonCodes, disposition.ReasonStoreUnavailable) { + t.Fatalf("a store failure at %s got %s", op, o.Raw) + } + if op == "conclude" && o.Result.ChainHash == "" { + t.Fatalf("a conclude failure after the ContextEntry was appended omitted its published chain head: %s", o.Raw) + } + if strings.Contains(string(o.Raw), "10.0.0.7") { + t.Fatalf("the store's error reached the wire: %s", o.Raw) + } + if !slices.ContainsFunc(h.audit.events, func(e guardian.AuditEvent) bool { + return e.Kind == guardian.EventFailure && strings.Contains(e.Message, "10.0.0.7") + }) { + t.Fatal("the store's error is not in the audit log") + } + }) + } +} + +// A signer that returns a signature response-envelope.json refuses is not +// sent: the Guardian checks the bytes it is about to send. +func TestMalformedSignatureIsNotSent(t *testing.T) { + h := newHarness(t) + c := h.session() + h.g = mustGuardian(t, guardian.Config{Engine: h.engine, Signer: badSigner{h.signer}, Store: h.store, AuditLog: h.audit}) + c.Transport = func(ctx context.Context, body []byte) ([]byte, error) { return h.g.Handle(ctx, body), nil } + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Result != nil || o.Error == nil || o.Error.Code != acs.InternalError { + t.Fatalf("a malformed signature got %s", o.Raw) + } +} + +type badSigner struct{ *guardian.HMACSigner } + +func (s badSigner) Sign(ctx context.Context, sessionID, keyID string, input []byte) (acs.Signature, error) { + sig, err := s.HMACSigner.Sign(ctx, sessionID, keyID, input) + sig.Algorithm = "" + return sig, err +} + +type panickingSignSigner struct { + *guardian.HMACSigner +} + +func (panickingSignSigner) Sign(context.Context, string, string, []byte) (acs.Signature, error) { + panic("sign") +} + +type blockingSignSigner struct { + *guardian.HMACSigner + block atomic.Bool + started chan struct{} +} + +func (s *blockingSignSigner) Sign(ctx context.Context, sessionID, keyID string, input []byte) (acs.Signature, error) { + if !s.block.Load() { + return s.HMACSigner.Sign(ctx, sessionID, keyID, input) + } + close(s.started) + <-ctx.Done() + return acs.Signature{}, ctx.Err() +} + +func TestSignerPanicReturnsUnsignedInternalError(t *testing.T) { + h := newHarness(t) + c := h.session() + h.g = mustGuardian(t, guardian.Config{ + Engine: h.engine, Signer: panickingSignSigner{HMACSigner: h.signer}, Store: h.store, AuditLog: h.audit, + }) + c.Transport = func(ctx context.Context, body []byte) ([]byte, error) { return h.g.Handle(ctx, body), nil } + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Result != nil || o.Error == nil || o.Error.Code != acs.InternalError || o.Verified { + t.Fatalf("a signer panic got %s", o.Raw) + } +} + +func TestShutdownCancelsResponseSigning(t *testing.T) { + h := newHarness(t) + c := h.session() + signer := &blockingSignSigner{HMACSigner: h.signer, started: make(chan struct{})} + h.g = mustGuardian(t, guardian.Config{ + Engine: h.engine, Signer: signer, Store: h.store, AuditLog: h.audit, + }) + c.Transport = func(ctx context.Context, body []byte) ([]byte, error) { return h.g.Handle(ctx, body), nil } + signer.block.Store(true) + + answered := make(chan error, 1) + go func() { + _, err := c.Send(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + answered <- err + }() + <-signer.started + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := h.g.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Shutdown: %v", err) + } + select { + case <-answered: + case <-time.After(time.Second): + t.Fatal("response signing remained blocked after shutdown cancellation") + } +} + +func mustGuardian(t *testing.T, cfg guardian.Config) *guardian.Guardian { + t.Helper() + g, err := guardian.New(cfg) + if err != nil { + t.Fatal(err) + } + return g +} + +// An engine that ignores cancellation cannot hold a step past Shutdown's +// grace: the step is answered DENY, and Shutdown returns at the grace. +func TestShutdownBoundsAnEngineThatIgnoresCancellation(t *testing.T) { + h := newHarness(t) + c := h.session() + started, never := make(chan struct{}), make(chan struct{}) + t.Cleanup(func() { close(never) }) + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + close(started) + <-never + return guardian.PolicyDecision{}, nil + }) + answered := make(chan *acs.Result, 1) + go func() { answered <- h.decision(c, acs.StepToolCallRequest, toolCall("ls")) }() + <-started + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + begun := time.Now() + if err := h.g.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Shutdown: %v", err) + } + if waited := time.Since(begun); waited > time.Second { + t.Fatalf("Shutdown returned after %s", waited) + } + select { + case r := <-answered: + if r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonShuttingDown) { + t.Fatalf("the in-flight step got %+v", r) + } + case <-time.After(time.Second): + t.Fatal("the in-flight step was not answered") + } +} + +// A turn_id supplied by the framework reaches every corresponding entry. +func TestTurnIDReachesTheChain(t *testing.T) { + h := newHarness(t) + c := h.session() + h.send(c, observedagent.Request{Method: acs.StepTurnStart, Payload: hookPayloads[acs.StepTurnStart], TurnID: "t1"}) + h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), TurnID: "t1"}) + h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + sess, _, _ := h.store.Load(context.Background(), c.SessionID) + var turns []string + for _, e := range sess.Entries { + turns = append(turns, e.TurnID) + } + if !slices.Equal(turns, []string{"t1", "t1", ""}) { + t.Fatalf("entries carry turns %v", turns) + } +} + +// A postCompact summary's lineage is held to the compacted entries. The +// hook permits no DENY, so the mismatch is recorded on an ALLOW. +func TestPostCompactLineage(t *testing.T) { + h := newHarness(t) + c := h.session() + first := h.decision(c, acs.StepToolCallRequest, withProvenance(toolCall("cat a"), "p1")) + second := h.decision(c, acs.StepToolCallRequest, withProvenance(toolCall("cat b"), "p2")) + head := second.ChainHash + compact := func(before, origin string, derived ...string) map[string]any { + return map[string]any{ + "summary": map[string]any{"value": "sum", "provenance": map[string]any{"provenance_id": "s", "origin": origin, "derived_from": derived}}, + "entries_compacted": []any{first.RequestID, second.RequestID}, + "pre_compact_chain_hash": before, + "post_compact_chain_hash": hash64, + } + } + r := h.decision(c, acs.StepPostCompact, compact(head, "agent_generated", "p2", "p1")) + if r.Disposition != acs.Allow || slices.Contains(r.ReasonCodes, disposition.ReasonLineageMismatch) { + t.Fatalf("a faithful summary got %+v", r) + } + head = r.ChainHash + for name, payload := range map[string]func() map[string]any{ + "missing_lineage": func() map[string]any { return compact(head, "agent_generated", "p1") }, + "forged_origin": func() map[string]any { return compact(head, "user_input", "p1", "p2") }, + "stale_pre_hash": func() map[string]any { return compact(first.ChainHash, "agent_generated", "p1", "p2") }, + } { + r := h.decision(c, acs.StepPostCompact, payload()) + if r.Disposition != acs.Allow || !slices.Contains(r.ReasonCodes, disposition.ReasonLineageMismatch) { + t.Fatalf("%s got %+v", name, r) + } + head = r.ChainHash + } + if count(h.audit.eventKinds(), guardian.EventLineageMismatch) != 3 { + t.Fatalf("audit events %v", h.audit.eventKinds()) + } +} + +func TestPostCompactLineageSurvivesEntryArchival(t *testing.T) { + var store *archivedStore + h := newHarness(t, func(c *guardian.Config) { + store = &archivedStore{SessionContextStore: c.Store} + c.Store = store + }) + c := h.session() + first := h.decision(c, acs.StepToolCallRequest, withProvenance(toolCall("cat a"), "p1")) + second := h.decision(c, acs.StepToolCallRequest, withProvenance(toolCall("cat b"), "p2")) + store.archive = true + r := h.decision(c, acs.StepPostCompact, map[string]any{ + "summary": map[string]any{"value": "sum", "provenance": map[string]any{"provenance_id": "s", "origin": "agent_generated", "derived_from": []any{"p1", "p2"}}}, + "entries_compacted": []any{first.RequestID, second.RequestID}, + "pre_compact_chain_hash": second.ChainHash, + "post_compact_chain_hash": hash64, + }) + if r.Disposition != acs.Allow || slices.Contains(r.ReasonCodes, disposition.ReasonLineageMismatch) { + t.Fatalf("a faithful summary over archived entries got %+v", r) + } +} + +func withProvenance(p acs.ToolCallRequestPayload, id string) acs.ToolCallRequestPayload { + arg := p.Arguments["command"] + arg.Provenance = &acs.Provenance{ProvenanceID: id, Origin: "user_input"} + p.Arguments["command"] = arg + return p +} + +// What an engine keeps for a session comes back on the session's later +// steps, and not on another session's. +func TestPolicyStateFollowsTheSession(t *testing.T) { + h := newHarness(t) + a, b := h.session(), h.session() + h.engine.set(func(_ context.Context, in guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}, State: json.RawMessage(`{"seen":"` + in.Request.Params.RequestID + `"}`)}, nil + }) + first := h.decision(a, acs.StepToolCallRequest, toolCall("ls")) + h.engine.set(nil) + h.decision(a, acs.StepToolCallRequest, toolCall("ls")) + if got := string(h.engine.last().Session.PolicyState); got != `{"seen":"`+first.RequestID+`"}` { + t.Fatalf("the next step saw policy state %s", got) + } + h.decision(b, acs.StepToolCallRequest, toolCall("ls")) + if got := h.engine.last().Session.PolicyState; got != nil { + t.Fatalf("another session saw policy state %s", got) + } +} + +// End to end, a decision altered on the way fails verification, and the +// Observed Agent denies it even under the proceed posture (#32). +func TestTamperedDecisionIsDenied(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.OnDecisionFailure = acs.FailureProceed }) + c := h.client(observedagent.NewUUID()) + o, err := c.Handshake(context.Background(), observedagent.DefaultHello(method.Hooks()...)) + if err != nil || o.Hello == nil { + t.Fatalf("handshake: %v", err) + } + posture := o.Hello.OnDecisionFailure + h.engine.answer(deny("rule")) + sent := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + tampered, err := c.Read(context.Background(), []byte(strings.Replace(string(sent.Raw), `"decision":"deny"`, `"decision":"allow"`, 1))) + if err != nil { + t.Fatal(err) + } + if d, verified := observedagent.Honour(tampered, nil, posture); d.Disposition != acs.Deny || verified { + t.Fatalf("a tampered decision was applied as %s", d.Disposition) + } + if d, _ := observedagent.Honour(sent, nil, posture); d.Disposition != acs.Deny || d.Reasoning != "denied: rule" { + t.Fatalf("the genuine decision was applied as %+v", d) + } +} + +// TestEngineCallsAreBounded: a call that outlives its decision timeout is +// answered DENY and keeps its engine slot until it returns, so an engine +// that cannot be interrupted, such as a call into a native library, holds +// it; with every slot held a step is DENY engine_saturated, and once the +// call returns the next step is decided. +func TestEngineCallsAreBounded(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.MaxEngineCalls = 1; c.DecisionTimeout = 20 * time.Millisecond }) + c := h.session() + release := make(chan struct{}) + t.Cleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + <-release + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}}, nil + }) + if r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonEvaluationFailed) { + t.Fatalf("the first step got %+v", r) + } + calls := h.engine.callCount() + if r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonEngineSaturated) { + t.Fatalf("a step with no place got %+v", r) + } + if h.engine.callCount() != calls { + t.Fatal("the engine was called with no place free") + } + close(release) + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) { + r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + if r.Disposition == acs.Allow { + break + } + if !slices.Contains(r.ReasonCodes, disposition.ReasonEngineSaturated) || time.Now().After(deadline) { + t.Fatalf("a step after the abandoned call returned got %+v", r) + } + } +} + +// An engine state past the bound is not kept, and the step is denied. +func TestPolicyStateIsBounded(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.MaxPolicyStateBytes = 16 }) + c := h.session() + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}, State: json.RawMessage(`{"padding":"0123456789"}`)}, nil + }) + if r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonPolicyStateTooLarge) { + t.Fatalf("an oversize state got %+v", r) + } + if sess, _, _ := h.store.Load(context.Background(), c.SessionID); sess.PolicyState != nil { + t.Fatalf("the oversize state was kept: %s", sess.PolicyState) + } +} + +func TestPolicyStateMustBeJSON(t *testing.T) { + h := newHarness(t) + c := h.session() + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}, State: json.RawMessage(`not-json`)}, nil + }) + if r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonEvaluationFailed) { + t.Fatalf("invalid state got %+v", r) + } + if sess, _, _ := h.store.Load(context.Background(), c.SessionID); sess.PolicyState != nil { + t.Fatalf("invalid state was kept: %s", sess.PolicyState) + } +} + +func TestInvalidPolicyDecisionLeavesNoDecisionState(t *testing.T) { + h := newHarness(t) + c := h.session() + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{ + Decision: acs.Decision{ + Disposition: acs.Ask, + Reasoning: "approval required", + AskDetails: &acs.AskDetails{ + Approver: acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"}, + Question: "allow?", + TimeoutSeconds: 0, + }, + }, + State: json.RawMessage(`{"must_not_persist":true}`), + }, nil + }) + result := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + if result.Disposition != acs.Deny || !slices.Contains(result.ReasonCodes, disposition.ReasonEvaluationFailed) { + t.Fatalf("invalid policy decision got %+v", result) + } + sess, _, err := h.store.Load(context.Background(), c.SessionID) + if err != nil { + t.Fatal(err) + } + if len(sess.AskSteps) != 0 || sess.Deferrals != 0 || sess.PolicyState != nil { + t.Fatalf("invalid policy decision changed decision state: asks=%v deferrals=%d state=%s", sess.AskSteps, sess.Deferrals, sess.PolicyState) + } +} diff --git a/reference-implementations/agt-go/guardian/guardian.go b/reference-implementations/agt-go/guardian/guardian.go new file mode 100644 index 00000000..fc938bd5 --- /dev/null +++ b/reference-implementations/agt-go/guardian/guardian.go @@ -0,0 +1,486 @@ +package guardian + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sync" + "time" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/envelope" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/mcp" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/schema" +) + +// Guardian answers ACS requests. It is safe for concurrent use: steps of +// different sessions run in parallel, and the steps of one session run one +// at a time, from reservation to answer, under the store's session lock. +type Guardian struct { + cfg Config + schemas *schema.Registry + methods *method.Table + policy PolicyDescription + algorithms []string + // engineCalls holds one token per engine call running, an abandoned + // one included, up to Config.MaxEngineCalls. + engineCalls chan struct{} + + // drain state: Shutdown stops new requests and, when its grace ends, + // cancels the requests still running so each is answered. + mu sync.Mutex + draining bool + inflight sync.WaitGroup + stop context.Context + cancel context.CancelFunc +} + +// New returns a Guardian ready to answer: the schemas are compiled and the +// configuration is checked. It governs the native hooks and wrapped MCP. +func New(cfg Config) (*Guardian, error) { + return newGuardian(cfg, method.NewTable(mcp.Handler{})) +} + +// newGuardian takes the method table separately so a test can register a +// wrapped-protocol handler of its own. +func newGuardian(cfg Config, methods *method.Table) (*Guardian, error) { + cfg, err := cfg.withDefaults() + if err != nil { + return nil, err + } + schemas, err := schema.Load() + if err != nil { + return nil, fmt.Errorf("guardian: %w", err) + } + policy, err := providerSnapshot("PolicyEngine.Policy", cfg.Engine.Policy) + if err != nil { + return nil, err + } + policy.ApproverTypes = slices.Clone(policy.ApproverTypes) + algorithms, err := providerSnapshot("Signer.Algorithms", cfg.Signer.Algorithms) + if err != nil { + return nil, err + } + algorithms = slices.Clone(algorithms) + if len(algorithms) == 0 { + return nil, errors.New("guardian: Signer.Algorithms returned no algorithm, but every non-ping request must be signed") + } + hello := acs.ServerHello{ + NegotiatedVersion: acs.Version, + MethodsEvaluated: []string{}, + SelectedTransport: cfg.Transport, + SignatureAlgorithmsSupported: algorithms, + TimeoutConfig: acs.TimeoutConfig{DefaultMS: cfg.DecisionTimeout.Milliseconds()}, + SkewWindowMS: cfg.SkewWindow.Milliseconds(), + OnDecisionFailure: cfg.OnDecisionFailure, + ApproverTypesSupported: policy.ApproverTypes, + PolicyRequiresProvenance: policy.RequiresProvenance, + ProfilesAccepted: []string{}, + } + helloJSON, err := jsonv2.Marshal(hello) + if err != nil { + return nil, fmt.Errorf("guardian: describe ServerHello: %w", err) + } + if err := schemas.ValidateJSON(schema.ServerHello, helloJSON); err != nil { + return nil, fmt.Errorf("guardian: provider capabilities do not form a valid ServerHello: %w", err) + } + stop, cancel := context.WithCancel(context.Background()) + return &Guardian{ + cfg: cfg, + schemas: schemas, + methods: methods, + policy: policy, + algorithms: algorithms, + engineCalls: make(chan struct{}, cfg.MaxEngineCalls), + stop: stop, + cancel: cancel, + }, nil +} + +func providerSnapshot[T any](name string, read func() T) (value T, err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("guardian: %s panicked: %v", name, p) + } + }() + return read(), nil +} + +// Handle answers one request: body is the request as received and the +// result is the response to send. Every request gets a response. ctx is +// passed to the four interfaces, and ends early when Shutdown's grace ends. +func (g *Guardian) Handle(ctx context.Context, body []byte) []byte { + if !g.begin() { + return g.shuttingDown(ctx) + } + defer g.inflight.Done() + return g.handle(ctx, body) +} + +func (g *Guardian) handle(ctx context.Context, body []byte) []byte { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + defer context.AfterFunc(g.stop, cancel)() + r, method := g.answer(ctx, body) + return g.encode(ctx, r, method) +} + +// begin counts a request in flight, or reports false once Shutdown has +// begun. +func (g *Guardian) begin() bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.draining { + return false + } + g.inflight.Add(1) + return true +} + +func (g *Guardian) shuttingDown(ctx context.Context) []byte { + return g.encode(ctx, errorReply(nullID, acs.InternalError, "shutting_down", "the Guardian is shutting down"), "") +} + +// Shutdown stops accepting requests and waits for the ones running. When ctx +// ends first, it cancels them and returns at once: each running step is +// answered DENY as its engine call, store call or lock wait returns, and a +// call to one of the four interfaces that ignores cancellation may finish +// after Shutdown has returned. +func (g *Guardian) Shutdown(ctx context.Context) error { + g.mu.Lock() + g.draining = true + g.mu.Unlock() + done := make(chan struct{}) + go func() { + g.inflight.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + g.cancel() + return ctx.Err() + } +} + +// reply is a response before it is encoded and signed: a result or an +// error, never both. +type reply struct { + id jsontext.Value + result any // *acs.Result or *acs.ServerHello + err *acs.Error + // sessionID names the key the result is signed with, empty for no + // signature. + sessionID string + // keyID is the key_id the request was verified with. + keyID string + // signatureOptional sends the result unsigned when signing fails. + signatureOptional bool +} + +var nullID = jsontext.Value("null") + +// call is an authenticated request: what was read of it, and the key_id its +// signature verified under. +type call struct { + *envelope.Inbound + KeyID string +} + +func errorReply(id jsontext.Value, code acs.ErrorCode, reason, message string) reply { + return reply{id: id, err: &acs.Error{Code: code, Message: message, Data: &acs.ErrorData{Reason: reason, Message: message}}} +} + +// answer routes a request. The order is a security property: nothing is +// decided before the request is authenticated, and nothing is read for a +// decision before it is validated. +func (g *Guardian) answer(ctx context.Context, body []byte) (r reply, methodName string) { + responseID := nullID + authenticatedSessionID, authenticatedKeyID := "", "" + authenticatedStep := false + authenticatedRequestID, authenticatedVersion := "", "" + defer func() { + if p := recover(); p != nil { + g.failure(ctx, methodName, authenticatedSessionID, "", "guardian defect", fmt.Errorf("panic: %v", p)) + if authenticatedStep { + r = reply{id: responseID, result: &acs.Result{ + Type: acs.ResultTypeFinal, + ACSVersion: authenticatedVersion, + RequestID: authenticatedRequestID, + Decision: denial(disposition.ReasonEvaluationFailed, "the Guardian failed while deciding the step"), + }, sessionID: authenticatedSessionID, keyID: authenticatedKeyID} + } else { + r = errorReply(responseID, acs.InternalError, "guardian_defect", "the Guardian failed to answer") + r.sessionID, r.keyID = authenticatedSessionID, authenticatedKeyID + } + } + }() + + in, err := envelope.Parse(body) + switch { + case errors.Is(err, envelope.ErrBatch): + return errorReply(nullID, acs.InvalidRequest, "batch_unsupported", "batch requests are not supported; send each request on its own"), "" + case errors.Is(err, envelope.ErrNotObject): + return errorReply(nullID, acs.InvalidRequest, "not_an_object", "the request is not a JSON object"), "" + case err != nil: + return errorReply(nullID, acs.ParseError, "parse_error", "the request is not a single valid I-JSON text"), "" + } + responseID, methodName = in.ID, in.Method + g.record(ctx, Inbound, in.Method, body) + if err := in.ValidateJSONRPCRequest(); err != nil { + return errorReply(in.ID, acs.InvalidRequest, "invalid_jsonrpc_request", "the request is not a valid JSON-RPC 2.0 request: "+err.Error()), in.Method + } + + route := g.methods.Route(in.Method) + if route == method.Ping { + return g.ping(ctx, in, body), in.Method + } + + input, sig, err := envelope.RequestSigningInput(body) + switch { + case err != nil: + return errorReply(in.ID, acs.SignatureInvalid, "signature_malformed", err.Error()), in.Method + case sig == nil: + return errorReply(in.ID, acs.SignatureInvalid, "signature_missing", "every request except system/ping must be signed (§10)"), in.Method + case in.SessionID == "": + return errorReply(in.ID, acs.SignatureInvalid, "session_missing", "metadata.session_id is required to derive the signing key"), in.Method + } + if err := g.cfg.Signer.Verify(ctx, in.SessionID, input, *sig); err != nil { + if !errors.Is(err, ErrSignatureInvalid) { + g.failure(ctx, in.Method, in.SessionID, in.RequestID, "signer", err) + } + return errorReply(in.ID, acs.SignatureInvalid, "signature_invalid", "the signature does not verify"), in.Method + } + authenticatedSessionID, authenticatedKeyID = in.SessionID, sig.KeyID + // From here the request is authenticated, so an error answering it is + // signed with the key it verified under. + defer func() { + if r.err != nil && r.sessionID == "" { + r.sessionID, r.keyID = in.SessionID, sig.KeyID + } + }() + + if err := g.schemas.Validate(schema.RequestEnvelope, in.Tree); err != nil { + return errorReply(in.ID, acs.InvalidRequest, "envelope_invalid", "the request fails request-envelope.json: "+err.Error()), in.Method + } + var req acs.Request + if err := jsonv2.Unmarshal(body, &req); err != nil { + return errorReply(in.ID, acs.InvalidRequest, "envelope_invalid", "the request cannot be read: "+err.Error()), in.Method + } + c := &call{Inbound: in, KeyID: sig.KeyID} + if route == method.Hook || route == method.Wrapped { + authenticatedStep = true + authenticatedRequestID = in.RequestID + authenticatedVersion = in.ACSVersion + } + + switch route { + case method.Handshake: + return g.handshake(ctx, c, req), in.Method + case method.Hook, method.Wrapped: + return g.step(ctx, c, req), in.Method + case method.Unsupported: + return g.rejectOutsideStep(ctx, c, req, notNegotiated(in.ID, req.Method, "this Guardian does not negotiate "+req.Method)), in.Method + default: + return g.rejectOutsideStep(ctx, c, req, errorReply(in.ID, acs.MethodNotFound, "method_not_found", "ACS v0.1 defines no method "+req.Method)), in.Method + } +} + +func (g *Guardian) rejectOutsideStep(ctx context.Context, in *call, req acs.Request, rejected reply) reply { + lockCtx, release, err := g.cfg.Store.Lock(ctx, in.SessionID) + if err != nil { + return g.storeFailed(ctx, in, "store lock", err) + } + defer release() + ctx = lockCtx + sess, ok, err := g.cfg.Store.Load(ctx, in.SessionID) + switch { + case err != nil: + return g.storeFailed(ctx, in, "store load", err) + case !ok: + return notNegotiated(in.ID, req.Method, "the session has not completed handshake/hello") + case sess.Handshake.KeyID != in.KeyID: + return g.answerStep(ctx, in, denial(disposition.ReasonKeyNotBound, "the request is signed by a key other than the key that opened this session"), "") + case sess.Handshake.AgentID != req.Params.Metadata.AgentID: + return g.answerStep(ctx, in, denial(disposition.ReasonAgentIDNotBound, "the request names an agent other than the agent that opened this session"), "") + } + if outside := g.timestampOutsideWindow(in, sess.Handshake.Server.SkewWindowMS); outside != nil { + return *outside + } + return g.reserveRejected(ctx, in, req, rejected) +} + +func (g *Guardian) timestampOutsideWindow(in *call, windowMS int64) *reply { + window := time.Duration(windowMS) * time.Millisecond + if withinSkew(in.Timestamp, g.cfg.Now(), window) { + return nil + } + r := errorReply(in.ID, acs.TimestampOutOfWindow, "timestamp_out_of_window", "the request timestamp is outside the skew window") + r.err.Data.SkewWindowMS = &windowMS + return &r +} + +func notNegotiated(id jsontext.Value, name, message string) reply { + r := errorReply(id, acs.CapabilityNotNegotiated, "capability_not_negotiated", message) + r.err.Data.Method = name + return r +} + +// decisionReply builds a result for in, checked against the response +// schema; false means the result cannot be sent, because the request's +// identifiers do not make a valid answer. +func (g *Guardian) decisionReply(in *call, d acs.Decision, chainHash string) (reply, bool) { + version := in.ACSVersion + if version == "" { + version = acs.Version + } + result := &acs.Result{Type: acs.ResultTypeFinal, ACSVersion: version, RequestID: in.RequestID, Decision: d, ChainHash: chainHash} + r := reply{id: in.ID, result: result, sessionID: in.SessionID, keyID: in.KeyID} + if in.NullID() || g.checkResponse(r) != nil { + return reply{}, false + } + return r, true +} + +// checkResponse validates the response against response-envelope.json. +func (g *Guardian) checkResponse(r reply) error { + b, err := marshalResponse(r) + if err != nil { + return err + } + return g.schemas.ValidateJSON(schema.ResponseEnvelope, b) +} + +func marshalResponse(r reply) ([]byte, error) { + resp := acs.Response{JSONRPC: acs.JSONRPCVersion, ID: json.RawMessage(r.id), Error: r.err} + if r.result != nil { + b, err := jsonv2.Marshal(r.result) + if err != nil { + return nil, err + } + resp.Result = b + } + return jsonv2.Marshal(resp) +} + +// encode signs and serialises a reply, checks the bytes to be sent against +// response-envelope.json, and records them. A reply that cannot be signed +// or does not pass the schema is replaced by an unsigned error. +func (g *Guardian) encode(ctx context.Context, r reply, methodName string) (b []byte) { + defer func() { + if p := recover(); p != nil { + g.failure(ctx, methodName, r.sessionID, "", "answer", fmt.Errorf("panic: %v", p)) + b, _ = marshalResponse(errorReply(r.id, acs.InternalError, "answer_failed", "the Guardian could not sign a valid answer")) + } + }() + b, err := g.sign(ctx, r) + if err != nil && r.signatureOptional { + r.sessionID = "" + b, err = marshalResponse(r) + } + if err == nil { + err = g.schemas.ValidateJSON(schema.ResponseEnvelope, b) + } + if err != nil { + g.failure(ctx, methodName, r.sessionID, "", "answer", err) + b, _ = marshalResponse(errorReply(r.id, acs.InternalError, "answer_failed", "the Guardian could not sign a valid answer")) + } + g.record(ctx, Outbound, methodName, b) + return b +} + +// sign adds the §10 signature to a result or an error: the signed input is +// the response envelope with no signature, canonicalized. A reply with no +// sessionID, which answers a request that did not authenticate, is sent +// unsigned: no key is established for it. +func (g *Guardian) sign(ctx context.Context, r reply) ([]byte, error) { + b, err := marshalResponse(r) + if err != nil || r.sessionID == "" { + return b, err + } + input, _, err := envelope.ResponseSigningInput(b) + if err != nil { + return nil, err + } + sig, err := g.cfg.Signer.Sign(ctx, r.sessionID, r.keyID, input) + if err != nil { + return nil, err + } + switch result := r.result.(type) { + case *acs.Result: + result.Signature = &sig + case *acs.ServerHello: + result.Signature = &sig + case nil: + r.err.Signature = &sig + } + return marshalResponse(r) +} + +func (g *Guardian) record(ctx context.Context, d Direction, methodName string, body []byte) { + defer func() { _ = recover() }() + g.cfg.AuditLog.Envelope(ctx, EnvelopeRecord{Direction: d, Method: methodName, Body: json.RawMessage(slices.Clone(body)), RecordedAt: g.cfg.Now().UTC()}) +} + +func (g *Guardian) event(ctx context.Context, kind AuditEventKind, methodName, sessionID, requestID, message string) { + defer func() { _ = recover() }() + g.cfg.AuditLog.Event(ctx, AuditEvent{Kind: kind, SessionID: sessionID, RequestID: requestID, Method: methodName, Message: message, RecordedAt: g.cfg.Now().UTC()}) +} + +// failure records what a provider or the Guardian itself reported. The +// detail stays in the audit log: the wire carries a fixed message, since a +// store, key service or engine error may name internals. +func (g *Guardian) failure(ctx context.Context, methodName, sessionID, requestID, what string, err error) { + g.event(ctx, EventFailure, methodName, sessionID, requestID, what+": "+err.Error()) +} + +// ping answers system/ping (§13): always ALLOW, never an ACS-specific +// error, no signature required, nothing written to the SessionContext. The +// answer is signed only when the ping was signed and verifies, so an +// unauthenticated caller cannot obtain the session key's signature over a +// result it chose; it is sent unsigned when signing fails, so liveness +// survives a key outage. +func (g *Guardian) ping(ctx context.Context, in *envelope.Inbound, body []byte) reply { + if err := g.schemas.Validate(schema.RequestEnvelope, in.Tree); err != nil || in.NullID() { + return errorReply(in.ID, acs.InvalidRequest, "envelope_invalid", "system/ping must be a valid request envelope") + } + var payload acs.SystemPingPayload + if err := g.payload(in, &payload); err != nil { + return errorReply(in.ID, acs.InvalidParams, "payload_invalid", err.Error()) + } + answer, _ := jsonv2.Marshal(acs.SystemPingResponse{Status: acs.PingStatusOK, Echo: payload.Echo, ServerTimestamp: g.cfg.Now().UTC().Format(time.RFC3339Nano)}) + result := &acs.Result{Type: acs.ResultTypeFinal, ACSVersion: in.ACSVersion, RequestID: in.RequestID, Decision: acs.Decision{Disposition: acs.Allow}, Payload: answer} + r := reply{id: in.ID, result: result, signatureOptional: true} + if input, sig, err := envelope.RequestSigningInput(body); err == nil && sig != nil && in.SessionID != "" && + g.cfg.Signer.Verify(ctx, in.SessionID, input, *sig) == nil { + r.sessionID, r.keyID = in.SessionID, sig.KeyID + } + return r +} + +// payload validates params.payload of in against its method's schema and +// decodes it into v. +func (g *Guardian) payload(in *envelope.Inbound, v any) error { + params, _ := in.Tree.(map[string]any)["params"].(map[string]any) + if err := g.schemas.ValidatePayload(in.Method, params["payload"]); err != nil { + return err + } + if v == nil { + return nil + } + var p struct { + Payload jsontext.Value `json:"payload"` + } + if err := jsonv2.Unmarshal(in.Params, &p); err != nil { + return err + } + return jsonv2.Unmarshal(p.Payload, v) +} diff --git a/reference-implementations/agt-go/guardian/guardian_test.go b/reference-implementations/agt-go/guardian/guardian_test.go new file mode 100644 index 00000000..c18bc9d3 --- /dev/null +++ b/reference-implementations/agt-go/guardian/guardian_test.go @@ -0,0 +1,1454 @@ +package guardian_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "slices" + "strings" + "testing" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/chain" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/handshake" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/schema" +) + +var hash64 = strings.Repeat("a", 64) + +// hookPayloads holds a minimal valid payload for every native hook. +var hookPayloads = map[string]any{ + acs.StepSessionStart: map[string]any{}, + acs.StepAgentTrigger: map[string]any{"trigger_type": "user_message"}, + acs.StepTurnStart: map[string]any{"turn_id": "t1", "triggered_by": "user_message"}, + acs.StepUserMessage: map[string]any{"content": []any{map[string]any{"type": "text", "value": "hello"}}}, + acs.StepAgentResponse: map[string]any{"content": []any{map[string]any{"type": "text", "value": "hi"}}}, + acs.StepKnowledgeRetrieval: map[string]any{"query": map[string]any{"value": "q"}, "results": []any{}}, + acs.StepMemoryContextRetrieval: map[string]any{"results": []any{}}, + acs.StepMemoryStore: map[string]any{"content": map[string]any{"value": "m"}}, + acs.StepToolCallRequest: toolCall("ls -la"), + acs.StepToolCallResult: map[string]any{"tool": map[string]any{"name": "Bash"}, "exit_status": "success", "outputs": []any{map[string]any{"value": "ok"}}}, + acs.StepPreCompact: map[string]any{"entries_to_compact": []any{"s1"}, "triggered_by": "manual"}, + acs.StepPostCompact: map[string]any{"summary": map[string]any{"value": "sum", "provenance": map[string]any{"provenance_id": "p", "origin": "agent_generated"}}, "entries_compacted": []any{"s1"}, "pre_compact_chain_hash": hash64, "post_compact_chain_hash": hash64}, + acs.StepSubagentStart: map[string]any{"subagent_session_id": observedagent.NewUUID(), "parent_session_id": observedagent.NewUUID(), "parent_step_id": "s1", "intent_derivation": "fresh"}, + acs.StepSubagentStop: map[string]any{"subagent_session_id": observedagent.NewUUID(), "outcome": "completed"}, + acs.StepSkillRegister: map[string]any{"skill_id": "sk", "definition": map[string]any{"digest": map[string]any{"algorithm": "sha-256", "value": "v"}}, "declared_capabilities": []any{}}, + acs.StepSkillLoad: map[string]any{"skill_id": "sk", "load_trigger": "user", "load_path": []any{map[string]any{"skill_id": "sk"}}, "digest": map[string]any{"algorithm": "sha-256", "value": "v"}}, + acs.StepSkillUnload: map[string]any{"skill_id": "sk", "reason": "explicit_unload"}, + acs.StepTurnEnd: map[string]any{"turn_id": "t1", "outcome": "completed"}, + acs.StepSessionEnd: map[string]any{"reason": "completed"}, +} + +func TestHandshake(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.OnDecisionFailure = acs.FailureDeny }) + c := h.client(observedagent.NewUUID()) + hello := observedagent.DefaultHello(append(handshake.CoreMethods(), "steps/notAHook", "protocols/MCP/tools/call", "protocols/A2A/message/send", acs.MethodSystemPing)...) + hello.WrappedProtocols = []acs.WrappedProtocol{{Protocol: "MCP", Version: "2025-06-18"}} + o, err := c.Handshake(context.Background(), hello) + if err != nil { + t.Fatal(err) + } + if o.Hello == nil || !o.Verified { + t.Fatalf("no signed ServerHello: %s", o.Raw) + } + s := o.Hello + if s.NegotiatedVersion != acs.Version || s.SelectedTransport != acs.TransportHTTP || s.OnDecisionFailure != acs.FailureDeny { + t.Fatalf("ServerHello %+v", s) + } + if want := append(handshake.CoreMethods(), "protocols/MCP/tools/call"); !slices.Equal(s.MethodsEvaluated, want) { + t.Fatalf("methods_evaluated %v: must be exactly the declared hooks and MCP methods this Guardian evaluates, %v", s.MethodsEvaluated, want) + } + if s.SkewWindowMS != 300000 || s.TimeoutConfig.DefaultMS != 5000 || !slices.Equal(s.SignatureAlgorithmsSupported, []string{acs.SignatureAlgorithmHMACSHA256}) { + t.Fatalf("ServerHello %+v", s) + } + if !slices.Equal(s.ProfilesAccepted, []string{acs.ProfileCore}) { + t.Fatalf("profiles_accepted %v", s.ProfilesAccepted) + } + registry, err := schema.Load() + if err != nil { + t.Fatal(err) + } + if err := registry.ValidateJSON(schema.ResponseEnvelope, o.Raw); err != nil { + t.Fatalf("the handshake response fails response-envelope.json: %v", err) + } + if h.engine.callCount() != 0 { + t.Fatal("the handshake called the engine") + } + // An agent that restarts sends the same ClientHello again and gets the + // negotiated terms back; other terms would renegotiate, and are refused. + again := h.send(c, observedagent.Request{Method: acs.MethodHandshakeHello, Payload: hello}) + if again.Hello == nil || !again.Verified || !slices.Equal(again.Hello.MethodsEvaluated, s.MethodsEvaluated) { + t.Fatalf("the same ClientHello again got %s", again.Raw) + } + reordered := hello + reordered.MethodsImplemented = slices.Clone(hello.MethodsImplemented) + slices.Reverse(reordered.MethodsImplemented) + again = h.send(c, observedagent.Request{Method: acs.MethodHandshakeHello, Payload: reordered}) + if again.Hello == nil || !again.Verified || !slices.Equal(again.Hello.MethodsEvaluated, s.MethodsEvaluated) { + t.Fatalf("the same ClientHello set in another order got %s", again.Raw) + } + other := hello + other.MethodsImplemented = handshake.CoreMethods() + h.refusal(c, observedagent.Request{Method: acs.MethodHandshakeHello, Payload: other}, acs.SessionRefused) + h.decision(c, acs.StepSessionEnd, hookPayloads[acs.StepSessionEnd]) + h.refusal(c, observedagent.Request{Method: acs.MethodHandshakeHello, Payload: hello}, acs.SessionRefused) +} + +func TestRecoveredSessionUsesNegotiatedTerms(t *testing.T) { + now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + store, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{ + MaxSessions: 4, MaxEntries: 32, MaxReservations: 64, MaxSkillApprovals: 4, Retention: time.Hour, + }) + if err != nil { + t.Fatal(err) + } + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k1", Secret: ikm}) + if err != nil { + t.Fatal(err) + } + firstEngine := &testEngine{policy: guardian.PolicyDescription{ApproverTypes: []acs.ApproverType{acs.ApproverHuman}}} + first := mustGuardian(t, guardian.Config{ + Engine: firstEngine, Signer: signer, Store: store, + DecisionTimeout: 40 * time.Millisecond, SkewWindow: time.Second, + Now: func() time.Time { return now }, + }) + client := &observedagent.Client{ + Transport: func(ctx context.Context, body []byte) ([]byte, error) { return first.Handle(ctx, body), nil }, + Signer: signer, AgentID: "agent", SessionID: observedagent.NewUUID(), Now: func() time.Time { return now }, + } + hello := observedagent.DefaultHello(acs.StepToolCallRequest) + initial, err := client.Handshake(context.Background(), hello) + if err != nil || initial.Hello == nil { + t.Fatalf("initial handshake: %v %s", err, initial.Raw) + } + + secondEngine := &testEngine{policy: guardian.PolicyDescription{ApproverTypes: []acs.ApproverType{acs.ApproverService}}} + second := mustGuardian(t, guardian.Config{ + Engine: secondEngine, Signer: signer, Store: store, + DecisionTimeout: time.Second, SkewWindow: time.Minute, + Now: func() time.Time { return now }, + }) + client.Transport = func(ctx context.Context, body []byte) ([]byte, error) { return second.Handle(ctx, body), nil } + recovered, err := client.Handshake(context.Background(), hello) + if err != nil || recovered.Hello == nil || recovered.Hello.TimeoutConfig.DefaultMS != 40 || recovered.Hello.SkewWindowMS != 1000 || + !slices.Equal(recovered.Hello.ApproverTypesSupported, []acs.ApproverType{acs.ApproverHuman}) { + t.Fatalf("recovered handshake: %v %s", err, recovered.Raw) + } + + stale, err := client.Send(context.Background(), observedagent.Request{ + Method: acs.StepToolCallRequest, Payload: toolCall("stale"), Timestamp: now.Add(-10 * time.Second), + }) + if err != nil || stale.Error == nil || stale.Error.Code != acs.TimestampOutOfWindow || secondEngine.callCount() != 0 { + t.Fatalf("stale request: %v %s; engine calls %d", err, stale.Raw, secondEngine.callCount()) + } + + deadline := make(chan time.Duration, 1) + requestedAt := time.Now() + secondEngine.set(func(ctx context.Context, _ guardian.PolicyInput) (guardian.PolicyDecision, error) { + limit, ok := ctx.Deadline() + if !ok { + deadline <- 0 + } else { + deadline <- limit.Sub(requestedAt) + } + <-ctx.Done() + return guardian.PolicyDecision{}, ctx.Err() + }) + timed, err := client.Send(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("slow")}) + engineTimeout := <-deadline + if err != nil || timed.Result == nil || timed.Result.Disposition != acs.Deny || engineTimeout <= 0 || engineTimeout > 100*time.Millisecond { + t.Fatalf("negotiated timeout: %v %s; engine deadline %s", err, timed.Raw, engineTimeout) + } + + secondEngine.answer(acs.Decision{Disposition: acs.Ask, Reasoning: "approval required", AskDetails: &acs.AskDetails{ + Approver: acs.Approver{Type: acs.ApproverHuman, ID: "operator"}, Question: "allow?", TimeoutSeconds: 30, + }}) + asked, err := client.Send(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ask")}) + if err != nil || asked.Result == nil || asked.Result.Disposition != acs.Ask { + t.Fatalf("negotiated approver types: %v %s", err, asked.Raw) + } +} + +// A handshake replayed into the session it opened is a replay (§10.3), not +// a second negotiation. +func TestHandshakeReplay(t *testing.T) { + h := newHarness(t) + c := h.client(observedagent.NewUUID()) + body, err := c.Envelope(context.Background(), observedagent.Request{Method: acs.MethodHandshakeHello, Payload: observedagent.DefaultHello(method.Hooks()...)}) + if err != nil { + t.Fatal(err) + } + if o, err := c.SendBody(context.Background(), body); err != nil || o.Hello == nil { + t.Fatalf("handshake: %v %s", err, o.Raw) + } + o, err := c.SendBody(context.Background(), body) + if err != nil { + t.Fatal(err) + } + if o.Error == nil || o.Error.Code != acs.ReplayDetected { + t.Fatalf("replayed handshake got %s, want -32005", o.Raw) + } +} + +func TestHandshakeReplayAfterSessionEnd(t *testing.T) { + h := newHarness(t) + c := h.client(observedagent.NewUUID()) + body, err := c.Envelope(context.Background(), observedagent.Request{ + Method: acs.MethodHandshakeHello, Payload: observedagent.DefaultHello(acs.StepSessionEnd), + }) + if err != nil { + t.Fatal(err) + } + if out, err := c.SendBody(context.Background(), body); err != nil || out.Hello == nil { + t.Fatalf("handshake: %v %s", err, out.Raw) + } + h.decision(c, acs.StepSessionEnd, hookPayloads[acs.StepSessionEnd]) + out, err := c.SendBody(context.Background(), body) + if err != nil || out.Error == nil || out.Error.Code != acs.ReplayDetected { + t.Fatalf("replayed closed-session handshake: %v %s", err, out.Raw) + } +} + +func TestReplayReservationsAreBounded(t *testing.T) { + t.Run("restart handshakes", func(t *testing.T) { + store, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{ + MaxSessions: 1, MaxEntries: 8, MaxReservations: 2, MaxSkillApprovals: 1, Retention: time.Hour, + }) + if err != nil { + t.Fatal(err) + } + h := newHarness(t, func(cfg *guardian.Config) { cfg.Store = store }) + c := h.client(observedagent.NewUUID()) + hello := observedagent.DefaultHello(acs.StepToolCallRequest) + for i := 0; i < 2; i++ { + if out, err := c.Handshake(context.Background(), hello); err != nil || out.Hello == nil { + t.Fatalf("handshake %d: %v %s", i+1, err, out.Raw) + } + } + if out, err := c.Handshake(context.Background(), hello); err != nil || out.Error == nil || out.Error.Code != acs.SessionRefused { + t.Fatalf("handshake past reservation capacity: %v %s", err, out.Raw) + } + }) + + t.Run("closed session", func(t *testing.T) { + store, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{ + MaxSessions: 1, MaxEntries: 8, MaxReservations: 3, MaxSkillApprovals: 1, Retention: time.Hour, + }) + if err != nil { + t.Fatal(err) + } + h := newHarness(t, func(cfg *guardian.Config) { cfg.Store = store }) + c := h.client(observedagent.NewUUID()) + if out, err := c.Handshake(context.Background(), observedagent.DefaultHello(acs.StepSessionEnd, acs.StepToolCallRequest)); err != nil || out.Hello == nil { + t.Fatalf("handshake: %v %s", err, out.Raw) + } + if result := h.decision(c, acs.StepSessionEnd, hookPayloads[acs.StepSessionEnd]); result.Disposition != acs.Allow { + t.Fatalf("session end: %+v", result.Decision) + } + if result := h.decision(c, acs.StepToolCallRequest, toolCall("first")); result.Disposition != acs.Deny || !slices.Contains(result.ReasonCodes, disposition.ReasonSessionClosed) { + t.Fatalf("first closed-session request: %+v", result.Decision) + } + if result := h.decision(c, acs.StepToolCallRequest, toolCall("second")); result.Disposition != acs.Deny || !slices.Contains(result.ReasonCodes, disposition.ReasonStoreFull) { + t.Fatalf("request past reservation capacity: %+v", result.Decision) + } + }) +} + +// A full store refuses the session; it never drops a live one to make room. +func TestHandshakeAtCapacity(t *testing.T) { + store, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{MaxSessions: 1, MaxEntries: 8, MaxReservations: 16, MaxSkillApprovals: 1, Retention: time.Hour}) + if err != nil { + t.Fatal(err) + } + h := newHarness(t, func(c *guardian.Config) { c.Store = store }) + first := h.session() + e := h.refusal(h.client(observedagent.NewUUID()), observedagent.Request{Method: acs.MethodHandshakeHello, Payload: observedagent.DefaultHello(method.Hooks()...)}, acs.SessionRefused) + if e.Data == nil || e.Data.Reason != "capacity" { + t.Fatalf("refusal %+v", e) + } + if r := h.decision(first, acs.StepToolCallRequest, toolCall("ls")); r.Disposition != acs.Allow { + t.Fatalf("the live session lost its state: %+v", r) + } +} + +func TestHandshakeRefusals(t *testing.T) { + h2 := newHarness(t) + tests := []struct { + name string + h *harness + hello func(acs.ClientHello) acs.ClientHello + code acs.ErrorCode + }{ + {"no_common_major_version", h2, func(c acs.ClientHello) acs.ClientHello { c.ACSVersionsSupported = []string{"1.0.0"}; return c }, acs.UnsupportedVersion}, + {"no_compatible_0x_minor_0_9_0", h2, func(c acs.ClientHello) acs.ClientHello { c.ACSVersionsSupported = []string{"0.9.0"}; return c }, acs.UnsupportedVersion}, + {"no_compatible_0x_minor_0_2_0", h2, func(c acs.ClientHello) acs.ClientHello { c.ACSVersionsSupported = []string{"0.2.0"}; return c }, acs.UnsupportedVersion}, + {"provenance_required", newHarness(t, requiresProvenance), func(c acs.ClientHello) acs.ClientHello { return c }, acs.ProvenanceRequired}, + {"no_common_transport", h2, func(c acs.ClientHello) acs.ClientHello { c.TransportsSupported = []string{"stdio"}; return c }, acs.SessionRefused}, + {"client_hello_invalid", h2, func(c acs.ClientHello) acs.ClientHello { c.ACSVersionsSupported = nil; return c }, acs.InvalidParams}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := tt.h.client(observedagent.NewUUID()) + e := tt.h.refusal(c, observedagent.Request{Method: acs.MethodHandshakeHello, Payload: tt.hello(observedagent.DefaultHello(handshake.CoreMethods()...))}, tt.code) + if tt.code == acs.UnsupportedVersion && !slices.Equal(e.Data.SupportedVersions, []string{acs.Version}) { + t.Fatalf("data %+v", e.Data) + } + }) + } +} + +func requiresProvenance(c *guardian.Config) { + c.Engine.(*testEngine).policy.RequiresProvenance = true +} + +func TestSignatureRequired(t *testing.T) { + h := newHarness(t) + c := h.session() + h.refusal(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), Unsigned: true}, acs.SignatureInvalid) + + other, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k1", Secret: []byte(strings.Repeat("x", 32))}) + if err != nil { + t.Fatal(err) + } + forged := *c + forged.Signer = other + h.refusal(&forged, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}, acs.SignatureInvalid) + + // The configured secret under a key_id the Guardian does not hold is + // refused, never verified with the configured secret (§10). + relabelled, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k9", Secret: ikm}) + if err != nil { + t.Fatal(err) + } + unknown := *c + unknown.Signer, unknown.KeyID = relabelled, "k9" + h.refusal(&unknown, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}, acs.SignatureInvalid) + + body, err := c.Envelope(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if err != nil { + t.Fatal(err) + } + tampered := []byte(strings.Replace(string(body), `ls`, `rm`, 1)) + o, err := c.SendBody(context.Background(), tampered) + if err != nil { + t.Fatal(err) + } + if o.Error == nil || o.Error.Code != acs.SignatureInvalid { + t.Fatalf("a tampered request got %s", o.Raw) + } + if h.engine.callCount() != 0 { + t.Fatal("an unauthenticated request reached the engine") + } + h.decision(c, acs.StepToolCallRequest, toolCall("ls")) +} + +func TestKeyBoundToSession(t *testing.T) { + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k1", Secret: ikm}, guardian.HMACKey{ID: "k2", Secret: []byte(strings.Repeat("y", 32))}) + if err != nil { + t.Fatal(err) + } + h := newHarness(t, func(c *guardian.Config) { c.Signer = signer }) + h.signer = signer + c := h.session() + other := *c + other.KeyID = "k2" + // The request authenticates, under another key: a signed DENY, and + // nothing enters the session. + o := h.send(&other, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Result == nil || !o.Verified || o.Result.Disposition != acs.Deny || !slices.Contains(o.Result.ReasonCodes, disposition.ReasonKeyNotBound) || o.Result.Signature.KeyID != "k2" { + t.Fatalf("a step under another key got %s", o.Raw) + } + if sess, _, _ := h.store.Load(context.Background(), c.SessionID); len(sess.Entries) != 0 { + t.Fatalf("a step under another key entered the chain: %+v", sess.Entries) + } + h.decision(c, acs.StepToolCallRequest, toolCall("ls")) +} + +func TestAgentIDBoundToSession(t *testing.T) { + h := newHarness(t) + c := h.session() + other := *c + other.AgentID = "other-agent" + o := h.send(&other, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Result == nil || !o.Verified || o.Result.Disposition != acs.Deny || !slices.Contains(o.Result.ReasonCodes, disposition.ReasonAgentIDNotBound) { + t.Fatalf("a step from another agent got %s", o.Raw) + } + if sess, _, _ := h.store.Load(context.Background(), c.SessionID); len(sess.Entries) != 0 { + t.Fatalf("a step from another agent entered the chain: %+v", sess.Entries) + } + h.refusal(&other, observedagent.Request{Method: acs.MethodHandshakeHello, Payload: observedagent.DefaultHello(handshake.CoreMethods()...)}, acs.SessionRefused) + h.decision(c, acs.StepToolCallRequest, toolCall("ls")) +} + +func TestReplayAndSkew(t *testing.T) { + h := newHarness(t) + c := h.session() + first := observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), RequestID: observedagent.NewUUID(), Nonce: strings.Repeat("n", 16)} + h.send(c, first) + sess, _, _ := h.store.Load(context.Background(), c.SessionID) + + h.refusal(c, first, acs.ReplayDetected) + sameNonce := first + sameNonce.RequestID = observedagent.NewUUID() + h.refusal(c, sameNonce, acs.ReplayDetected) + after, _, _ := h.store.Load(context.Background(), c.SessionID) + if after.Head != sess.Head || len(after.Entries) != len(sess.Entries) { + t.Fatal("a replay was appended to the chain") + } + + for _, skew := range []time.Duration{-301 * time.Second, 301 * time.Second} { + e := h.refusal(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), Timestamp: time.Now().Add(skew)}, acs.TimestampOutOfWindow) + if e.Data == nil || e.Data.SkewWindowMS == nil || *e.Data.SkewWindowMS != 300000 { + t.Fatalf("data %+v, want skew_window_ms 300000", e.Data) + } + } + h.decision(c, acs.StepToolCallRequest, toolCall("ls")) +} + +func TestRejectedAuthenticatedRequestsCannotBeReplayed(t *testing.T) { + tests := []struct { + name string + methods []string + request observedagent.Request + code acs.ErrorCode + }{ + { + name: "method not negotiated", + methods: []string{acs.StepToolCallRequest}, + request: observedagent.Request{Method: acs.StepUserMessage, Payload: hookPayloads[acs.StepUserMessage]}, + code: acs.CapabilityNotNegotiated, + }, + { + name: "invalid payload", + methods: []string{acs.StepToolCallRequest}, + request: observedagent.Request{Method: acs.StepToolCallRequest, Payload: map[string]any{"tool": map[string]any{"name": "Bash"}}}, + code: acs.InvalidParams, + }, + { + name: "unsupported ACS method", + methods: []string{acs.StepToolCallRequest}, + request: observedagent.Request{Method: acs.MethodAgBOMSnapshot, Payload: map[string]any{}}, + code: acs.CapabilityNotNegotiated, + }, + { + name: "undefined method", + methods: []string{acs.StepToolCallRequest}, + request: observedagent.Request{Method: "steps/unknown", Payload: map[string]any{}}, + code: acs.MethodNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newHarness(t) + c := h.client(observedagent.NewUUID()) + if out, err := c.Handshake(context.Background(), observedagent.DefaultHello(tt.methods...)); err != nil || out.Hello == nil { + t.Fatalf("handshake: %v %s", err, out.Raw) + } + tt.request.RequestID = observedagent.NewUUID() + body, err := c.Envelope(context.Background(), tt.request) + if err != nil { + t.Fatal(err) + } + first, err := c.SendBody(context.Background(), body) + if err != nil || first.Error == nil || first.Error.Code != tt.code { + t.Fatalf("first request: %v %s", err, first.Raw) + } + second, err := c.SendBody(context.Background(), body) + if err != nil || second.Error == nil || second.Error.Code != acs.ReplayDetected { + t.Fatalf("replay: %v %s", err, second.Raw) + } + }) + } + + // 0.x versions must share X.Y, so a 0.2.0 step is refused in a 0.1.0 session. + for _, offered := range []string{"1.0.0", "0.2.0"} { + t.Run("unsupported ACS version "+offered, func(t *testing.T) { + h := newHarness(t) + c := h.session() + body, err := c.Envelope(context.Background(), observedagent.Request{ + Method: acs.StepToolCallRequest, + Payload: toolCall("ls"), + RequestID: observedagent.NewUUID(), + }) + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := jsonv2.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + params := request["params"].(map[string]any) + params["acs_version"] = offered + delete(params, "signature") + unsigned, err := jsonv2.Marshal(request) + if err != nil { + t.Fatal(err) + } + body = resign(t, c, unsigned) + + first, err := c.SendBody(context.Background(), body) + if err != nil || first.Error == nil || first.Error.Code != acs.UnsupportedVersion { + t.Fatalf("first request: %v %s", err, first.Raw) + } + second, err := c.SendBody(context.Background(), body) + if err != nil || second.Error == nil || second.Error.Code != acs.ReplayDetected { + t.Fatalf("replay: %v %s", err, second.Raw) + } + }) + } +} + +func TestReplayHistoryOutlivesEveryAcceptedTimestamp(t *testing.T) { + now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + h := newHarness(t, func(cfg *guardian.Config) { + cfg.Store = nil + cfg.MaxSessions = 2 + cfg.SkewWindow = time.Minute + cfg.SessionRetention = 2 * time.Minute + cfg.Now = func() time.Time { return now } + }) + c := h.client(observedagent.NewUUID()) + c.Now = func() time.Time { return now } + hello := observedagent.Request{ + Method: acs.MethodHandshakeHello, + Payload: observedagent.DefaultHello(acs.StepToolCallRequest), + RequestID: observedagent.NewUUID(), + Timestamp: now.Add(time.Minute), + } + body, err := c.Envelope(context.Background(), hello) + if err != nil { + t.Fatal(err) + } + if out, err := c.SendBody(context.Background(), body); err != nil || out.Hello == nil { + t.Fatalf("initial handshake: %v %s", err, out.Raw) + } + second := h.client(observedagent.NewUUID()) + second.Now = func() time.Time { return now } + if out, err := second.Handshake(context.Background(), observedagent.DefaultHello(acs.StepToolCallRequest)); err != nil || out.Hello == nil { + t.Fatalf("second session: %v %s", err, out.Raw) + } + + now = now.Add(2 * time.Minute) + third := h.client(observedagent.NewUUID()) + third.Now = func() time.Time { return now } + if out, err := third.Handshake(context.Background(), observedagent.DefaultHello(acs.StepToolCallRequest)); err != nil || out.Error == nil || out.Error.Code != acs.SessionRefused { + t.Fatalf("session evicted at the freshness boundary: %v %s", err, out.Raw) + } + if out, err := c.SendBody(context.Background(), body); err != nil || out.Error == nil || out.Error.Code != acs.ReplayDetected { + t.Fatalf("captured handshake at the freshness boundary: %v %s", err, out.Raw) + } + + now = now.Add(time.Nanosecond) + if out, err := third.Handshake(context.Background(), observedagent.DefaultHello(acs.StepToolCallRequest)); err != nil || out.Hello == nil { + t.Fatalf("session after replay window: %v %s", err, out.Raw) + } + if out, err := c.SendBody(context.Background(), body); err != nil || out.Error == nil || out.Error.Code != acs.TimestampOutOfWindow { + t.Fatalf("captured handshake after eviction: %v %s", err, out.Raw) + } +} + +func TestEnvelopeGate(t *testing.T) { + h := newHarness(t) + c := h.session() + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + t.Error("the engine was called for an invalid envelope") + return guardian.PolicyDecision{}, nil + }) + missingArguments := map[string]any{"tool": map[string]any{"name": "Bash"}} + h.refusal(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: missingArguments}, acs.InvalidParams) + + // An envelope missing a required member, re-signed so only the schema + // can refuse it. + body, err := c.Envelope(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := jsonv2.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + params := m["params"].(map[string]any) + delete(params, "timestamp") + delete(params, "signature") + unsigned, _ := jsonv2.Marshal(m) + o, err := c.SendBody(context.Background(), resign(t, c, unsigned)) + if err != nil { + t.Fatal(err) + } + if o.Error == nil || o.Error.Code != acs.InvalidRequest { + t.Fatalf("an envelope without timestamp got %s, want -32600", o.Raw) + } +} + +// resign signs an arbitrary body with the client's key. +func resign(t *testing.T, c *observedagent.Client, body []byte) []byte { + t.Helper() + var m map[string]any + if err := jsonv2.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + input, err := canonicalJSON(body) + if err != nil { + t.Fatal(err) + } + sig, err := c.Signer.Sign(context.Background(), c.SessionID, c.KeyID, input) + if err != nil { + t.Fatal(err) + } + m["params"].(map[string]any)["signature"] = map[string]any{"algorithm": sig.Algorithm, "value": sig.Value, "key_id": sig.KeyID} + out, _ := jsonv2.Marshal(m) + return out +} + +func canonicalJSON(b []byte) ([]byte, error) { + var v any + if err := jsonv2.Unmarshal(b, &v); err != nil { + return nil, err + } + var s strings.Builder + if err := canonical(&s, toNumbers(v)); err != nil { + return nil, err + } + return []byte(s.String()), nil +} + +// toNumbers turns the float64 of a decoded tree into json.Number for the +// independent canonicalizer. +func toNumbers(v any) any { + switch v := v.(type) { + case map[string]any: + for k, e := range v { + v[k] = toNumbers(e) + } + case []any: + for i, e := range v { + v[i] = toNumbers(e) + } + case float64: + b, _ := json.Marshal(v) + return json.Number(b) + } + return v +} + +func TestChainPublication(t *testing.T) { + h := newHarness(t) + c := h.session() + allowed := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + h.engine.answer(deny("destructive")) + denied := h.decision(c, acs.StepToolCallRequest, toolCall("rm -rf /")) + if denied.Disposition != acs.Deny { + t.Fatalf("got %s", denied.Disposition) + } + sess, _, err := h.store.Load(context.Background(), c.SessionID) + if err != nil { + t.Fatal(err) + } + if len(sess.Entries) != 2 || sess.Entries[0].EntryHash != allowed.ChainHash || sess.Entries[1].EntryHash != denied.ChainHash || sess.Head != denied.ChainHash { + t.Fatalf("published heads %s %s, chain %+v", allowed.ChainHash, denied.ChainHash, sess.Entries) + } + if sess.Entries[0].PreviousHash != nil || *sess.Entries[1].PreviousHash != allowed.ChainHash { + t.Fatal("the chain does not link") + } + registry, _ := schema.Load() + for _, e := range sess.Entries { + recomputed, err := chain.EntryHash(e) + if err != nil || recomputed != e.EntryHash { + t.Fatalf("entry %s: recomputed %s", e.EntryID, recomputed) + } + if e.RequestHash == "" || e.StepID != e.EntryID || e.StepType != acs.StepToolCallRequest { + t.Fatalf("entry %+v", e) + } + b, _ := jsonv2.Marshal(e) + if err := registry.ValidateJSON(schema.ContextEntry, b); err != nil { + t.Fatalf("entry fails context-entry.json: %v", err) + } + } + if got := h.engine.last().Session.ChainHash; got != denied.ChainHash { + t.Fatalf("the engine saw head %s, want the entry just written", got) + } + + // The published head is covered by the signature: changing it breaks + // verification. + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + tampered := []byte(strings.Replace(string(o.Raw), o.Result.ChainHash, hash64, 1)) + if err := verifyResponse(tampered, ikm, c.SessionID); err == nil { + t.Fatal("a response with a rewritten chain_hash still verifies") + } +} + +func TestChainMismatch(t *testing.T) { + h := newHarness(t) + c := h.session() + first := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + ok := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), ChainHash: first.ChainHash}) + if ok.Result.Disposition != acs.Allow { + t.Fatalf("a matching chain_hash got %s", ok.Raw) + } + bad := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), ChainHash: hash64}) + if bad.Result.Disposition != acs.Deny || !slices.Contains(bad.Result.ReasonCodes, disposition.ReasonChainMismatch) || bad.Result.ChainHash == "" { + t.Fatalf("a stale chain_hash got %s", bad.Raw) + } + if !slices.Contains(h.audit.eventKinds(), guardian.EventChainMismatch) { + t.Fatal("no chain_mismatch audit event") + } +} + +// TestCoreFloor sends every native hook and requires a signed decision +// with the chain head, then every disposition from the engine. +func TestCoreFloor(t *testing.T) { + h := newHarness(t) + c := h.session() + for _, name := range method.Hooks() { + if name == acs.StepSessionEnd || name == acs.StepSkillLoad { + continue + } + r := h.decision(c, name, hookPayloads[name]) + if r.Disposition != acs.Allow || r.ChainHash == "" { + t.Errorf("%s: %+v", name, r) + } + } + + // The skillRegister above was allowed, so a load of the same skill_id + // and digest is bound to it; any other digest is not (skill-load.json). + if r := h.decision(c, acs.StepSkillLoad, hookPayloads[acs.StepSkillLoad]); r.Disposition != acs.Allow { + t.Fatalf("a load of the registered skill got %+v", r) + } + swapped := map[string]any{"skill_id": "sk", "load_trigger": "user", "load_path": []any{map[string]any{"skill_id": "sk"}}, "digest": map[string]any{"algorithm": "sha-256", "value": "other"}} + if r := h.decision(c, acs.StepSkillLoad, swapped); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonSkillUnverifiable) { + t.Fatalf("a load with another digest got %+v", r) + } + + approver := acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"} + dispositions := []acs.Decision{ + {Disposition: acs.Allow}, + deny("rule"), + {Disposition: acs.Modify, Reasoning: "redacted", Modifications: &acs.Modifications{ParameterOverrides: map[string]json.RawMessage{"command": json.RawMessage(`"ls"`)}}}, + {Disposition: acs.Ask, Reasoning: "needs approval", AskDetails: &acs.AskDetails{Approver: approver, Question: "run it?", TimeoutSeconds: 60}}, + {Disposition: acs.Defer, Reasoning: "later", DeferDetails: &acs.DeferDetails{Reason: acs.DeferInsufficientContext, ResolutionMethod: acs.ResolveAdditionalContext, ResolutionTimeoutMS: 1000}}, + } + for _, d := range dispositions { + h.engine.answer(d) + r := h.decision(c, acs.StepToolCallRequest, toolCall("rm x")) + if r.Disposition != d.Disposition { + t.Fatalf("%s came back as %+v", d.Disposition, r) + } + if d.Disposition == acs.Defer && r.DeferDetails.TimeoutDecision != acs.Deny { + t.Fatalf("timeout_decision %q, want the default deny", r.DeferDetails.TimeoutDecision) + } + if d.Disposition == acs.Modify { + out, err := observedagent.Apply(r.Decision, mustJSON(toolCall("rm x"))) + if err != nil || !strings.Contains(string(out), `"value":"ls"`) { + t.Fatalf("the client could not apply the modification: %s, %v", out, err) + } + } + } + + h.engine.answer(acs.Decision{Disposition: acs.Allow}) + end := h.decision(c, acs.StepSessionEnd, hookPayloads[acs.StepSessionEnd]) + if end.Disposition != acs.Allow || end.ChainHash == "" { + t.Fatalf("sessionEnd %+v", end) + } + afterEnd := observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), RequestID: observedagent.NewUUID()} + closed := h.send(c, afterEnd) + if closed.Result == nil || closed.Result.Disposition != acs.Deny || !slices.Contains(closed.Result.ReasonCodes, disposition.ReasonSessionClosed) || closed.Result.ChainHash != "" { + t.Fatalf("a step after sessionEnd got %s", closed.Raw) + } + // A closed session still refuses replays (§10.3). + h.refusal(c, afterEnd, acs.ReplayDetected) +} + +// A skill whose registration was denied is never approved. +func TestSkillLoadNeedsApprovedRegistration(t *testing.T) { + h := newHarness(t) + c := h.session() + if r := h.decision(c, acs.StepSkillLoad, hookPayloads[acs.StepSkillLoad]); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonSkillUnverifiable) { + t.Fatalf("a load with no registration got %+v", r) + } + h.engine.answer(deny("unvetted")) + h.decision(c, acs.StepSkillRegister, hookPayloads[acs.StepSkillRegister]) + h.engine.answer(acs.Decision{Disposition: acs.Allow}) + if r := h.decision(c, acs.StepSkillLoad, hookPayloads[acs.StepSkillLoad]); r.Disposition != acs.Deny { + t.Fatalf("a load after a denied registration got %+v", r) + } + // The approval outlives the session that made it. + h.decision(c, acs.StepSkillRegister, hookPayloads[acs.StepSkillRegister]) + if r := h.decision(h.session(), acs.StepSkillLoad, hookPayloads[acs.StepSkillLoad]); r.Disposition != acs.Allow { + t.Fatalf("a load in a later session got %+v", r) + } +} + +func mustJSON(v any) json.RawMessage { + b, _ := jsonv2.Marshal(v) + return b +} + +func TestPing(t *testing.T) { + h := newHarness(t) + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{}, errors.New("engine down") + }) + c := h.client(observedagent.NewUUID()) + echo := "probe-1" + for _, unsigned := range []bool{true, false} { + o := h.send(c, observedagent.Request{Method: acs.MethodSystemPing, Payload: acs.SystemPingPayload{Echo: &echo}, Unsigned: unsigned}) + if o.Result == nil || o.Result.Disposition != acs.Allow || o.Result.ChainHash != "" { + t.Fatalf("ping got %s", o.Raw) + } + // An unsigned ping never obtains the session key's signature over an + // answer whose request_id and echo the caller chose. + if signed := o.Result.Signature != nil; signed == unsigned || o.Verified == unsigned { + t.Fatalf("unsigned=%v ping answered with signature %v, verified %v", unsigned, o.Result.Signature, o.Verified) + } + var answer acs.SystemPingResponse + if err := jsonv2.Unmarshal(o.Result.Payload, &answer); err != nil || answer.Status != acs.PingStatusOK || *answer.Echo != echo || answer.ServerTimestamp == "" { + t.Fatalf("ping payload %s", o.Result.Payload) + } + } + if h.engine.callCount() != 0 { + t.Fatal("ping called the engine") + } + if _, ok, _ := h.store.Load(context.Background(), c.SessionID); ok { + t.Fatal("ping created session state") + } +} + +func TestMethodRouting(t *testing.T) { + h := newHarness(t) + c := h.session() + h.refusal(c, observedagent.Request{Method: "system/deferredDecision", Payload: map[string]any{}}, acs.MethodNotFound) + h.refusal(c, observedagent.Request{Method: "steps/notAHook", Payload: map[string]any{}}, acs.MethodNotFound) + h.refusal(c, observedagent.Request{Method: acs.MethodAgBOMSnapshot, Payload: map[string]any{}}, acs.CapabilityNotNegotiated) + e := h.refusal(c, observedagent.Request{Method: "protocols/MCP/tools/call", Payload: map[string]any{}}, acs.CapabilityNotNegotiated) + if e.Data.Method != "protocols/MCP/tools/call" { + t.Fatalf("data %+v", e.Data) + } + + narrow := h.client(observedagent.NewUUID()) + withoutUserMessage := append(slices.DeleteFunc(handshake.CoreMethods(), func(m string) bool { return m == acs.StepUserMessage }), acs.StepAgentTrigger) + if o, _ := narrow.Handshake(context.Background(), observedagent.DefaultHello(withoutUserMessage...)); o.Hello == nil { + t.Fatal("handshake refused") + } + h.refusal(narrow, observedagent.Request{Method: acs.StepUserMessage, Payload: hookPayloads[acs.StepUserMessage]}, acs.CapabilityNotNegotiated) + h.refusal(h.client(observedagent.NewUUID()), observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}, acs.CapabilityNotNegotiated) +} + +func TestTransportErrors(t *testing.T) { + h := newHarness(t) + tests := []struct { + name string + body string + code acs.ErrorCode + }{ + {"not_json", `{"jsonrpc":`, acs.ParseError}, + {"duplicate_member", `{"id":1,"id":2}`, acs.ParseError}, + {"batch", `[{"jsonrpc":"2.0"}]`, acs.InvalidRequest}, + {"not_an_object", `"hello"`, acs.InvalidRequest}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := h.post(context.Background(), []byte(tt.body)) + if err != nil { + t.Fatal(err) + } + var resp acs.Response + if err := jsonv2.Unmarshal(raw, &resp); err != nil { + t.Fatal(err) + } + if resp.Error == nil || resp.Error.Code != tt.code || string(resp.ID) != "null" { + t.Fatalf("got %s", raw) + } + }) + } +} + +func TestInvalidJSONRPCPrecedesSignatureVerification(t *testing.T) { + h := newHarness(t) + tests := []struct { + name, body string + }{ + {"missing_jsonrpc", `{"id":1,"method":"steps/toolCallRequest","params":{}}`}, + {"unsupported_jsonrpc_version", `{"jsonrpc":"1.0","id":1,"method":"steps/toolCallRequest","params":{}}`}, + {"response_shaped_input", `{"jsonrpc":"2.0","id":1,"result":{}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := h.post(context.Background(), []byte(tt.body)) + if err != nil { + t.Fatal(err) + } + var response acs.Response + if err := jsonv2.Unmarshal(raw, &response); err != nil { + t.Fatal(err) + } + if response.Error == nil || response.Error.Code != acs.InvalidRequest || string(response.ID) != "1" { + t.Fatalf("got %s", raw) + } + }) + } +} + +func TestBodyCap(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.MaxBodyBytes = 1024 }) + c := h.session() + big := toolCall(strings.Repeat("x", 2048)) + body, err := c.Envelope(context.Background(), observedagent.Request{Method: acs.StepToolCallRequest, Payload: big}) + if err != nil { + t.Fatal(err) + } + raw, err := h.post(context.Background(), body) + if err != nil { + t.Fatal(err) + } + var resp acs.Response + if err := jsonv2.Unmarshal(raw, &resp); err != nil || resp.Error == nil || resp.Error.Code != acs.InvalidRequest { + t.Fatalf("an oversize body got %s", raw) + } + h.decision(c, acs.StepToolCallRequest, toolCall("ls")) +} + +type unreadableBody struct{} + +func (unreadableBody) Read([]byte) (int, error) { return 0, errors.New("read failed") } +func (unreadableBody) Close() error { return nil } + +func TestUnreadableBodyReturnsInvalidRequest(t *testing.T) { + h := newHarness(t) + request := httptest.NewRequest(http.MethodPost, h.server.URL, nil) + request.Body = unreadableBody{} + response := httptest.NewRecorder() + h.g.ServeHTTP(response, request) + + var result acs.Response + if err := jsonv2.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusOK || result.Error == nil || result.Error.Code != acs.InvalidRequest || string(result.ID) != "null" { + t.Fatalf("got HTTP %d: %s", response.Code, response.Body.Bytes()) + } +} + +var _ io.ReadCloser = unreadableBody{} + +func TestEngineFailuresAreDenials(t *testing.T) { + tests := []struct { + name string + decide func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) + reason string + }{ + {"error", func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{}, errors.New("boom") + }, disposition.ReasonEvaluationFailed}, + {"panic", func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { panic("boom") }, disposition.ReasonEvaluationFailed}, + {"timeout", func(ctx context.Context, _ guardian.PolicyInput) (guardian.PolicyDecision, error) { + <-ctx.Done() + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}}, nil + }, disposition.ReasonEvaluationFailed}, + {"deny_without_reasoning", func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Deny}}, nil + }, disposition.ReasonEvaluationFailed}, + {"modify_breaking_composition", func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + content := "x" + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{ + ModifiedContent: &content, Redactions: []acs.Redaction{{Path: "/raw_command"}}, + }}}, nil + }, disposition.ReasonEvaluationFailed}, + {"overlapping_edits", func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{ + Redactions: []acs.Redaction{{Path: "/arguments/command/value"}}, + ParameterOverrides: map[string]json.RawMessage{"command": json.RawMessage(`"ls"`)}, + }}}, nil + }, disposition.ReasonEvaluationFailed}, + {"ask_to_undeclared_approver", func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Ask, Reasoning: "r", AskDetails: &acs.AskDetails{ + Approver: acs.Approver{Type: acs.ApproverService, ID: "svc"}, Question: "q", TimeoutSeconds: 1, + }}}, nil + }, disposition.ReasonEvaluationFailed}, + {"delegate_to_agent", func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}, DelegateToAgent: true}, nil + }, disposition.ReasonAgentLayerUnavailable}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.DecisionTimeout = 50 * time.Millisecond }) + c := h.session() + h.engine.set(tt.decide) + r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + if r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, tt.reason) || r.Reasoning == "" || r.ChainHash == "" { + t.Fatalf("got %+v", r) + } + }) + } +} + +func TestClientSubstitutions(t *testing.T) { + h := newHarness(t) + c := h.session() + modify := acs.Decision{Disposition: acs.Modify, Reasoning: "rewrite", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/summary/value"}}}} + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: modify, Client: guardian.ClientCapability{CannotApplyModify: true}}, nil + }) + r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + if r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonModifyUnsupported) { + t.Fatalf("§6.5 substitution got %+v", r) + } + r = h.decision(c, acs.StepPostCompact, hookPayloads[acs.StepPostCompact]) + if r.Disposition != acs.Allow || !slices.Contains(r.ReasonCodes, disposition.ReasonModifyUnsupported) { + t.Fatalf("§6.5 postCompact exception got %+v", r) + } + + ask := acs.Decision{Disposition: acs.Ask, Reasoning: "approve", AskDetails: &acs.AskDetails{Approver: acs.Approver{Type: acs.ApproverHuman, ID: "r"}, Question: "q", TimeoutSeconds: 30}} + for _, substitute := range []acs.Disposition{acs.Defer, acs.Deny} { + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: ask, Client: guardian.ClientCapability{CannotResolveAsk: true, AskSubstitute: substitute}}, nil + }) + r = h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + if r.Disposition != substitute || !slices.Contains(r.ReasonCodes, disposition.ReasonApproverUnavailable) { + t.Fatalf("§9.2 substitution %s got %+v", substitute, r) + } + if substitute == acs.Defer && (r.DeferDetails.TimeoutDecision != acs.Deny || r.DeferDetails.ResolutionTimeoutMS != 30000) { + t.Fatalf("defer details %+v", r.DeferDetails) + } + } + kinds := h.audit.eventKinds() + if count(kinds, guardian.EventModifyUnsupported) != 2 || count(kinds, guardian.EventAskSubstituted) != 2 { + t.Fatalf("audit events %v", kinds) + } +} + +func count[T comparable](s []T, v T) int { + n := 0 + for _, e := range s { + if e == v { + n++ + } + } + return n +} + +func TestHookPermittedDispositions(t *testing.T) { + h := newHarness(t) + c := h.session() + h.engine.answer(acs.Decision{Disposition: acs.Ask, Reasoning: "a", AskDetails: &acs.AskDetails{Approver: acs.Approver{Type: acs.ApproverHuman, ID: "r"}, Question: "q", TimeoutSeconds: 1}}) + r := h.decision(c, acs.StepUserMessage, hookPayloads[acs.StepUserMessage]) + if r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonDispositionNotPermitted) { + t.Fatalf("ask at userMessage got %+v", r) + } + h.engine.answer(deny("x")) + r = h.decision(c, acs.StepPostCompact, hookPayloads[acs.StepPostCompact]) + if r.Disposition != acs.Allow || !slices.Contains(r.ReasonCodes, disposition.ReasonDispositionNotPermitted) { + t.Fatalf("deny at postCompact got %+v", r) + } + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{}, errors.New("down") + }) + r = h.decision(c, acs.StepTurnEnd, hookPayloads[acs.StepTurnEnd]) + if r.Disposition != acs.Allow { + t.Fatalf("an engine failure at an audit-only hook got %+v", r) + } + if count(h.audit.eventKinds(), guardian.EventDispositionNotPermitted) != 3 { + t.Fatalf("audit events %v", h.audit.eventKinds()) + } +} + +func TestDeferralBound(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.MaxDeferrals = 2 }) + c := h.session() + h.engine.answer(acs.Decision{Disposition: acs.Defer, Reasoning: "wait", DeferDetails: &acs.DeferDetails{Reason: acs.DeferPendingDependency, ResolutionMethod: acs.ResolveHumanApproval, ResolutionTimeoutMS: 1}}) + for range 2 { + if r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")); r.Disposition != acs.Defer { + t.Fatalf("got %+v", r) + } + } + r := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + if r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonDeferralBoundExceeded) { + t.Fatalf("the third deferral got %+v", r) + } +} + +func TestIntent(t *testing.T) { + h := newHarness(t) + c := h.session() + raw := "list files" + provenance := map[string]any{"provenance_id": "p1", "origin": "user_input"} + intent := map[string]any{"raw": raw, "parsed": []any{map[string]any{"tool": "Bash", "operation": "list"}, map[string]any{"tool": "Read", "operation": "file"}}, "parser_provenance": provenance} + h.decision(c, acs.StepSessionStart, map[string]any{"intent": intent}) + if got := h.engine.last().Session.Intent; got == nil || *got.Raw != raw || len(got.Parsed) != 2 { + t.Fatalf("the engine saw intent %+v", got) + } + reordered := map[string]any{"raw": raw, "parsed": []any{map[string]any{"tool": "Read", "operation": "file"}, map[string]any{"tool": "Bash", "operation": "list"}}, "parser_provenance": provenance} + same := h.decision(c, acs.StepAgentTrigger, map[string]any{"trigger_type": "user_message", "intent": reordered}) + if same.Disposition != acs.Allow { + t.Fatalf("restating the Intent got %+v", same) + } + wider := map[string]any{"raw": raw, "parsed": []any{map[string]any{"tool": "Bash", "operation": "list"}, map[string]any{"tool": "Read", "operation": "file"}, map[string]any{"tool": "curl"}}, "parser_provenance": provenance} + r := h.decision(c, acs.StepAgentTrigger, map[string]any{"trigger_type": "user_message", "intent": wider}) + if r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonIntentMismatch) || r.ChainHash == "" { + t.Fatalf("widening the Intent got %+v", r) + } + if !slices.Contains(h.audit.eventKinds(), guardian.EventIntentMutationRejected) { + t.Fatal("no intent_mutation_rejected audit event") + } + sess, _, _ := h.store.Load(context.Background(), c.SessionID) + if len(sess.Intent.Parsed) != 2 { + t.Fatalf("the Intent changed to %+v", sess.Intent) + } +} + +func TestIntentExtension(t *testing.T) { + h := newHarness(t) + c := h.session() + approver := acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"} + h.engine.answer(acs.Decision{Disposition: acs.Ask, Reasoning: "curl is outside the Intent", AskDetails: &acs.AskDetails{Approver: approver, Question: "allow curl?", TimeoutSeconds: 60}}) + asked := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")) + h.engine.answer(acs.Decision{Disposition: acs.Allow}) + allowed := h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + + source := "reviewer" + grant := guardian.IntentGrant{ + Approver: approver, + Extension: acs.IntentExtension{Capabilities: []acs.Capability{{Tool: "curl"}}, Scope: acs.ScopeSession, + Provenance: &acs.Provenance{ProvenanceID: "grant-1", Origin: "user_input", SourceID: &source}}, + AskStepID: asked.RequestID, + } + withGrant := func(g guardian.IntentGrant) { + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}, Grant: &g}, nil + }) + } + + // A grant must answer an ASK this session was given. + notAsked := grant + notAsked.AskStepID = allowed.RequestID + withGrant(notAsked) + if r := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonGrantInvalid) { + t.Fatalf("a grant for a step that was not asked got %+v", r) + } + + invalidScope := grant + invalidScope.Extension.Scope = "future" + withGrant(invalidScope) + if r := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")); r.Disposition != acs.Deny || !slices.Contains(r.ReasonCodes, disposition.ReasonGrantInvalid) { + t.Fatalf("a grant with an undefined scope got %+v", r) + } + beforeGrant, _, _ := h.store.Load(context.Background(), c.SessionID) + if beforeGrant.Intent != nil { + t.Fatalf("an invalid grant changed the Intent to %+v", beforeGrant.Intent) + } + + withGrant(grant) + r := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")) + sess, _, _ := h.store.Load(context.Background(), c.SessionID) + last := sess.Entries[len(sess.Entries)-1] + if last.StepType != acs.StepTypeIntentExtension || last.StepID != grant.AskStepID || r.ChainHash != last.EntryHash { + t.Fatalf("intent_extension entry %+v, published head %s", last, r.ChainHash) + } + // §9.1: the entry records the approver, the capabilities and the + // extension's own provenance, and its hash commits to them. + if last.Approver == nil || last.Approver.ID != "reviewer" || last.IntentExtension == nil || + len(last.IntentExtension.Capabilities) != 1 || last.IntentExtension.Provenance == nil || last.IntentExtension.Provenance.ProvenanceID != "grant-1" { + t.Fatalf("intent_extension entry records %+v", last) + } + if recomputed, err := chain.EntryHash(last); err != nil || recomputed != last.EntryHash { + t.Fatalf("the entry's hash does not commit to its grant: %s, %v", recomputed, err) + } + if sess.Intent == nil || len(sess.Intent.Parsed) != 1 || sess.Intent.Parsed[0].Tool != "curl" { + t.Fatalf("Intent %+v", sess.Intent) + } +} + +func TestIntentExtensionPreservesPostCompactLineage(t *testing.T) { + h := newHarness(t) + c := h.session() + approver := acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"} + h.engine.answer(acs.Decision{Disposition: acs.Ask, Reasoning: "approval required", AskDetails: &acs.AskDetails{ + Approver: approver, Question: "allow curl?", TimeoutSeconds: 60, + }}) + asked := h.decision(c, acs.StepToolCallRequest, withProvenance(toolCall("curl x"), "p1")) + source := "reviewer" + h.engine.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{ + Decision: acs.Decision{Disposition: acs.Allow}, + Grant: &guardian.IntentGrant{ + Approver: approver, + Extension: acs.IntentExtension{ + Capabilities: []acs.Capability{{Tool: "curl"}}, + Scope: acs.ScopeSession, + Provenance: &acs.Provenance{ProvenanceID: "grant-1", Origin: "user_input", SourceID: &source}, + }, + AskStepID: asked.RequestID, + }, + }, nil + }) + granted := h.decision(c, acs.StepToolCallRequest, toolCall("curl x")) + h.engine.answer(acs.Decision{Disposition: acs.Allow}) + compacted := h.decision(c, acs.StepPostCompact, map[string]any{ + "summary": map[string]any{ + "value": "sum", + "provenance": map[string]any{ + "provenance_id": "summary-1", + "origin": "agent_generated", + "derived_from": []any{"grant-1", "p1"}, + }, + }, + "entries_compacted": []any{asked.RequestID}, + "pre_compact_chain_hash": granted.ChainHash, + "post_compact_chain_hash": hash64, + }) + if compacted.Disposition != acs.Allow || slices.Contains(compacted.ReasonCodes, disposition.ReasonLineageMismatch) { + t.Fatalf("a faithful summary after an intent extension got %+v", compacted) + } + missingGrant := h.decision(c, acs.StepPostCompact, map[string]any{ + "summary": map[string]any{ + "value": "sum", + "provenance": map[string]any{ + "provenance_id": "summary-2", + "origin": "agent_generated", + "derived_from": []any{"p1"}, + }, + }, + "entries_compacted": []any{asked.RequestID}, + "pre_compact_chain_hash": compacted.ChainHash, + "post_compact_chain_hash": hash64, + }) + if missingGrant.Disposition != acs.Allow || !slices.Contains(missingGrant.ReasonCodes, disposition.ReasonLineageMismatch) { + t.Fatalf("a summary missing intent extension provenance got %+v", missingGrant) + } +} + +func TestSessionsRunInParallel(t *testing.T) { + h := newHarness(t) + release := make(chan struct{}) + h.engine.set(func(ctx context.Context, in guardian.PolicyInput) (guardian.PolicyDecision, error) { + if in.Request.Method == acs.StepUserMessage { + <-release + } + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}}, nil + }) + a, b := h.session(), h.session() + done := make(chan struct{}) + go func() { + h.decision(a, acs.StepUserMessage, hookPayloads[acs.StepUserMessage]) + close(done) + }() + h.decision(b, acs.StepToolCallRequest, toolCall("ls")) + close(release) + <-done +} + +func TestShutdownAnswersInFlightSteps(t *testing.T) { + h := newHarness(t) + c := h.session() + started := make(chan struct{}) + h.engine.set(func(ctx context.Context, _ guardian.PolicyInput) (guardian.PolicyDecision, error) { + close(started) + <-ctx.Done() + return guardian.PolicyDecision{}, ctx.Err() + }) + answered := make(chan *acs.Result) + go func() { answered <- h.decision(c, acs.StepToolCallRequest, toolCall("ls")) }() + <-started + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := h.g.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Shutdown: %v", err) + } + if r := <-answered; r.Disposition != acs.Deny { + t.Fatalf("the in-flight step got %+v", r) + } + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls")}) + if o.Error == nil { + t.Fatalf("a step after Shutdown got %s", o.Raw) + } +} + +func TestAuditLogReceivesEveryEnvelope(t *testing.T) { + h := newHarness(t) + c := h.session() + h.decision(c, acs.StepToolCallRequest, toolCall("ls")) + _, _ = h.post(context.Background(), []byte("not json")) + h.audit.mu.Lock() + defer h.audit.mu.Unlock() + var directions []guardian.Direction + for _, e := range h.audit.envelopes { + directions = append(directions, e.Direction) + } + want := []guardian.Direction{guardian.Inbound, guardian.Outbound, guardian.Inbound, guardian.Outbound, guardian.Outbound} + if !slices.Equal(directions, want) { + t.Fatalf("recorded %v, want %v", directions, want) + } +} + +func TestNewRefusesIncompleteConfig(t *testing.T) { + signer, _ := guardian.NewHMACSigner(guardian.HMACKey{ID: "k", Secret: ikm}) + engine := &testEngine{} + badPolicy := &testEngine{policy: guardian.PolicyDescription{ApproverTypes: []acs.ApproverType{"unknown"}}} + for name, cfg := range map[string]guardian.Config{ + "no_engine": {Signer: signer}, + "no_signer": {Engine: engine}, + "unknown_posture": {Engine: engine, Signer: signer, OnDecisionFailure: "maybe"}, + "unknown_transport": {Engine: engine, Signer: signer, Transport: "grpc"}, + "unknown_ask_substitution": {Engine: engine, Signer: signer, AskSubstitution: "ask"}, + "negative_window": {Engine: engine, Signer: signer, SkewWindow: -time.Second}, + "retention_one_window": {Engine: engine, Signer: signer, SkewWindow: time.Minute, SessionRetention: time.Minute}, + "window_too_large": {Engine: engine, Signer: signer, SkewWindow: time.Duration(1<<63 - 1), SessionRetention: time.Duration(1<<63 - 1)}, + "invalid_policy_descriptor": {Engine: badPolicy, Signer: signer}, + } { + if _, err := guardian.New(cfg); err == nil { + t.Errorf("%s: accepted", name) + } + } + if _, err := guardian.New(guardian.Config{Engine: engine, Signer: signer, SkewWindow: time.Minute, SessionRetention: 2 * time.Minute}); err != nil { + t.Fatalf("retention of two skew windows: %v", err) + } + if _, err := guardian.New(guardian.Config{Engine: engine, Signer: signerWithAlgorithms{Signer: signer}}); err == nil { + t.Fatal("accepted a signer that declares no algorithms") + } + if _, err := guardian.New(guardian.Config{Engine: panickingPolicyDescription{PolicyEngine: engine}, Signer: signer}); err == nil { + t.Fatal("accepted an engine whose Policy method panics") + } + if _, err := guardian.New(guardian.Config{Engine: engine, Signer: panickingAlgorithms{Signer: signer}}); err == nil { + t.Fatal("accepted a signer whose Algorithms method panics") + } +} + +type signerWithAlgorithms struct { + guardian.Signer +} + +func (signerWithAlgorithms) Algorithms() []string { return nil } + +type panickingPolicyDescription struct { + guardian.PolicyEngine +} + +func (panickingPolicyDescription) Policy() guardian.PolicyDescription { panic("policy") } + +type panickingAlgorithms struct { + guardian.Signer +} + +func (panickingAlgorithms) Algorithms() []string { panic("algorithms") } + +func TestConfiguredAskSubstitution(t *testing.T) { + approver := acs.Approver{Type: acs.ApproverHuman, ID: "reviewer"} + ask := acs.Decision{Disposition: acs.Ask, Reasoning: "approval required", AskDetails: &acs.AskDetails{Approver: approver, Question: "allow?", TimeoutSeconds: 30}} + for _, tt := range []struct { + name, substitution string + want acs.Disposition + }{ + {"none", string(guardian.AskSubstitutionNone), acs.Ask}, + {"deny", string(guardian.AskSubstitutionDeny), acs.Deny}, + {"defer", string(guardian.AskSubstitutionDefer), acs.Defer}, + } { + t.Run(tt.name, func(t *testing.T) { + h := newHarness(t, func(c *guardian.Config) { c.AskSubstitution = guardian.AskSubstitution(tt.substitution) }) + h.engine.answer(ask) + r := h.decision(h.session(), acs.StepToolCallRequest, toolCall("deploy")) + if r.Disposition != tt.want { + t.Fatalf("got %+v, want %s", r, tt.want) + } + }) + } +} + +// Wrapped MCP is governed by the Guardian New returns: a request and its +// response each get a signed decision and a chain entry, the engine sees the +// message intact, and a malformed message is Invalid params. +func TestWrappedMCP(t *testing.T) { + h := newHarness(t) + c := h.client(observedagent.NewUUID()) + const call, explicit = "protocols/MCP/tools/call", "wrapped:mcp-2025-06-18/tools/call" + hello := observedagent.DefaultHello(append(method.Hooks(), call, explicit)...) + hello.WrappedProtocols = []acs.WrappedProtocol{{Protocol: "MCP", Version: "2025-06-18"}} + if o, err := c.Handshake(context.Background(), hello); err != nil || o.Hello == nil { + t.Fatalf("handshake: %v %s", err, o.Raw) + } + request := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "get_weather", "arguments": map[string]any{"city": "Barcelona"}}} + response := map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []any{map[string]any{"type": "text", "text": "sunny"}}}} + for _, m := range []string{call, explicit} { + for _, payload := range []any{request, response} { + if r := h.decision(c, m, payload); r.Disposition != acs.Allow || r.ChainHash == "" { + t.Fatalf("%s got %+v", m, r) + } + var got, want any + _ = jsonv2.Unmarshal(h.engine.last().Request.Params.Payload, &got) + _ = jsonv2.Unmarshal(mustJSON(payload), &want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("the engine saw %v, want %v", got, want) + } + } + } + h.refusal(c, observedagent.Request{Method: call, Payload: map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}}, acs.InvalidParams) +} + +func TestWrappedMCPRefusesOverlappingModifications(t *testing.T) { + for _, methodName := range []string{"protocols/MCP/tools/call", "wrapped:mcp-2025-06-18/tools/call"} { + for _, path := range []string{ + "/params/arguments/command", + "/params/arguments", + "/params/arguments/command/value", + } { + t.Run(methodName+path, func(t *testing.T) { + h := newHarness(t) + c := h.client(observedagent.NewUUID()) + hello := observedagent.DefaultHello(methodName) + hello.WrappedProtocols = []acs.WrappedProtocol{{Protocol: "MCP", Version: "2025-06-18"}} + if out, err := c.Handshake(context.Background(), hello); err != nil || out.Hello == nil { + t.Fatalf("handshake: %v %s", err, out.Raw) + } + h.engine.answer(acs.Decision{ + Disposition: acs.Modify, + Reasoning: "conflicting changes", + Modifications: &acs.Modifications{ + Redactions: []acs.Redaction{{Path: path}}, + ParameterOverrides: map[string]json.RawMessage{"command": json.RawMessage(`"changed"`)}, + }, + }) + payload := json.RawMessage(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"Bash","arguments":{"command":"echo hello"}}}`) + result := h.decision(c, methodName, payload) + if result.Disposition != acs.Deny || !slices.Contains(result.Decision.ReasonCodes, disposition.ReasonEvaluationFailed) { + t.Fatalf("overlapping edits got %+v", result.Decision) + } + }) + } + } +} + +// TestUnresolvedRequestIDRefIsRecorded: a toolCallResult whose +// request_id_ref names no toolCallRequest of the session is recorded, and one +// that names the session's request is not; the decision stays the policy's +// (hooks.md, toolCallResult). +func TestUnresolvedRequestIDRefIsRecorded(t *testing.T) { + h := newHarness(t) + c := h.session() + const requestID = "7d3c1e9a-2b4f-4a6d-9c8e-1f0a3b5c7d9e" + o := h.send(c, observedagent.Request{Method: acs.StepToolCallRequest, Payload: toolCall("ls"), RequestID: requestID}) + if o.Result == nil { + t.Fatalf("the request got %s", o.Raw) + } + result := func(ref string) acs.ToolCallResultPayload { + return acs.ToolCallResultPayload{Tool: acs.Tool{Name: "Bash"}, RequestIDRef: &ref, ExitStatus: "success", Outputs: []acs.ToolOutput{{Value: json.RawMessage(`"ok"`)}}} + } + h.decision(c, acs.StepToolCallResult, result(requestID)) + if slices.Contains(h.audit.eventKinds(), guardian.EventRequestIDRefUnresolved) { + t.Fatal("a reference to the session's own request was recorded as unresolved") + } + if r := h.decision(c, acs.StepToolCallResult, result("0e9d8c7b-6a5f-4e3d-8c2b-1a0f9e8d7c6b")); r.Disposition != acs.Allow { + t.Fatalf("the decision on an unresolved reference changed: %+v", r) + } + if !slices.Contains(h.audit.eventKinds(), guardian.EventRequestIDRefUnresolved) { + t.Fatal("an unresolved request_id_ref was not recorded") + } +} diff --git a/reference-implementations/agt-go/guardian/guardiantest/store.go b/reference-implementations/agt-go/guardian/guardiantest/store.go new file mode 100644 index 00000000..e3fc88b5 --- /dev/null +++ b/reference-implementations/agt-go/guardian/guardiantest/store.go @@ -0,0 +1,469 @@ +// Package guardiantest checks implementations of the guardian interfaces +// against their contracts, so a host with its own implementation proves the +// same properties the defaults are tested for. +package guardiantest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +// TestSessionContextStore runs the SessionContextStore contract against +// stores built by newStore, one fresh store per case. The store must hold at +// least two sessions. +func TestSessionContextStore(t *testing.T, newStore func(t *testing.T) guardian.SessionContextStore) { + t.Run("negotiate_then_load", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + s, ok, err := store.Load(ctx, "s1") + if err != nil || !ok { + t.Fatalf("Load: ok=%v err=%v", ok, err) + } + if s.Handshake.Server.NegotiatedVersion != acs.Version || s.Head != "" || len(s.Entries) != 0 || s.Closed { + t.Fatalf("fresh session is %+v", s) + } + }) + + t.Run("load_unknown_session", func(t *testing.T) { + if _, ok, err := newStore(t).Load(context.Background(), "nobody"); ok || err != nil { + t.Fatalf("Load: ok=%v err=%v, want false and no error", ok, err) + } + }) + + t.Run("negotiate_twice", func(t *testing.T) { + store := newStore(t) + negotiate(t, store, "s1", "r0") + err := store.Negotiate(context.Background(), "s1", guardian.Reservation{RequestID: "r1"}, handshake()) + if !errors.Is(err, guardian.ErrAlreadyNegotiated) { + t.Fatalf("second Negotiate: %v, want ErrAlreadyNegotiated", err) + } + }) + + t.Run("append_moves_head", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + intent := &acs.Intent{Parsed: []acs.Capability{{Tool: "Bash"}}} + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, ""), Intent: intent}) + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r2"}, ExpectedHead: digest(1), Entry: entry(2, digest(1)), Close: true}) + s, _, err := store.Load(ctx, "s1") + if err != nil { + t.Fatal(err) + } + if s.Head != digest(2) || len(s.Entries) != 2 || s.Entries[0].EntryHash != digest(1) || s.Entries[1].EntryHash != digest(2) { + t.Fatalf("head %s, entries %+v", s.Head, s.Entries) + } + if s.Intent == nil || len(s.Intent.Parsed) != 1 || !s.Closed { + t.Fatalf("intent %+v closed %v", s.Intent, s.Closed) + } + }) + + t.Run("replayed_request_id_changes_nothing", func(t *testing.T) { + store := newStore(t) + negotiate(t, store, "s1", "r0") + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + for _, id := range []string{"r0", "r1"} { + err := store.Append(context.Background(), "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: id}, ExpectedHead: digest(1), Entry: entry(2, digest(1))}) + if !errors.Is(err, guardian.ErrReplayDetected) { + t.Fatalf("request_id %s: %v, want ErrReplayDetected", id, err) + } + } + requireHead(t, store, "s1", digest(1), 1) + }) + + t.Run("replayed_nonce_changes_nothing", func(t *testing.T) { + store := newStore(t) + if err := store.Negotiate(context.Background(), "s1", guardian.Reservation{RequestID: "r0", Nonce: "n0"}, handshake()); err != nil { + t.Fatal(err) + } + err := store.Append(context.Background(), "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1", Nonce: "n0"}, Entry: entry(1, "")}) + if !errors.Is(err, guardian.ErrReplayDetected) { + t.Fatalf("%v, want ErrReplayDetected", err) + } + requireHead(t, store, "s1", "", 0) + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1", Nonce: "n1"}, Entry: entry(1, "")}) + }) + + t.Run("moved_head_changes_nothing", func(t *testing.T) { + store := newStore(t) + negotiate(t, store, "s1", "r0") + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + stale := guardian.Append{Reservation: guardian.Reservation{RequestID: "r2", Nonce: "n2"}, Entry: entry(9, "")} + if err := store.Append(context.Background(), "s1", stale); !errors.Is(err, guardian.ErrHeadMoved) { + t.Fatalf("%v, want ErrHeadMoved", err) + } + requireHead(t, store, "s1", digest(1), 1) + // Neither the request_id nor the nonce was reserved by the refused append. + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r2", Nonce: "n2"}, ExpectedHead: digest(1), Entry: entry(2, digest(1))}) + }) + + t.Run("append_to_unknown_session", func(t *testing.T) { + err := newStore(t).Append(context.Background(), "nobody", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + if !errors.Is(err, guardian.ErrHeadMoved) { + t.Fatalf("%v, want ErrHeadMoved", err) + } + }) + + t.Run("sessions_are_separate", func(t *testing.T) { + store := newStore(t) + negotiate(t, store, "s1", "r0") + negotiate(t, store, "s2", "r0") + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + appendOK(t, store, "s2", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + requireHead(t, store, "s1", digest(1), 1) + requireHead(t, store, "s2", digest(1), 1) + }) + + t.Run("conclude_records_the_decision", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + for i := range 3 { + if err := store.Conclude(ctx, "s1", guardian.Conclusion{Deferred: true, AskStepID: fmt.Sprintf("ask-%d", i)}); err != nil { + t.Fatal(err) + } + } + if err := store.Conclude(ctx, "s1", guardian.Conclusion{PolicyState: json.RawMessage(`{"labels":["secret"]}`)}); err != nil { + t.Fatal(err) + } + if err := store.Conclude(ctx, "s1", guardian.Conclusion{}); err != nil { + t.Fatal(err) + } + s, _, err := store.Load(ctx, "s1") + if err != nil { + t.Fatal(err) + } + if s.Deferrals != 3 || !slices.Equal(s.AskSteps, []string{"ask-0", "ask-1", "ask-2"}) || string(s.PolicyState) != `{"labels":["secret"]}` { + t.Fatalf("deferrals %d, ask steps %v, policy state %s", s.Deferrals, s.AskSteps, s.PolicyState) + } + if err := store.Conclude(ctx, "nobody", guardian.Conclusion{Deferred: true}); !errors.Is(err, guardian.ErrSessionNotFound) { + t.Fatalf("%v, want ErrSessionNotFound", err) + } + }) + + t.Run("replayed_handshake", func(t *testing.T) { + store := newStore(t) + negotiate(t, store, "s1", "r0") + err := store.Negotiate(context.Background(), "s1", guardian.Reservation{RequestID: "r0"}, handshake()) + if !errors.Is(err, guardian.ErrReplayDetected) { + t.Fatalf("%v, want ErrReplayDetected", err) + } + }) + + t.Run("reserve_without_entry", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + if err := store.Reserve(ctx, "s1", guardian.Reservation{RequestID: "r1", Nonce: "n1"}); err != nil { + t.Fatal(err) + } + for _, r := range []guardian.Reservation{{RequestID: "r0"}, {RequestID: "r1"}, {RequestID: "r2", Nonce: "n1"}} { + if err := store.Reserve(ctx, "s1", r); !errors.Is(err, guardian.ErrReplayDetected) { + t.Fatalf("Reserve %+v: %v, want ErrReplayDetected", r, err) + } + } + err := store.Append(ctx, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + if !errors.Is(err, guardian.ErrReplayDetected) { + t.Fatalf("Append after Reserve: %v, want ErrReplayDetected", err) + } + requireHead(t, store, "s1", "", 0) + if err := store.Reserve(ctx, "nobody", guardian.Reservation{RequestID: "r1"}); !errors.Is(err, guardian.ErrSessionNotFound) { + t.Fatalf("%v, want ErrSessionNotFound", err) + } + }) + + t.Run("lineage_is_kept_by_step", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, ""), Lineage: []string{"p1", "p2"}}) + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r2"}, ExpectedHead: digest(1), Entry: entry(2, digest(1))}) + s, _, err := store.Load(ctx, "s1") + if err != nil { + t.Fatal(err) + } + if !slices.Equal(s.Lineage["step-1"], []string{"p1", "p2"}) { + t.Fatalf("lineage %v", s.Lineage) + } + if empty, ok := s.Lineage["step-2"]; !ok || len(empty) != 0 { + t.Fatalf("empty lineage is not retained: %v", s.Lineage) + } + }) + + t.Run("repeated_step_id_keeps_all_lineage", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, ""), Lineage: []string{"p1", "p2"}}) + extension := entry(2, digest(1)) + extension.StepID = "step-1" + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r2"}, ExpectedHead: digest(1), Entry: extension, Lineage: []string{"p2", "p3"}}) + s, _, err := store.Load(ctx, "s1") + if err != nil { + t.Fatal(err) + } + lineage := slices.Clone(s.Lineage["step-1"]) + slices.Sort(lineage) + if !slices.Equal(lineage, []string{"p1", "p2", "p3"}) { + t.Fatalf("lineage %v", s.Lineage) + } + }) + + t.Run("skill_approvals_span_sessions", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + approved := guardian.SkillApproval{SkillID: "skill-a", DigestAlgorithm: "sha-256", DigestValue: "aa"} + if ok, err := store.SkillApproved(ctx, approved); ok || err != nil { + t.Fatalf("before approval: %v, %v", ok, err) + } + for range 2 { + if err := store.Conclude(ctx, "s1", guardian.Conclusion{ApproveSkill: &approved}); err != nil { + t.Fatalf("approving: %v", err) + } + } + if ok, err := store.SkillApproved(ctx, approved); !ok || err != nil { + t.Fatalf("after approval: %v, %v", ok, err) + } + other := approved + other.DigestValue = "bb" + if ok, _ := store.SkillApproved(ctx, other); ok { + t.Fatal("another digest of the same skill is approved") + } + }) + + // A holder whose lock was lost concludes against a head another holder + // has moved: nothing it carries is recorded. + t.Run("stale_conclusion_changes_nothing", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + approved := guardian.SkillApproval{SkillID: "skill-a", DigestAlgorithm: "sha-256", DigestValue: "aa"} + stale := guardian.Conclusion{ExpectedHead: "", Deferred: true, AskStepID: "a", PolicyState: json.RawMessage(`{}`), ApproveSkill: &approved} + if err := store.Conclude(ctx, "s1", stale); !errors.Is(err, guardian.ErrHeadMoved) { + t.Fatalf("%v, want ErrHeadMoved", err) + } + s, _, _ := store.Load(ctx, "s1") + if s.Deferrals != 0 || len(s.AskSteps) != 0 || s.PolicyState != nil { + t.Fatalf("a stale conclusion changed the session: %+v", s) + } + if ok, _ := store.SkillApproved(ctx, approved); ok { + t.Fatal("a stale conclusion approved a skill") + } + }) + + t.Run("loaded_session_is_a_copy", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + intent := &acs.Intent{Parsed: []acs.Capability{{Tool: "Bash"}}} + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, ""), Intent: intent, Lineage: []string{"p1"}}) + if err := store.Conclude(ctx, "s1", guardian.Conclusion{ExpectedHead: digest(1), AskStepID: "ask", PolicyState: json.RawMessage(`{"a":1}`)}); err != nil { + t.Fatal(err) + } + // The caller's own values are not kept by reference either. + intent.Parsed[0].Tool = "changed by the caller" + s, _, err := store.Load(ctx, "s1") + if err != nil { + t.Fatal(err) + } + s.Entries[0].StepID = "changed" + s.Intent.Parsed[0].Tool = "changed" + s.Handshake.Server.NegotiatedVersion = "changed" + s.AskSteps[0] = "changed" + s.Lineage["step-1"][0] = "changed" + s.PolicyState[0] = 'x' + again, _, err := store.Load(ctx, "s1") + if err != nil { + t.Fatal(err) + } + if again.Entries[0].StepID != "step-1" || again.Intent.Parsed[0].Tool != "Bash" || again.Handshake.Server.NegotiatedVersion != acs.Version || + again.AskSteps[0] != "ask" || again.Lineage["step-1"][0] != "p1" || string(again.PolicyState) != `{"a":1}` { + t.Fatalf("a change to a loaded session reached the store: %+v", again) + } + }) + + t.Run("lock_excludes", func(t *testing.T) { + store := newStore(t) + lockCtx, release, err := store.Lock(context.Background(), "s1") + if err != nil { + t.Fatal(err) + } + if lockCtx == nil { + t.Fatal("Lock returned a nil context") + } + release() + select { + case <-lockCtx.Done(): + case <-time.After(time.Second): + t.Fatal("the lock context remained live after release") + } + if !errors.Is(context.Cause(lockCtx), context.Canceled) { + t.Fatalf("the released lock context ended with %v", context.Cause(lockCtx)) + } + + parent, cancelParent := context.WithCancel(context.Background()) + parentCtx, releaseParent, err := store.Lock(parent, "parent") + if err != nil { + t.Fatal(err) + } + cancelParent() + select { + case <-parentCtx.Done(): + case <-time.After(time.Second): + t.Fatal("the lock context remained live after its parent ended") + } + releaseParent() + + _, release, err = store.Lock(context.Background(), "s1") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if _, _, err := store.Lock(ctx, "s1"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("second Lock of a held session: %v, want the context's deadline", err) + } + _, other, err := store.Lock(context.Background(), "s2") + if err != nil { + t.Fatalf("another session's lock: %v", err) + } + other() + release() + _, again, err := store.Lock(context.Background(), "s1") + if err != nil { + t.Fatalf("Lock after release: %v", err) + } + again() + }) + + // Holders of one session's lock never overlap, however many wait. + t.Run("lock_serialises", func(t *testing.T) { + store := newStore(t) + var inside, overlaps atomic.Int32 + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + _, release, err := store.Lock(context.Background(), "s1") + if err != nil { + t.Error(err) + return + } + if inside.Add(1) > 1 { + overlaps.Add(1) + } + time.Sleep(time.Millisecond) + inside.Add(-1) + release() + }) + } + wg.Wait() + if overlaps.Load() != 0 { + t.Fatalf("%d holders overlapped", overlaps.Load()) + } + }) + + t.Run("loaded_entries_do_not_change", func(t *testing.T) { + store, ctx := newStore(t), context.Background() + negotiate(t, store, "s1", "r0") + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: entry(1, "")}) + before, _, err := store.Load(ctx, "s1") + if err != nil { + t.Fatal(err) + } + appendOK(t, store, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r2"}, ExpectedHead: digest(1), Entry: entry(2, digest(1))}) + if len(before.Entries) != 1 || before.Head != digest(1) { + t.Fatalf("an earlier Load changed to %+v", before) + } + }) + + // Concurrent appends against one head: exactly one wins, the rest see + // ErrHeadMoved, which is what keeps two processes from extending one + // chain twice. + t.Run("one_append_wins_a_race", func(t *testing.T) { + store := newStore(t) + negotiate(t, store, "s1", "r0") + const racers = 16 + var wg sync.WaitGroup + errs := make([]error, racers) + for i := range racers { + wg.Go(func() { + errs[i] = store.Append(context.Background(), "s1", guardian.Append{ + Reservation: guardian.Reservation{RequestID: fmt.Sprintf("race-%d", i)}, + Entry: entry(100+i, ""), + }) + }) + } + wg.Wait() + won := 0 + for _, err := range errs { + switch { + case err == nil: + won++ + case !errors.Is(err, guardian.ErrHeadMoved): + t.Errorf("racer failed with %v, want ErrHeadMoved", err) + } + } + if won != 1 { + t.Fatalf("%d appends won, want 1", won) + } + s, _, _ := store.Load(context.Background(), "s1") + if len(s.Entries) != 1 { + t.Fatalf("%d entries after the race, want 1", len(s.Entries)) + } + }) +} + +func handshake() guardian.Handshake { + return guardian.Handshake{ + Client: acs.ClientHello{ACSVersionsSupported: []string{acs.Version}, ProvenanceProducer: acs.ProvenanceNone}, + Server: acs.ServerHello{NegotiatedVersion: acs.Version, OnDecisionFailure: acs.FailureProceed}, + } +} + +func negotiate(t *testing.T, store guardian.SessionContextStore, sessionID, requestID string) { + t.Helper() + if err := store.Negotiate(context.Background(), sessionID, guardian.Reservation{RequestID: requestID}, handshake()); err != nil { + t.Fatalf("Negotiate: %v", err) + } +} + +func appendOK(t *testing.T, store guardian.SessionContextStore, sessionID string, a guardian.Append) { + t.Helper() + if err := store.Append(context.Background(), sessionID, a); err != nil { + t.Fatalf("Append %s: %v", a.RequestID, err) + } +} + +func requireHead(t *testing.T, store guardian.SessionContextStore, sessionID, head string, entries int) { + t.Helper() + s, ok, err := store.Load(context.Background(), sessionID) + if err != nil || !ok { + t.Fatalf("Load: ok=%v err=%v", ok, err) + } + if s.Head != head || len(s.Entries) != entries { + t.Fatalf("head %q with %d entries, want %q with %d", s.Head, len(s.Entries), head, entries) + } +} + +// digest is a stand-in entry_hash: the store keeps digests, it never +// computes them. +func digest(n int) string { + return fmt.Sprintf("%064x", n) +} + +func entry(n int, previous string) acs.ContextEntry { + e := acs.ContextEntry{ + EntryID: fmt.Sprint(n), + StepID: fmt.Sprintf("step-%d", n), + StepType: acs.StepToolCallRequest, + EntryHash: digest(n), + } + if previous != "" { + e.PreviousHash = &previous + } + return e +} diff --git a/reference-implementations/agt-go/guardian/harness_test.go b/reference-implementations/agt-go/guardian/harness_test.go new file mode 100644 index 00000000..44a70918 --- /dev/null +++ b/reference-implementations/agt-go/guardian/harness_test.go @@ -0,0 +1,233 @@ +package guardian_test + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" +) + +// testEngine answers with a function each test chooses and records every +// input it was given. +type testEngine struct { + policy guardian.PolicyDescription + decide func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) + + mu sync.Mutex + calls []guardian.PolicyInput +} + +func (e *testEngine) Policy() guardian.PolicyDescription { return e.policy } + +func (e *testEngine) Decide(ctx context.Context, in guardian.PolicyInput) (guardian.PolicyDecision, error) { + e.mu.Lock() + e.calls = append(e.calls, in) + decide := e.decide + e.mu.Unlock() + if decide == nil { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}}, nil + } + return decide(ctx, in) +} + +func (e *testEngine) set(decide func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error)) { + e.mu.Lock() + e.decide = decide + e.mu.Unlock() +} + +func (e *testEngine) answer(d acs.Decision) { + e.set(func(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: d}, nil + }) +} + +func (e *testEngine) callCount() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.calls) +} + +func (e *testEngine) last() guardian.PolicyInput { + e.mu.Lock() + defer e.mu.Unlock() + return e.calls[len(e.calls)-1] +} + +// auditRecorder is an AuditLog that keeps everything. +type auditRecorder struct { + mu sync.Mutex + envelopes []guardian.EnvelopeRecord + events []guardian.AuditEvent +} + +func (a *auditRecorder) Envelope(_ context.Context, r guardian.EnvelopeRecord) { + a.mu.Lock() + a.envelopes = append(a.envelopes, r) + a.mu.Unlock() +} + +func (a *auditRecorder) Event(_ context.Context, e guardian.AuditEvent) { + a.mu.Lock() + a.events = append(a.events, e) + a.mu.Unlock() +} + +func (a *auditRecorder) eventKinds() []guardian.AuditEventKind { + a.mu.Lock() + defer a.mu.Unlock() + var kinds []guardian.AuditEventKind + for _, e := range a.events { + kinds = append(kinds, e.Kind) + } + return kinds +} + +// ikm is the deployment keying material every test shares. +var ikm = []byte("0123456789abcdef0123456789abcdef") + +type harness struct { + t *testing.T + g *guardian.Guardian + store *guardian.MemorySessionContextStore + engine *testEngine + audit *auditRecorder + signer *guardian.HMACSigner + server *httptest.Server + now time.Time +} + +type option func(*guardian.Config) + +func newHarness(t *testing.T, opts ...option) *harness { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k1", Secret: ikm}) + if err != nil { + t.Fatal(err) + } + store, err := guardian.NewMemorySessionContextStore(guardian.MemoryStoreLimits{MaxSessions: 64, MaxEntries: 1024, MaxReservations: 2048, MaxSkillApprovals: 64, Retention: time.Hour}) + if err != nil { + t.Fatal(err) + } + h := &harness{ + t: t, store: store, signer: signer, + engine: &testEngine{policy: guardian.PolicyDescription{ApproverTypes: []acs.ApproverType{acs.ApproverHuman}}}, + audit: &auditRecorder{}, + now: time.Now(), + } + cfg := guardian.Config{Engine: h.engine, Signer: signer, Store: store, AuditLog: h.audit} + for _, o := range opts { + o(&cfg) + } + h.g, err = guardian.New(cfg) + if err != nil { + t.Fatal(err) + } + h.server = httptest.NewServer(h.g) + t.Cleanup(h.server.Close) + return h +} + +func (h *harness) post(ctx context.Context, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.server.URL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + resp, err := h.server.Client().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +func (h *harness) client(sessionID string) *observedagent.Client { + return &observedagent.Client{Transport: h.post, Signer: h.signer, AgentID: "agent", SessionID: sessionID} +} + +// session returns a client whose session is negotiated for every native +// hook. +func (h *harness) session() *observedagent.Client { + h.t.Helper() + c := h.client(observedagent.NewUUID()) + o, err := c.Handshake(context.Background(), observedagent.DefaultHello(append(method.Hooks(), acs.MethodSystemPing)...)) + if err != nil { + h.t.Fatal(err) + } + if o.Hello == nil { + h.t.Fatalf("handshake refused: %s", o.Raw) + } + return c +} + +// send sends one request and fails the test on a transport error. +func (h *harness) send(c *observedagent.Client, r observedagent.Request) observedagent.Outcome { + h.t.Helper() + o, err := c.Send(context.Background(), r) + if err != nil { + h.t.Fatal(err) + } + return o +} + +// decision sends a step and requires a signed decision back. +func (h *harness) decision(c *observedagent.Client, methodName string, payload any) *acs.Result { + h.t.Helper() + o := h.send(c, observedagent.Request{Method: methodName, Payload: payload}) + if o.Result == nil { + h.t.Fatalf("%s: no decision: %s", methodName, o.Raw) + } + if !o.Verified { + h.t.Fatalf("%s: the decision's signature does not verify: %s", methodName, o.Raw) + } + if err := verifyResponse(o.Raw, ikm, c.SessionID); err != nil && !strings.Contains(err.Error(), "integers only") { + h.t.Fatalf("%s: the independent verifier refuses the decision: %v", methodName, err) + } + return o.Result +} + +// refusal sends a request and requires an error answer with code. An error +// answering a signed request that verifies must itself be signed and verify; +// one answering a request that does not authenticate must be unsigned. +func (h *harness) refusal(c *observedagent.Client, r observedagent.Request, code acs.ErrorCode) *acs.Error { + h.t.Helper() + o := h.send(c, r) + if o.Error == nil || o.Error.Code != code { + h.t.Fatalf("%s: got %s, want error %d", r.Method, o.Raw, code) + } + authenticated := !r.Unsigned && code != acs.SignatureInvalid + switch { + case authenticated && !o.Verified: + h.t.Fatalf("%s: the error answering an authenticated request is not signed: %s", r.Method, o.Raw) + case authenticated: + if err := verifyResponse(o.Raw, ikm, c.SessionID); err != nil && c.KeyID == "" { + h.t.Fatalf("%s: the independent verifier refuses the error: %v", r.Method, err) + } + case o.Error.Signature != nil: + h.t.Fatalf("%s: an error answering an unauthenticated request is signed: %s", r.Method, o.Raw) + } + return o.Error +} + +func toolCall(command string) acs.ToolCallRequestPayload { + value := []byte(`"` + command + `"`) + return acs.ToolCallRequestPayload{ + Tool: acs.Tool{Name: "Bash"}, + Arguments: map[string]acs.ToolArgument{"command": {Value: value}}, + RawCommand: &command, + } +} + +func deny(reason string) acs.Decision { + return acs.Decision{Disposition: acs.Deny, Reasoning: "denied: " + reason, ReasonCodes: []string{reason}} +} diff --git a/reference-implementations/agt-go/guardian/hmac_signer.go b/reference-implementations/agt-go/guardian/hmac_signer.go new file mode 100644 index 00000000..bb118130 --- /dev/null +++ b/reference-implementations/agt-go/guardian/hmac_signer.go @@ -0,0 +1,147 @@ +package guardian + +import ( + "context" + "crypto/hkdf" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// MinHMACSecretBytes is the shortest input keying material NewHMACSigner +// accepts: the output length of SHA-256, below which a guessed secret costs +// less than a guessed HMAC key (RFC 2104 §3). +const MinHMACSecretBytes = sha256.Size + +// DecodeHMACSecret decodes input keying material written as standard base64 +// (RFC 4648 §4), surrounding whitespace ignored, and requires at least +// MinHMACSecretBytes decoded bytes, the format #256 proposes for every +// reference implementation, so one secret file serves every +// Guardian and host adapter in a deployment. +func DecodeHMACSecret(encoded []byte) ([]byte, error) { + secret, err := base64.StdEncoding.Strict().DecodeString(strings.TrimSpace(string(encoded))) + if err != nil { + return nil, fmt.Errorf("the HMAC secret is not standard base64: %w", err) + } + if len(secret) < MinHMACSecretBytes { + return nil, fmt.Errorf("the HMAC secret decodes to %d bytes, fewer than %d", len(secret), MinHMACSecretBytes) + } + return secret, nil +} + +// ReadHMACSecretFile reads a file DecodeHMACSecret accepts. +func ReadHMACSecretFile(path string) ([]byte, error) { + encoded, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return DecodeHMACSecret(encoded) +} + +// HMACKey is deployment-provided input keying material and the key_id that +// names it on the wire. +type HMACKey struct { + ID string + Secret []byte +} + +// HMACSigner is the ACS-Core baseline Signer: HMAC-SHA256 under a +// per-session key derived with HKDF-SHA256 (RFC 5869) from the input keying +// material and the session identifier, as §10 prescribes. +// +// §10 leaves the HKDF parameters open. This signer uses no salt, the UTF-8 +// session_id as info and a 32-byte output; the signature value is standard +// base64 with padding (RFC 4648 §4). +type HMACSigner struct { + signing HMACKey // answers unsigned requests + byID map[string][]byte +} + +// NewHMACSigner returns a signer that verifies with any of its keys and +// answers with the key the request used, so a retired secret keeps working +// for the sessions that use it while its successor opens new ones. Sign uses +// the first key when keyID is empty. +func NewHMACSigner(keys ...HMACKey) (*HMACSigner, error) { + if len(keys) == 0 { + return nil, errors.New("an HMAC signer needs at least one key") + } + s := &HMACSigner{signing: keys[0], byID: map[string][]byte{}} + for _, k := range keys { + switch { + case k.ID == "": + return nil, errors.New("an HMAC key needs a key_id") + case len(k.Secret) < MinHMACSecretBytes: + return nil, fmt.Errorf("HMAC key %q has %d bytes of secret, fewer than %d", k.ID, len(k.Secret), MinHMACSecretBytes) + } + if _, dup := s.byID[k.ID]; dup { + return nil, fmt.Errorf("HMAC key_id %q is given twice", k.ID) + } + s.byID[k.ID] = append([]byte(nil), k.Secret...) + } + s.signing.Secret = s.byID[s.signing.ID] + return s, nil +} + +// Algorithms implements Signer. +func (s *HMACSigner) Algorithms() []string { + return []string{acs.SignatureAlgorithmHMACSHA256} +} + +// Verify implements Signer. +func (s *HMACSigner) Verify(_ context.Context, sessionID string, input []byte, sig acs.Signature) error { + if sig.Algorithm != acs.SignatureAlgorithmHMACSHA256 { + return fmt.Errorf("%w: algorithm %q is not supported", ErrSignatureInvalid, sig.Algorithm) + } + secret, ok := s.byID[sig.KeyID] + if !ok { + return fmt.Errorf("%w: unknown key_id %q", ErrSignatureInvalid, sig.KeyID) + } + got, err := base64.StdEncoding.Strict().DecodeString(sig.Value) + if err != nil { + return fmt.Errorf("%w: value is not base64", ErrSignatureInvalid) + } + want, err := mac(secret, sessionID, input) + if err != nil { + return err + } + if !hmac.Equal(got, want) { + return fmt.Errorf("%w: HMAC does not match", ErrSignatureInvalid) + } + return nil +} + +// Sign implements Signer. +func (s *HMACSigner) Sign(_ context.Context, sessionID, keyID string, input []byte) (acs.Signature, error) { + if keyID == "" { + keyID = s.signing.ID + } + secret, ok := s.byID[keyID] + if !ok { + return acs.Signature{}, fmt.Errorf("unknown key_id %q", keyID) + } + m, err := mac(secret, sessionID, input) + if err != nil { + return acs.Signature{}, err + } + return acs.Signature{ + Algorithm: acs.SignatureAlgorithmHMACSHA256, + Value: base64.StdEncoding.EncodeToString(m), + KeyID: keyID, + }, nil +} + +func mac(secret []byte, sessionID string, input []byte) ([]byte, error) { + key, err := hkdf.Key(sha256.New, secret, nil, sessionID, sha256.Size) + if err != nil { + return nil, fmt.Errorf("derive session key: %w", err) + } + h := hmac.New(sha256.New, key) + h.Write(input) + return h.Sum(nil), nil +} diff --git a/reference-implementations/agt-go/guardian/hmac_signer_test.go b/reference-implementations/agt-go/guardian/hmac_signer_test.go new file mode 100644 index 00000000..fd419074 --- /dev/null +++ b/reference-implementations/agt-go/guardian/hmac_signer_test.go @@ -0,0 +1,176 @@ +package guardian_test + +import ( + "context" + "encoding/hex" + "errors" + "os" + "strings" + "testing" + + "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +// signatureVectors reads the vectors computed in Python, independently of +// this module, for the §10 HMAC baseline. +type signatureVectors struct { + IKMHex string `json:"ikm_hex"` + KeyID string `json:"key_id"` + SessionID string `json:"session_id"` + Request struct { + Signed struct { + Params struct { + Signature acs.Signature `json:"signature"` + } `json:"params"` + } `json:"signed"` + SigningInput string `json:"signing_input"` + } `json:"request"` + Response struct { + Signed struct { + Result struct { + Signature acs.Signature `json:"signature"` + } `json:"result"` + } `json:"signed"` + SigningInput string `json:"signing_input"` + } `json:"response"` +} + +func loadSignatureVectors(t *testing.T) (signatureVectors, *guardian.HMACSigner) { + t.Helper() + b, err := os.ReadFile("../internal/envelope/testdata/vectors.json") + if err != nil { + t.Fatal(err) + } + var v signatureVectors + if err := json.Unmarshal(b, &v); err != nil { + t.Fatal(err) + } + ikm, err := hex.DecodeString(v.IKMHex) + if err != nil { + t.Fatal(err) + } + s, err := guardian.NewHMACSigner(guardian.HMACKey{ID: v.KeyID, Secret: ikm}) + if err != nil { + t.Fatal(err) + } + return v, s +} + +func TestHMACSignerVectors(t *testing.T) { + v, s := loadSignatureVectors(t) + ctx := context.Background() + if err := s.Verify(ctx, v.SessionID, []byte(v.Request.SigningInput), v.Request.Signed.Params.Signature); err != nil { + t.Fatalf("request vector does not verify: %v", err) + } + got, err := s.Sign(ctx, v.SessionID, v.KeyID, []byte(v.Response.SigningInput)) + if err != nil { + t.Fatal(err) + } + if got != v.Response.Signed.Result.Signature { + t.Fatalf("signed %+v, want %+v", got, v.Response.Signed.Result.Signature) + } +} + +func TestHMACSignerRefuses(t *testing.T) { + v, s := loadSignatureVectors(t) + ctx := context.Background() + input := []byte(v.Request.SigningInput) + good := v.Request.Signed.Params.Signature + tests := []struct { + name string + sessionID string + input []byte + sig func(acs.Signature) acs.Signature + }{ + {"other_session", "00000000-0000-4000-8000-000000000000", input, func(s acs.Signature) acs.Signature { return s }}, + {"changed_input", v.SessionID, append([]byte(" "), input...), func(s acs.Signature) acs.Signature { return s }}, + {"unknown_key_id", v.SessionID, input, func(s acs.Signature) acs.Signature { s.KeyID = "k9"; return s }}, + {"other_algorithm", v.SessionID, input, func(s acs.Signature) acs.Signature { s.Algorithm = "ECDSA-P256"; return s }}, + {"not_base64", v.SessionID, input, func(s acs.Signature) acs.Signature { s.Value = "***"; return s }}, + {"unpadded_base64", v.SessionID, input, func(s acs.Signature) acs.Signature { s.Value = strings.TrimRight(s.Value, "="); return s }}, + {"truncated", v.SessionID, input, func(s acs.Signature) acs.Signature { s.Value = s.Value[:8]; return s }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := s.Verify(ctx, tt.sessionID, tt.input, tt.sig(good)) + if !errors.Is(err, guardian.ErrSignatureInvalid) { + t.Fatalf("%v, want ErrSignatureInvalid", err) + } + }) + } +} + +func TestHMACSignerRotation(t *testing.T) { + ctx := context.Background() + oldKey := guardian.HMACKey{ID: "old", Secret: []byte(strings.Repeat("o", 32))} + newKey := guardian.HMACKey{ID: "new", Secret: []byte(strings.Repeat("n", 32))} + before, err := guardian.NewHMACSigner(oldKey) + if err != nil { + t.Fatal(err) + } + after, err := guardian.NewHMACSigner(newKey, oldKey) + if err != nil { + t.Fatal(err) + } + sig, err := before.Sign(ctx, "s", "", []byte("input")) + if err != nil { + t.Fatal(err) + } + if err := after.Verify(ctx, "s", []byte("input"), sig); err != nil { + t.Fatalf("a retired key no longer verifies: %v", err) + } + answer, err := after.Sign(ctx, "s", sig.KeyID, []byte("input")) + if err != nil { + t.Fatal(err) + } + if answer.KeyID != "old" { + t.Fatalf("answered with %q, want the key the request used", answer.KeyID) + } + unsigned, err := after.Sign(ctx, "s", "", []byte("input")) + if err != nil { + t.Fatal(err) + } + if unsigned.KeyID != "new" { + t.Fatalf("answered an unsigned request with %q, want the first key", unsigned.KeyID) + } + if _, err := after.Sign(ctx, "s", "gone", []byte("input")); err == nil { + t.Fatal("signed with a key_id the signer does not hold") + } +} + +func TestNewHMACSignerRefuses(t *testing.T) { + secret := []byte(strings.Repeat("s", 32)) + tests := []struct { + name string + keys []guardian.HMACKey + }{ + {"no_key", nil}, + {"no_key_id", []guardian.HMACKey{{Secret: secret}}}, + {"short_secret", []guardian.HMACKey{{ID: "k", Secret: secret[:31]}}}, + {"duplicate_key_id", []guardian.HMACKey{{ID: "k", Secret: secret}, {ID: "k", Secret: secret}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := guardian.NewHMACSigner(tt.keys...); err == nil { + t.Fatal("accepted") + } + }) + } +} + +func TestHMACSignerCopiesTheSecret(t *testing.T) { + secret := []byte(strings.Repeat("s", 32)) + s, err := guardian.NewHMACSigner(guardian.HMACKey{ID: "k", Secret: secret}) + if err != nil { + t.Fatal(err) + } + sig, _ := s.Sign(context.Background(), "x", "", []byte("in")) + secret[0] = 'z' + again, _ := s.Sign(context.Background(), "x", "", []byte("in")) + if sig != again { + t.Fatal("changing the caller's slice changed the key") + } +} diff --git a/reference-implementations/agt-go/guardian/http.go b/reference-implementations/agt-go/guardian/http.go new file mode 100644 index 00000000..307aefe4 --- /dev/null +++ b/reference-implementations/agt-go/guardian/http.go @@ -0,0 +1,93 @@ +package guardian + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sync/atomic" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// ServeHTTP answers ACS requests sent as the body of an HTTP POST, at +// whatever route the host mounts it on. Every answer is 200 with a JSON-RPC +// response, as JSON-RPC over HTTP is; a body larger than MaxBodyBytes is +// refused with Invalid Request, never buffered. +func (g *Guardian) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "ACS requests are sent with POST", http.StatusMethodNotAllowed) + return + } + // Counted in flight from here, before the body arrives, so a request that + // is still arriving when Shutdown begins is answered. + if !g.begin() { + writeJSON(w, http.StatusOK, g.shuttingDown(r.Context())) + return + } + defer g.inflight.Done() + stopReading := context.AfterFunc(g.stop, func() { _ = r.Body.Close() }) + defer stopReading() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, g.cfg.MaxBodyBytes)) + var tooLarge *http.MaxBytesError + switch { + case errors.As(err, &tooLarge): + message := fmt.Sprintf("the request body exceeds %d bytes", g.cfg.MaxBodyBytes) + writeJSON(w, http.StatusOK, g.encode(r.Context(), errorReply(nullID, acs.InvalidRequest, "body_too_large", message), "")) + return + case err != nil: + writeJSON(w, http.StatusOK, g.encode(r.Context(), errorReply(nullID, acs.InvalidRequest, "body_unreadable", "the request body cannot be read"), "")) + return + } + writeJSON(w, http.StatusOK, g.handle(r.Context(), body)) +} + +func writeJSON(w http.ResponseWriter, status int, body []byte) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(body) +} + +// Paths the Endpoints serve. +const ( + PathACS = "/acs" + PathHealthz = "/healthz" + PathReadyz = "/readyz" +) + +// Endpoints serves a Guardian that may still be loading its engine: POST +// /acs answers once Ready is called, GET /healthz answers while the process +// runs, and GET /readyz answers 200 only once Ready is called. A supervisor +// routes traffic on /readyz, so no agent reaches a Guardian that is not +// ready and falls back on its failure posture. +type Endpoints struct { + guardian atomic.Pointer[Guardian] +} + +// Ready makes g the Guardian the endpoints serve. +func (e *Endpoints) Ready(g *Guardian) { e.guardian.Store(g) } + +// ServeHTTP implements http.Handler. +func (e *Endpoints) ServeHTTP(w http.ResponseWriter, r *http.Request) { + g := e.guardian.Load() + switch r.URL.Path { + case PathHealthz: + w.WriteHeader(http.StatusOK) + case PathReadyz: + if g == nil { + http.Error(w, "loading", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + case PathACS: + if g == nil { + http.Error(w, "the Guardian is loading", http.StatusServiceUnavailable) + return + } + g.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } +} diff --git a/reference-implementations/agt-go/guardian/http_test.go b/reference-implementations/agt-go/guardian/http_test.go new file mode 100644 index 00000000..9bc995a1 --- /dev/null +++ b/reference-implementations/agt-go/guardian/http_test.go @@ -0,0 +1,33 @@ +package guardian_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +func TestEndpointsGateOnReadiness(t *testing.T) { + endpoints := &guardian.Endpoints{} + server := httptest.NewServer(endpoints) + defer server.Close() + status := func(method, path string) int { + t.Helper() + req, _ := http.NewRequest(method, server.URL+path, strings.NewReader("{}")) + resp, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp.StatusCode + } + if status(http.MethodGet, guardian.PathHealthz) != 200 || status(http.MethodGet, guardian.PathReadyz) != 503 || status(http.MethodPost, guardian.PathACS) != 503 { + t.Fatal("a loading Guardian must be live and not ready") + } + endpoints.Ready(newHarness(t).g) + if status(http.MethodGet, guardian.PathReadyz) != 200 || status(http.MethodPost, guardian.PathACS) != 200 || status(http.MethodGet, guardian.PathACS) != 405 { + t.Fatal("a ready Guardian must answer") + } +} diff --git a/reference-implementations/agt-go/guardian/independent_verifier_test.go b/reference-implementations/agt-go/guardian/independent_verifier_test.go new file mode 100644 index 00000000..cfd795bd --- /dev/null +++ b/reference-implementations/agt-go/guardian/independent_verifier_test.go @@ -0,0 +1,179 @@ +package guardian_test + +import ( + "bytes" + "crypto/hkdf" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + stdjson "encoding/json" + "errors" + "fmt" + "os" + "slices" + "strings" + "testing" + "unicode/utf16" +) + +// verifyResponse checks the signature of a response envelope from §10 alone, +// with its own RFC 8785 canonicalizer and the standard library's HKDF and +// HMAC. It shares no code with the Guardian's signing path, so a defect +// there cannot hide behind the same defect here. Its canonicalizer handles +// integers only, which is all a Guardian response in these tests carries, +// and refuses anything else rather than guess. +func verifyResponse(raw, ikm []byte, sessionID string) error { + dec := stdjson.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var envelope map[string]any + if err := dec.Decode(&envelope); err != nil { + return err + } + holder, ok := envelope["result"].(map[string]any) + if !ok { + if holder, ok = envelope["error"].(map[string]any); !ok { + return errors.New("no result or error to verify") + } + } + sig, ok := holder["signature"].(map[string]any) + if !ok { + return errors.New("the answer carries no signature") + } + delete(holder, "signature") + if sig["algorithm"] != "HMAC-SHA256" { + return fmt.Errorf("algorithm %v", sig["algorithm"]) + } + var input strings.Builder + if err := canonical(&input, envelope); err != nil { + return err + } + key, err := hkdf.Key(sha256.New, ikm, nil, sessionID, 32) + if err != nil { + return err + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(input.String())) + value, _ := sig["value"].(string) + got, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return err + } + if !hmac.Equal(got, mac.Sum(nil)) { + return errors.New("signature does not verify") + } + return nil +} + +func canonical(b *strings.Builder, v any) error { + switch v := v.(type) { + case nil: + b.WriteString("null") + case bool: + fmt.Fprint(b, v) + case string: + canonicalString(b, v) + case stdjson.Number: + if strings.ContainsAny(string(v), ".eE") { + return fmt.Errorf("the independent verifier handles integers only, got %s", v) + } + if v == "-0" { + v = "0" + } + b.WriteString(string(v)) + case []any: + b.WriteByte('[') + for i, e := range v { + if i > 0 { + b.WriteByte(',') + } + if err := canonical(b, e); err != nil { + return err + } + } + b.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + // RFC 8785 §3.2.3: members sorted by their UTF-16 code units. + slices.SortFunc(keys, func(x, y string) int { + return slices.Compare(utf16.Encode([]rune(x)), utf16.Encode([]rune(y))) + }) + b.WriteByte('{') + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + canonicalString(b, k) + b.WriteByte(':') + if err := canonical(b, v[k]); err != nil { + return err + } + } + b.WriteByte('}') + default: + return fmt.Errorf("unexpected %T", v) + } + return nil +} + +// canonicalString follows RFC 8785 §3.2.2.2: only the quotation mark, the +// reverse solidus and the controls are escaped. +func canonicalString(b *strings.Builder, s string) { + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + fmt.Fprintf(b, `\u%04x`, r) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') +} + +func TestIndependentVerifier(t *testing.T) { + b, err := os.ReadFile("../internal/envelope/testdata/vectors.json") + if err != nil { + t.Fatal(err) + } + var v struct { + IKMHex string `json:"ikm_hex"` + SessionID string `json:"session_id"` + Response struct { + Signed stdjson.RawMessage `json:"signed"` + } `json:"response"` + } + if err := stdjson.Unmarshal(b, &v); err != nil { + t.Fatal(err) + } + ikm, _ := hex.DecodeString(v.IKMHex) + if err := verifyResponse(v.Response.Signed, ikm, v.SessionID); err != nil { + t.Fatalf("the recorded response does not verify: %v", err) + } + tampered := bytes.Replace(v.Response.Signed, []byte(`"deny"`), []byte(`"allow"`), 1) + if err := verifyResponse(tampered, ikm, v.SessionID); err == nil { + t.Fatal("a response changed from deny to allow still verifies") + } + if err := verifyResponse(v.Response.Signed, ikm, "another-session"); err == nil { + t.Fatal("a response verifies under another session's key") + } +} diff --git a/reference-implementations/agt-go/guardian/jsonl_audit_file_other.go b/reference-implementations/agt-go/guardian/jsonl_audit_file_other.go new file mode 100644 index 00000000..3b375f1a --- /dev/null +++ b/reference-implementations/agt-go/guardian/jsonl_audit_file_other.go @@ -0,0 +1,13 @@ +//go:build !unix + +package guardian + +import "io/fs" + +// Symbolic links are not refused and mode bits are not checked here: those +// platforms do not give the Unix meaning to either. +const auditOpenFlags = 0 + +func auditOpenError(_ string, err error) error { return err } + +func checkAuditFileMode(string, fs.FileInfo) error { return nil } diff --git a/reference-implementations/agt-go/guardian/jsonl_audit_file_unix.go b/reference-implementations/agt-go/guardian/jsonl_audit_file_unix.go new file mode 100644 index 00000000..fef42845 --- /dev/null +++ b/reference-implementations/agt-go/guardian/jsonl_audit_file_unix.go @@ -0,0 +1,31 @@ +//go:build unix + +package guardian + +import ( + "errors" + "fmt" + "io/fs" + "syscall" +) + +// auditOpenFlags makes the open fail on a symbolic link at the log path, so +// the Guardian never writes through a link an attacker placed . +const auditOpenFlags = syscall.O_NOFOLLOW + +func auditOpenError(path string, err error) error { + if errors.Is(err, syscall.ELOOP) { + return fmt.Errorf("audit log %s is a symbolic link; the Guardian does not follow it", path) + } + return err +} + +// checkAuditFileMode refuses a log that group or other can read or write: +// its records name agents, sessions and tool arguments. The operator sets the +// mode; the Guardian does not chmod a file it did not create. +func checkAuditFileMode(path string, info fs.FileInfo) error { + if perm := info.Mode().Perm(); perm&0o077 != 0 { + return fmt.Errorf("audit log %s has mode %04o; group and other must have no access", path, perm) + } + return nil +} diff --git a/reference-implementations/agt-go/guardian/jsonl_audit_file_unix_test.go b/reference-implementations/agt-go/guardian/jsonl_audit_file_unix_test.go new file mode 100644 index 00000000..a46e097e --- /dev/null +++ b/reference-implementations/agt-go/guardian/jsonl_audit_file_unix_test.go @@ -0,0 +1,74 @@ +//go:build unix + +package guardian + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAuditLogDirectoryIsPrivate(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit", "events.jsonl") + f, err := openRotatingFile(path, JSONLRotation{}) + if err != nil { + t.Fatal(err) + } + defer f.Close() + info, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("audit directory mode %04o, want 0700", got) + } +} + +func TestAuditLogRefusesASymbolicLink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "elsewhere.jsonl") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "events.jsonl") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + _, err := openRotatingFile(link, JSONLRotation{}) + if err == nil || !strings.Contains(err.Error(), "is a symbolic link") { + t.Fatalf("a symbolic link as the audit log: got %v", err) + } +} + +func TestAuditLogRefusesAGroupOrOtherReadableFile(t *testing.T) { + for name, tt := range map[string]struct { + mode os.FileMode + want string + }{ + "group_readable": {0o640, "has mode 0640"}, + "world_readable": {0o644, "has mode 0644"}, + "private": {0o600, ""}, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "events.jsonl") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, tt.mode); err != nil { + t.Fatal(err) + } + f, err := openRotatingFile(path, JSONLRotation{}) + if tt.want == "" { + if err != nil { + t.Fatalf("a %04o log refused: %v", tt.mode, err) + } + _ = f.Close() + return + } + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("got %v, want a refusal naming %q", err, tt.want) + } + }) + } +} diff --git a/reference-implementations/agt-go/guardian/jsonl_audit_log.go b/reference-implementations/agt-go/guardian/jsonl_audit_log.go new file mode 100644 index 00000000..892daa25 --- /dev/null +++ b/reference-implementations/agt-go/guardian/jsonl_audit_log.go @@ -0,0 +1,429 @@ +package guardian + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sync" + "time" + + jsonv2 "github.com/go-json-experiment/json" +) + +// JSONLAuditLog is an AuditLog, cmd/guardian's: it appends envelopes to one JSON +// Lines file and audit events to another, from a background writer, so a +// slow disk never delays a decision. +// +// The envelope file has the TypeScript reference's line shape, {seq, +// recorded_at, direction, method, rpc_id, envelope} with direction request +// or response, so its Inspector reads it. +// +// The queue holds the records JSONLQueue allows, the record being written +// included. A record that would exceed either limit is dropped and counted, +// and logging continues as space frees. A write failure turns the log off for +// the rest of the process: the failed record and every later one are dropped +// and counted. Each kind of failure +// reaches onError once, never the caller, and Close reports every dropped +// record. +type JSONLAuditLog struct { + queue chan line + done chan struct{} + onError func(error) + limits JSONLQueue + + mu sync.Mutex + closed bool + failed bool + queued int + queuedBytes int64 + envelopes, events int + dropped int + queueFull error + writeFailure error + offeredAfterClose error +} + +// line is one record to write; exactly one of envelope and event is set. +// It is numbered when offered, from one per file and process run, so a +// record dropped from a full queue leaves a gap a reader detects; a restart +// appending to the same file repeats the numbers already in it. +type line struct { + envelope *envelopeLine + event *eventLine + size int64 +} + +// JSONLQueue bounds the records the writer holds. Zero means the default. +type JSONLQueue struct { + MaxQueueRecords int + MaxQueueBytes int64 +} + +const ( + // DefaultMaxQueueRecords holds about 6.5 MiB at the 1.6 KB per step + // docs/load.md measures. + DefaultMaxQueueRecords = 4096 + // DefaultMaxQueueBytes holds a stalled disk to 64 MiB of records rather + // than DefaultMaxQueueRecords requests of DefaultMaxBodyBytes, 4 GiB. + DefaultMaxQueueBytes = 64 << 20 +) + +// Validate refuses a negative limit. +func (q JSONLQueue) Validate() error { + if q.MaxQueueRecords < 0 || q.MaxQueueBytes < 0 { + return errors.New("max_queue_records and max_queue_bytes cannot be negative") + } + return nil +} + +func (q JSONLQueue) withDefaults() JSONLQueue { + if q.MaxQueueRecords == 0 { + q.MaxQueueRecords = DefaultMaxQueueRecords + } + if q.MaxQueueBytes == 0 { + q.MaxQueueBytes = DefaultMaxQueueBytes + } + return q +} + +// JSONLRecordBytes is the most that the record of a request of bodyBytes +// counts against MaxQueueBytes: the body, its method and JSON-RPC id, which +// are copied out of it, and the fixed part of a record. A MaxQueueBytes below +// it drops every such request. +func JSONLRecordBytes(bodyBytes int64) int64 { + return lineOverheadBytes + 2*bodyBytes +} + +// lineOverheadBytes is the memory a record holds beyond its variable-length +// fields: the structs, pointers and timestamp. +const lineOverheadBytes = 256 + +// JSONLRotation bounds each of the two files. When a record would take a +// file past MaxFileBytes, the file is renamed to .1, earlier ones move +// up to .2 and so on, and the one past MaxFiles is deleted, so a log +// keeps at most MaxFileBytes × (MaxFiles + 1) bytes, plus one record larger +// than MaxFileBytes. The zero value keeps every record: a deployment that +// rotates the files with its own tooling leaves it unset. +type JSONLRotation struct { + MaxFileBytes int64 + MaxFiles int +} + +// Validate refuses a negative value, MaxFiles without MaxFileBytes, and +// MaxFileBytes without MaxFiles, which would delete the live log at each +// rotation. +func (r JSONLRotation) Validate() error { + switch { + case r.MaxFileBytes < 0 || r.MaxFiles < 0: + return errors.New("max_file_bytes and max_files cannot be negative") + case r.MaxFileBytes == 0 && r.MaxFiles != 0: + return errors.New("max_files needs max_file_bytes") + case r.MaxFileBytes > 0 && r.MaxFiles == 0: + return errors.New("max_file_bytes needs max_files of at least 1, or each rotation deletes the live log") + } + return nil +} + +// NewJSONLAuditLog opens (creating directories as needed) the two files and +// starts the writer. onError, which may be nil, receives the first failure of +// each kind - queue full, write failure, offer after Close - asynchronously +// and possibly concurrently, so it may safely close the log. +func NewJSONLAuditLog(envelopePath, eventPath string, rotation JSONLRotation, queue JSONLQueue, onError func(error)) (*JSONLAuditLog, error) { + if err := rotation.Validate(); err != nil { + return nil, err + } + if err := queue.Validate(); err != nil { + return nil, err + } + var files [2]auditFile + for i, path := range []string{envelopePath, eventPath} { + f, err := openRotatingFile(path, rotation) + if err != nil { + if files[0] != nil { + _ = files[0].Close() + } + return nil, err + } + files[i] = f + } + return newJSONLAuditLog(files, queue, onError), nil +} + +// rotatingFile appends to path and rotates it as JSONLRotation describes. +// Only the writer goroutine uses it. +type rotatingFile struct { + path string + rotation JSONLRotation + f *os.File + size int64 +} + +func openRotatingFile(path string, rotation JSONLRotation) (*rotatingFile, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + r := &rotatingFile{path: path, rotation: rotation} + if err := r.open(); err != nil { + return nil, err + } + return r, nil +} + +func (r *rotatingFile) open() error { + f, err := os.OpenFile(r.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|auditOpenFlags, 0o600) + if err != nil { + return auditOpenError(r.path, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return err + } + if err := checkAuditFileMode(r.path, info); err != nil { + _ = f.Close() + return err + } + r.f, r.size = f, info.Size() + return nil +} + +// Write appends one record, rotating first when the record would take the +// file past MaxFileBytes; a record is never split across files. +func (r *rotatingFile) Write(b []byte) (int, error) { + if r.rotation.MaxFileBytes > 0 && r.size > 0 && r.size+int64(len(b)) > r.rotation.MaxFileBytes { + if err := r.rotate(); err != nil { + return 0, err + } + } + n, err := r.f.Write(b) + r.size += int64(n) + return n, err +} + +func (r *rotatingFile) rotate() error { + if err := r.f.Close(); err != nil { + return err + } + numbered := func(i int) string { return fmt.Sprintf("%s.%d", r.path, i) } + keep := r.rotation.MaxFiles + last := r.path + if keep > 0 { + last = numbered(keep) + } + if err := os.Remove(last); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + for i := keep - 1; i >= 1; i-- { + if err := os.Rename(numbered(i), numbered(i+1)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + if keep > 0 { + if err := os.Rename(r.path, numbered(1)); err != nil { + return err + } + } + return r.open() +} + +func (r *rotatingFile) Close() error { return r.f.Close() } + +type auditFile interface { + Write([]byte) (int, error) + Close() error +} + +func newJSONLAuditLog(files [2]auditFile, queue JSONLQueue, onError func(error)) *JSONLAuditLog { + limits := queue.withDefaults() + l := &JSONLAuditLog{queue: make(chan line, limits.MaxQueueRecords), done: make(chan struct{}), onError: onError, limits: limits} + go l.write(files) + return l +} + +type envelopeLine struct { + Seq int `json:"seq"` + RecordedAt string `json:"recorded_at"` + Direction string `json:"direction"` + Method *string `json:"method"` + RPCID json.RawMessage `json:"rpc_id"` + Envelope json.RawMessage `json:"envelope"` +} + +type eventLine struct { + Seq int `json:"seq"` + RecordedAt string `json:"recorded_at"` + Kind AuditEventKind `json:"kind"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + Method string `json:"method"` + Message string `json:"message"` +} + +// Envelope implements AuditLog. +func (l *JSONLAuditLog) Envelope(_ context.Context, r EnvelopeRecord) { + direction := "request" + if r.Direction == Outbound { + direction = "response" + } + var method *string + if r.Method != "" { + // A pointer into r would keep r.Body, the caller's buffer, alive + // beside the copy the queue counts. + m := r.Method + method = &m + } + var head struct { + ID json.RawMessage `json:"id"` + } + _ = jsonv2.Unmarshal(r.Body, &head) + id := json.RawMessage("null") + if len(head.ID) > 0 && (head.ID[0] == '"' || head.ID[0] == '-' || (head.ID[0] >= '0' && head.ID[0] <= '9')) { + id = head.ID + } + // The body is copied: the caller may reuse its buffer once Envelope + // returns, and the writer reads it later. + l.enqueue(line{ + envelope: &envelopeLine{RecordedAt: stamp(r.RecordedAt), Direction: direction, Method: method, RPCID: slices.Clone(id), Envelope: slices.Clone(r.Body)}, + size: lineOverheadBytes + int64(len(r.Method)+len(id)+len(r.Body)), + }) +} + +// Event implements AuditLog. +func (l *JSONLAuditLog) Event(_ context.Context, e AuditEvent) { + l.enqueue(line{ + event: &eventLine{RecordedAt: stamp(e.RecordedAt), Kind: e.Kind, SessionID: e.SessionID, RequestID: e.RequestID, Method: e.Method, Message: e.Message}, + size: lineOverheadBytes + int64(len(e.Kind)+len(e.SessionID)+len(e.RequestID)+len(e.Method)+len(e.Message)), + }) +} + +func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +// enqueue numbers a record and queues it, or drops and counts it when the +// queue is full, the log has failed or the log is closed. onError is called +// after the lock is released, so a slow or re-entrant callback cannot hold a +// governed request. +func (l *JSONLAuditLog) enqueue(ln line) { + report := func() {} + l.mu.Lock() + if ln.envelope != nil { + l.envelopes++ + ln.envelope.Seq = l.envelopes + } else { + l.events++ + ln.event.Seq = l.events + } + switch { + case l.closed: + l.dropped++ + report = l.firstOf(&l.offeredAfterClose, errors.New("audit log closed; records are being dropped and counted")) + case l.failed: + l.dropped++ + case l.queued == l.limits.MaxQueueRecords || l.queuedBytes+ln.size > l.limits.MaxQueueBytes: + l.dropped++ + report = l.firstOf(&l.queueFull, errors.New("audit log queue full; records are being dropped and counted")) + default: + l.queued++ + l.queuedBytes += ln.size + // Never blocks: the channel holds at most queued records, and queued + // falls only after the writer has received a record and written it. + l.queue <- ln + } + l.mu.Unlock() + report() +} + +// write writes each queued record and releases its place in the queue only +// once its write returns, so the record in flight counts against the limits. +func (l *JSONLAuditLog) write(files [2]auditFile) { + defer close(l.done) + failed := false + for ln := range l.queue { + var err error + if !failed { + err = writeLine(files, ln) + } + l.mu.Lock() + l.queued-- + l.queuedBytes -= ln.size + report := func() {} + if failed || err != nil { + l.dropped++ + } + if err != nil { + failed, l.failed = true, true + report = l.firstOf(&l.writeFailure, fmt.Errorf("audit log write failed; logging is off for the rest of the process: %w", err)) + } + l.mu.Unlock() + report() + } + for _, f := range files { + if err := f.Close(); err != nil { + l.mu.Lock() + report := l.firstOf(&l.writeFailure, fmt.Errorf("audit log close failed: %w", err)) + l.mu.Unlock() + report() + } + } +} + +func writeLine(files [2]auditFile, ln line) error { + f, record := files[0], any(ln.envelope) + if ln.event != nil { + f, record = files[1], ln.event + } + b, err := jsonv2.Marshal(record) + if err != nil { + return err + } + _, err = f.Write(append(b, '\n')) + return err +} + +// Close writes every queued record and closes the files until ctx expires. +// It returns the first error of each kind and the number of records that did +// not reach the files, counting those still queued when ctx expires. Records +// sent after Close are dropped and counted, and reported only through +// onError. +func (l *JSONLAuditLog) Close(ctx context.Context) error { + l.mu.Lock() + if !l.closed { + l.closed = true + close(l.queue) + } + l.mu.Unlock() + var timeout error + select { + case <-l.done: + case <-ctx.Done(): + timeout = ctx.Err() + } + l.mu.Lock() + defer l.mu.Unlock() + errs := []error{timeout, l.writeFailure, l.queueFull, l.offeredAfterClose} + switch { + case timeout != nil && l.queued > 0: + errs = append(errs, fmt.Errorf("the audit log dropped %d records and %d were still queued", l.dropped, l.queued)) + case l.dropped > 0: + errs = append(errs, fmt.Errorf("the audit log dropped %d records", l.dropped)) + } + return errors.Join(errs...) +} + +// firstOf keeps err in *slot when it is the first of its kind and returns the +// report to make after l.mu is released. The callback runs asynchronously +// because a synchronous callback that closes the log would otherwise wait for +// the writer that called it. Callers hold l.mu. +func (l *JSONLAuditLog) firstOf(slot *error, err error) func() { + if *slot != nil { + return func() {} + } + *slot = err + if l.onError == nil { + return func() {} + } + return func() { go l.onError(err) } +} diff --git a/reference-implementations/agt-go/guardian/jsonl_audit_log_internal_test.go b/reference-implementations/agt-go/guardian/jsonl_audit_log_internal_test.go new file mode 100644 index 00000000..0030db49 --- /dev/null +++ b/reference-implementations/agt-go/guardian/jsonl_audit_log_internal_test.go @@ -0,0 +1,371 @@ +package guardian + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "runtime" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type failingAuditFile struct{ err error } + +func (f failingAuditFile) Write([]byte) (int, error) { return 0, f.err } +func (f failingAuditFile) Close() error { return nil } + +// stalledAuditFile holds every Write until proceed is closed. It then keeps +// the first failFrom records and fails every later Write with err; a zero +// failFrom keeps every record. Each kept record is announced on wrote. +type stalledAuditFile struct { + proceed chan struct{} + failFrom int + err error + wrote chan struct{} + + mu sync.Mutex + lines [][]byte +} + +func newStalledAuditFile() *stalledAuditFile { + return &stalledAuditFile{proceed: make(chan struct{}), wrote: make(chan struct{}, DefaultMaxQueueRecords)} +} + +func (f *stalledAuditFile) Write(b []byte) (int, error) { + <-f.proceed + f.mu.Lock() + defer f.mu.Unlock() + if f.failFrom > 0 && len(f.lines) >= f.failFrom { + return 0, f.err + } + f.lines = append(f.lines, slices.Clone(b)) + select { + case f.wrote <- struct{}{}: + default: + } + return len(b), nil +} + +func (f *stalledAuditFile) Close() error { return nil } + +func (f *stalledAuditFile) seqs(t *testing.T) []int { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + var seqs []int + for _, b := range f.lines { + var ln struct { + Seq int `json:"seq"` + } + if err := json.Unmarshal(b, &ln); err != nil { + t.Fatalf("line %q: %v", b, err) + } + seqs = append(seqs, ln.Seq) + } + return seqs +} + +// paddedBody is a JSON-RPC request of about size bytes. +func paddedBody(id, size int) []byte { + return fmt.Appendf(nil, `{"jsonrpc":"2.0","id":%d,"pad":"%s"}`, id, strings.Repeat("x", size)) +} + +func (l *JSONLAuditLog) queuedBytesNow() int64 { + l.mu.Lock() + defer l.mu.Unlock() + return l.queuedBytes +} + +// smallRecordBytes is what one paddedBody(1, 32) envelope counts against the +// queue: the overhead, the body and its one-byte rpc_id. +var smallRecordBytes = lineOverheadBytes + int64(len(paddedBody(1, 32))) + 1 + +func closedFile() *stalledAuditFile { + f := newStalledAuditFile() + close(f.proceed) + return f +} + +func closeLog(t *testing.T, l *JSONLAuditLog) error { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return l.Close(ctx) +} + +// With the writer stalled, the queue keeps the records that fit, the one in +// the writer included, and drops and counts the rest. +func TestJSONLAuditLogQueueIsBounded(t *testing.T) { + const mib = 1 << 20 + for name, tt := range map[string]struct { + queue JSONLQueue + bodyBytes int + offered int + written int + }{ + "bytes": {queue: JSONLQueue{MaxQueueBytes: 4 * mib}, bodyBytes: mib, offered: 40, written: 3}, + "default_records": {bodyBytes: 16, offered: DefaultMaxQueueRecords + 10, written: DefaultMaxQueueRecords}, + "configured_records": {queue: JSONLQueue{MaxQueueRecords: 2}, bodyBytes: 16, offered: 3, written: 2}, + "one_record_over_limit": {queue: JSONLQueue{MaxQueueBytes: mib}, bodyBytes: mib, offered: 1, written: 0}, + } { + t.Run(name, func(t *testing.T) { + envelopes := newStalledAuditFile() + l := newJSONLAuditLog([2]auditFile{envelopes, closedFile()}, tt.queue, nil) + maxBytes := tt.queue.withDefaults().MaxQueueBytes + for i := range tt.offered { + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(i+1, tt.bodyBytes)}) + if got := l.queuedBytesNow(); got > maxBytes { + t.Fatalf("after %d offers the queue holds %d bytes, limit %d", i+1, got, maxBytes) + } + } + close(envelopes.proceed) + err := closeLog(t, l) + want := make([]int, tt.written) + for i := range want { + want[i] = i + 1 + } + if got := envelopes.seqs(t); !slices.Equal(got, want) { + t.Fatalf("written seq %v, want %v", got, want) + } + if wantErr := fmt.Sprintf("the audit log dropped %d records", tt.offered-tt.written); err == nil || !strings.Contains(err.Error(), wantErr) { + t.Fatalf("Close() = %v, want %q", err, wantErr) + } + }) + } +} + +// The queue's byte count is what the queued records keep alive: a record +// holds its own copy of the body and nothing of the caller's. +func TestJSONLAuditLogRetainsOnlyWhatItCounts(t *testing.T) { + const mib = 1 << 20 + envelopes := newStalledAuditFile() + l := newJSONLAuditLog([2]auditFile{envelopes, closedFile()}, JSONLQueue{MaxQueueBytes: 32 * mib}, nil) + heap := func() int64 { + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return int64(m.HeapAlloc) + } + before := heap() + for i := range 64 { + l.Envelope(context.Background(), EnvelopeRecord{Direction: Inbound, Method: "steps/userMessage", Body: paddedBody(i+1, mib)}) + } + retained, counted := heap()-before, l.queuedBytesNow() + close(envelopes.proceed) + _ = closeLog(t, l) + if retained > counted+4*mib { + t.Fatalf("queued records keep %d bytes alive, the queue counts %d", retained, counted) + } +} + +// A queue-full drop, then a write failure: both are reported, and Close +// counts every record offered and not written. +func TestJSONLAuditLogCountsEveryLoss(t *testing.T) { + diskFailure := errors.New("disk failed") + envelopes := newStalledAuditFile() + envelopes.failFrom, envelopes.err = 1, diskFailure + reports := make(chan error, 8) + size := smallRecordBytes + l := newJSONLAuditLog([2]auditFile{envelopes, closedFile()}, JSONLQueue{MaxQueueBytes: 3 * size}, func(err error) { reports <- err }) + offer := func(n int) { + for range n { + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(1, 32)}) + } + } + offer(5) // three queued, two dropped from the full queue + close(envelopes.proceed) + var got []error + for len(got) < 2 { + select { + case err := <-reports: + got = append(got, err) + case <-time.After(5 * time.Second): + t.Fatalf("reports %v, want a queue-full and a write-failure report", got) + } + } + queueFull := slices.ContainsFunc(got, func(err error) bool { return strings.Contains(err.Error(), "queue full") }) + writeFailed := slices.ContainsFunc(got, func(err error) bool { return errors.Is(err, diskFailure) }) + if !queueFull || !writeFailed { + t.Fatalf("reports %v, want one queue-full and one write-failure report", got) + } + offer(2) // after the failure + err := closeLog(t, l) + offer(1) // after Close + err = errors.Join(err, closeLog(t, l)) + if written := len(envelopes.seqs(t)); written != 1 { + t.Fatalf("%d records written, want 1", written) + } + if !errors.Is(err, diskFailure) || !strings.Contains(err.Error(), "the audit log dropped 6 records") || !strings.Contains(err.Error(), "the audit log dropped 7 records") { + t.Fatalf("Close() = %v, want the write failure, then 6 and 7 of 8 records dropped", err) + } +} + +// Producers racing each other, a backlog meeting a write failure, and Close: every record +// offered is written or counted, each file's numbers rise in write order, and +// each failure kind is reported once. +func TestJSONLAuditLogAccountingUnderConcurrency(t *testing.T) { + const producers, offers = 8, 400 + diskFailure := errors.New("disk failed") + envelopes, events := newStalledAuditFile(), closedFile() + envelopes.failFrom, envelopes.err = 10, diskFailure + var reportsMu sync.Mutex + reports := map[string]int{} + writeFailed := make(chan struct{}) + l := newJSONLAuditLog([2]auditFile{envelopes, events}, JSONLQueue{MaxQueueBytes: 16 * smallRecordBytes}, func(err error) { + reportsMu.Lock() + defer reportsMu.Unlock() + kind := strings.SplitN(err.Error(), ";", 2)[0] + if reports[kind]++; errors.Is(err, diskFailure) && reports[kind] == 1 { + close(writeFailed) + } + }) + var extra atomic.Int64 + var wg sync.WaitGroup + for p := range producers { + wg.Go(func() { + for i := range offers { + if i%2 == 0 { + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(1, 32)}) + } else { + l.Event(context.Background(), AuditEvent{Kind: EventFailure, Message: fmt.Sprint(p)}) + } + if p == 0 && i == offers/4 { + close(envelopes.proceed) // the backlog meets the write failure + } + if p == 0 && i == offers/2 { + // Close races the other producers only once the write has + // failed; until then this producer keeps the writer fed. + deadline := time.Now().Add(5 * time.Second) + for waiting := true; waiting; { + select { + case <-writeFailed: + waiting = false + default: + if time.Now().After(deadline) { + t.Error("no write-failure report within 5s") + waiting = false + } + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(1, 32)}) + extra.Add(1) + runtime.Gosched() + } + } + go func() { _ = l.Close(context.Background()) }() + } + } + }) + } + wg.Wait() + err := closeLog(t, l) + var dropped int + if _, scanErr := fmt.Sscanf(err.Error()[strings.LastIndex(err.Error(), "the audit log dropped"):], "the audit log dropped %d records", &dropped); scanErr != nil { + t.Fatalf("Close() = %v: %v", err, scanErr) + } + envelopeSeqs, eventSeqs := envelopes.seqs(t), events.seqs(t) + if written, offered := len(envelopeSeqs)+len(eventSeqs), producers*offers+int(extra.Load()); written+dropped != offered { + t.Fatalf("%d written + %d dropped, want %d offered", written, dropped, offered) + } + for _, seqs := range [][]int{envelopeSeqs, eventSeqs} { + if !slices.IsSorted(seqs) || len(slices.Compact(slices.Clone(seqs))) != len(seqs) { + t.Fatalf("seq %v does not rise in write order", seqs) + } + } + // The writer stalls until a quarter of the offers are made, so the queue + // fills before the write failure, and Close waits for the failure; offers + // after Close are not guaranteed. + expected := []string{"audit log queue full", "audit log write failed"} + deadline := time.Now().Add(5 * time.Second) + for { + reportsMu.Lock() + got := maps.Clone(reports) + reportsMu.Unlock() + missing := slices.ContainsFunc(expected, func(kind string) bool { return got[kind] == 0 }) + if !missing || time.Now().After(deadline) { + for _, kind := range expected { + if got[kind] != 1 { + t.Fatalf("reports %v, want %v once each", got, expected) + } + } + for kind, n := range got { + if n != 1 { + t.Fatalf("%q reported %d times, want once", kind, n) + } + } + return + } + time.Sleep(time.Millisecond) + } +} + +// A record dropped from a full queue keeps its number, so the written ones +// show the gap. +func TestJSONLAuditLogDropLeavesAGap(t *testing.T) { + envelopes, events := newStalledAuditFile(), closedFile() + size := smallRecordBytes + l := newJSONLAuditLog([2]auditFile{envelopes, events}, JSONLQueue{MaxQueueBytes: 2 * size}, nil) + for range 3 { + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(1, 32)}) + } + close(envelopes.proceed) + <-envelopes.wrote + <-envelopes.wrote + // The writer released the first record before writing the second, so + // one record's space is free; the event waits until the fourth envelope + // is written, which frees the second's. + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(1, 32)}) + <-envelopes.wrote + l.Event(context.Background(), AuditEvent{Kind: EventFailure}) + _ = closeLog(t, l) + if got := envelopes.seqs(t); !slices.Equal(got, []int{1, 2, 4}) { + t.Fatalf("envelope seq %v, want [1 2 4]", got) + } + if got := events.seqs(t); !slices.Equal(got, []int{1}) { + t.Fatalf("event seq %v, want [1]: each file numbers its own records", got) + } +} + +// Close returns when ctx expires and counts the records the writer still +// holds. +func TestJSONLAuditLogCloseHonorsContext(t *testing.T) { + envelopes := newStalledAuditFile() + defer close(envelopes.proceed) + l := newJSONLAuditLog([2]auditFile{envelopes, closedFile()}, JSONLQueue{}, nil) + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(1, 32)}) + l.Envelope(context.Background(), EnvelopeRecord{Body: paddedBody(2, 32)}) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + err := l.Close(ctx) + if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "dropped 0 records and 2 were still queued") { + t.Fatalf("Close() error = %v, want deadline exceeded and 2 records still queued", err) + } +} + +func TestJSONLAuditLogWriterFailureCallbackMayClose(t *testing.T) { + diskFailure := errors.New("disk failed") + callbackDone := make(chan error, 1) + var l *JSONLAuditLog + l = newJSONLAuditLog( + [2]auditFile{failingAuditFile{err: diskFailure}, failingAuditFile{}}, + JSONLQueue{}, + func(error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + callbackDone <- l.Close(ctx) + }, + ) + l.Envelope(context.Background(), EnvelopeRecord{Body: []byte(`{"jsonrpc":"2.0","id":"r1"}`), RecordedAt: time.Now()}) + select { + case err := <-callbackDone: + if !errors.Is(err, diskFailure) { + t.Fatalf("Close from failure callback: %v, want disk failure", err) + } + case <-time.After(time.Second): + t.Fatal("writer failure callback blocked while closing the log") + } +} diff --git a/reference-implementations/agt-go/guardian/jsonl_audit_log_test.go b/reference-implementations/agt-go/guardian/jsonl_audit_log_test.go new file mode 100644 index 00000000..eb49b829 --- /dev/null +++ b/reference-implementations/agt-go/guardian/jsonl_audit_log_test.go @@ -0,0 +1,151 @@ +package guardian_test + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" +) + +func readLines(t *testing.T, path string) []map[string]any { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + var lines []map[string]any + s := bufio.NewScanner(f) + for s.Scan() { + var m map[string]any + if err := json.Unmarshal(s.Bytes(), &m); err != nil { + t.Fatalf("line %q is not JSON: %v", s.Text(), err) + } + lines = append(lines, m) + } + return lines +} + +func TestJSONLAuditLog(t *testing.T) { + dir := t.TempDir() + envelopes, events := filepath.Join(dir, "a", "envelopes.jsonl"), filepath.Join(dir, "b", "events.jsonl") + var failures []error + l, err := guardian.NewJSONLAuditLog(envelopes, events, guardian.JSONLRotation{}, guardian.JSONLQueue{}, func(err error) { failures = append(failures, err) }) + if err != nil { + t.Fatal(err) + } + ctx, at := context.Background(), time.Date(2026, 9, 19, 10, 0, 0, 0, time.UTC) + pretty := []byte("{\n \"jsonrpc\": \"2.0\",\n \"method\": \"steps/userMessage\",\n \"id\": 7\n}") + l.Envelope(ctx, guardian.EnvelopeRecord{Direction: guardian.Inbound, Method: "steps/userMessage", Body: pretty, RecordedAt: at}) + l.Envelope(ctx, guardian.EnvelopeRecord{Direction: guardian.Outbound, Body: []byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"x"}}`), RecordedAt: at}) + l.Event(ctx, guardian.AuditEvent{Kind: guardian.EventChainMismatch, SessionID: "s", RequestID: "r", Method: "m", Message: "msg", RecordedAt: at}) + if err := l.Close(context.Background()); err != nil { + t.Fatal(err) + } + got := readLines(t, envelopes) + if len(got) != 2 { + t.Fatalf("%d envelope lines, want 2", len(got)) + } + first, second := got[0], got[1] + if first["seq"] != 1.0 || first["direction"] != "request" || first["method"] != "steps/userMessage" || first["rpc_id"] != 7.0 || first["recorded_at"] != "2026-09-19T10:00:00Z" { + t.Fatalf("first line %v", first) + } + if first["envelope"].(map[string]any)["method"] != "steps/userMessage" { + t.Fatalf("envelope %v", first["envelope"]) + } + if second["seq"] != 2.0 || second["direction"] != "response" || second["method"] != nil || second["rpc_id"] != nil { + t.Fatalf("second line %v", second) + } + ev := readLines(t, events) + if len(ev) != 1 || ev[0]["seq"] != 1.0 || ev[0]["kind"] != "chain_mismatch" || ev[0]["session_id"] != "s" { + t.Fatalf("event lines %v", ev) + } + if len(failures) != 0 { + t.Fatalf("failures %v", failures) + } + l.Envelope(ctx, guardian.EnvelopeRecord{Direction: guardian.Inbound, Body: []byte(`{}`)}) + if b, _ := os.ReadFile(envelopes); strings.Count(string(b), "\n") != 2 { + t.Fatal("a record sent after Close was written") + } +} + +func TestJSONLAuditLogRefusesANegativeQueueLimit(t *testing.T) { + dir := t.TempDir() + for name, queue := range map[string]guardian.JSONLQueue{ + "records": {MaxQueueRecords: -1}, + "bytes": {MaxQueueBytes: -1}, + } { + t.Run(name, func(t *testing.T) { + if _, err := guardian.NewJSONLAuditLog(filepath.Join(dir, name+"-e.jsonl"), filepath.Join(dir, name+"-v.jsonl"), guardian.JSONLRotation{}, queue, nil); err == nil { + t.Fatal("opened a log with a negative queue limit") + } + }) + } +} + +func TestJSONLAuditLogRefusesAnUnwritablePath(t *testing.T) { + dir := t.TempDir() + blocker := filepath.Join(dir, "file") + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := guardian.NewJSONLAuditLog(filepath.Join(blocker, "x.jsonl"), filepath.Join(dir, "e.jsonl"), guardian.JSONLRotation{}, guardian.JSONLQueue{}, nil); err == nil { + t.Fatal("opened a log under a regular file") + } +} + +// onError runs outside the log's lock: a callback that writes to the log +// itself, or closes it, returns instead of deadlocking the request that +// triggered it. +func TestJSONLAuditLogCallbackMayReenter(t *testing.T) { + dir := t.TempDir() + var l *guardian.JSONLAuditLog + reentered := make(chan struct{}) + l, err := guardian.NewJSONLAuditLog(filepath.Join(dir, "e.jsonl"), filepath.Join(dir, "v.jsonl"), guardian.JSONLRotation{}, guardian.JSONLQueue{}, func(error) { + l.Event(context.Background(), guardian.AuditEvent{Kind: guardian.EventFailure, Message: "reported"}) + close(reentered) + }) + if err != nil { + t.Fatal(err) + } + if err := l.Close(context.Background()); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { + l.Event(context.Background(), guardian.AuditEvent{Kind: guardian.EventFailure}) + close(done) + }() + select { + case <-done: + <-reentered + case <-time.After(time.Second): + t.Fatal("a re-entrant onError deadlocked the log") + } +} + +// The log keeps its own copy of a body: a caller reusing its buffer after +// Envelope returns does not change what is written. +func TestJSONLAuditLogCopiesTheBody(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "e.jsonl") + l, err := guardian.NewJSONLAuditLog(path, filepath.Join(dir, "v.jsonl"), guardian.JSONLRotation{}, guardian.JSONLQueue{}, nil) + if err != nil { + t.Fatal(err) + } + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"steps/userMessage"}`) + l.Envelope(context.Background(), guardian.EnvelopeRecord{Direction: guardian.Inbound, Body: body}) + copy(body, strings.Repeat("x", len(body))) + if err := l.Close(context.Background()); err != nil { + t.Fatal(err) + } + if got := readLines(t, path); len(got) != 1 || got[0]["envelope"].(map[string]any)["method"] != "steps/userMessage" { + t.Fatalf("written %v", got) + } +} diff --git a/reference-implementations/agt-go/guardian/jsonl_rotation_internal_test.go b/reference-implementations/agt-go/guardian/jsonl_rotation_internal_test.go new file mode 100644 index 00000000..bbc0327d --- /dev/null +++ b/reference-implementations/agt-go/guardian/jsonl_rotation_internal_test.go @@ -0,0 +1,137 @@ +package guardian + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// TestRotatingFileBoundsTheLog: a file that would grow past MaxFileBytes is +// renamed to .1, older ones move up, the one past MaxFiles is deleted, no +// record is split, and the files hold exactly the newest records, in order. +func TestRotatingFileBoundsTheLog(t *testing.T) { + recordAt := func(seq int) []byte { return fmt.Appendf(nil, `{"seq":%02d,"padding":"0123456789"}`+"\n", seq) } // 35 bytes + record := recordAt(0) + for name, tt := range map[string]struct { + rotation JSONLRotation + files []string + kept int + }{ + "keeps_two": {JSONLRotation{MaxFileBytes: 100, MaxFiles: 2}, []string{"log", "log.1", "log.2"}, 6}, + "keeps_all": {JSONLRotation{}, []string{"log"}, 20}, + "one_per_file": {JSONLRotation{MaxFileBytes: 10, MaxFiles: 1}, []string{"log", "log.1"}, 2}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "log") + f, err := openRotatingFile(path, tt.rotation) + if err != nil { + t.Fatal(err) + } + const records = 20 + for i := range records { + if _, err := f.Write(recordAt(i)); err != nil { + t.Fatal(err) + } + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var got []string + var total int64 + for _, e := range entries { + got = append(got, e.Name()) + info, _ := e.Info() + total += info.Size() + if tt.rotation.MaxFileBytes > 0 && info.Size() > max(tt.rotation.MaxFileBytes, int64(len(record))) { + t.Errorf("%s has %d bytes, past the bound", e.Name(), info.Size()) + } + content, _ := os.ReadFile(filepath.Join(dir, e.Name())) + if len(content)%len(record) != 0 { + t.Errorf("%s holds a split record", e.Name()) + } + } + if !equalNames(got, tt.files) { + t.Fatalf("files %v, want %v", got, tt.files) + } + var oldestFirst, want []byte + for i := len(tt.files) - 1; i >= 0; i-- { + content, _ := os.ReadFile(filepath.Join(dir, tt.files[len(tt.files)-1-i])) + oldestFirst = append(content, oldestFirst...) + } + for i := records - tt.kept; i < records; i++ { + want = append(want, recordAt(i)...) + } + if !bytes.Equal(oldestFirst, want) { + t.Fatalf("the files hold, oldest first:\n%s\nwant the newest %d records:\n%s", oldestFirst, tt.kept, want) + } + }) + } +} + +func equalNames(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestJSONLRotationRefusesAnInconsistentBound(t *testing.T) { + for name, r := range map[string]JSONLRotation{ + "negative_bytes": {MaxFileBytes: -1}, + "negative_files": {MaxFileBytes: 1, MaxFiles: -1}, + "files_without_a_size": {MaxFiles: 2}, + "size_without_files": {MaxFileBytes: 100}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if _, err := NewJSONLAuditLog(filepath.Join(dir, "e"), filepath.Join(dir, "v"), r, JSONLQueue{}, nil); err == nil { + t.Fatal("accepted") + } + }) + } +} + +// TestJSONLAuditLogRotates: the audit log's writer goes through the rotation, +// so a busy log stays within MaxFileBytes × (MaxFiles + 1). +func TestJSONLAuditLogRotates(t *testing.T) { + dir := t.TempDir() + rotation := JSONLRotation{MaxFileBytes: 2048, MaxFiles: 1} + l, err := NewJSONLAuditLog(filepath.Join(dir, "envelopes.jsonl"), filepath.Join(dir, "events.jsonl"), rotation, JSONLQueue{}, nil) + if err != nil { + t.Fatal(err) + } + for range 200 { + l.Envelope(context.Background(), EnvelopeRecord{Body: []byte(`{"jsonrpc":"2.0","id":"r1","method":"steps/toolCallRequest"}`), RecordedAt: time.Now()}) + } + if err := l.Close(context.Background()); err != nil { + t.Fatal(err) + } + var total int64 + for _, name := range []string{"envelopes.jsonl", "envelopes.jsonl.1"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + total += info.Size() + } + if _, err := os.Stat(filepath.Join(dir, "envelopes.jsonl.2")); err == nil { + t.Fatal("a file past MaxFiles was kept") + } + if total > rotation.MaxFileBytes*int64(rotation.MaxFiles+1) { + t.Fatalf("the log kept %d bytes, past %d", total, rotation.MaxFileBytes*int64(rotation.MaxFiles+1)) + } +} diff --git a/reference-implementations/agt-go/guardian/lineage.go b/reference-implementations/agt-go/guardian/lineage.go new file mode 100644 index 00000000..5b39dd6a --- /dev/null +++ b/reference-implementations/agt-go/guardian/lineage.go @@ -0,0 +1,105 @@ +package guardian + +import ( + "context" + "encoding/json" + "fmt" + "slices" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" +) + +// provenanceIDs returns the provenance_id of every Provenance object in a +// payload, in document order: any object carrying a string provenance_id +// and a string origin, the two members provenance.json requires. +func provenanceIDs(payload json.RawMessage) ([]string, error) { + if len(payload) == 0 { + return nil, nil + } + var tree any + if err := jsonv2.Unmarshal(payload, &tree); err != nil { + return nil, err + } + var ids []string + var walk func(any) + walk = func(v any) { + switch v := v.(type) { + case map[string]any: + id, idOK := v["provenance_id"].(string) + _, originOK := v["origin"].(string) + if idOK && originOK && !slices.Contains(ids, id) { + ids = append(ids, id) + } + for _, child := range v { + walk(child) + } + case []any: + for _, child := range v { + walk(child) + } + } + } + walk(tree) + slices.Sort(ids) + return ids, nil +} + +// checkLineage holds a postCompact step to post-compact.json: its +// pre_compact_chain_hash is the head before this step's entry, the +// summary's origin is agent_generated, and its derived_from is the union of +// the provenance_ids of every compacted entry. post_compact_chain_hash is +// not checked: it names the hash of the entry that carries it, which no +// sender can know, and the answer's chain_hash publishes the real one. The +// hook permits no DENY, since compaction has happened, so a mismatch is +// recorded and the engine's decision is sent with the mismatch's reason +// code added. Trust monotonicity over that lineage is policy, and stays +// with the engine. +func (g *Guardian) checkLineage(ctx context.Context, in *call, req acs.Request, sess Session, d acs.Decision) acs.Decision { + var p acs.PostCompactPayload + if err := jsonv2.Unmarshal(req.Params.Payload, &p); err != nil { + return d + } + var problem string + before := "" + if n := len(sess.Entries); n > 0 && sess.Entries[n-1].PreviousHash != nil { + before = *sess.Entries[n-1].PreviousHash + } + if p.PreCompactChainHash != before { + problem = fmt.Sprintf("pre_compact_chain_hash %s is not the head %s this step was appended to", p.PreCompactChainHash, before) + } else if p.Summary.Provenance.Origin != acs.OriginAgentGenerated { + problem = fmt.Sprintf("the summary's origin is %q, not %q", p.Summary.Provenance.Origin, acs.OriginAgentGenerated) + } else { + var want []string + for _, step := range p.EntriesCompacted { + ids, ok := sess.Lineage[step] + if !ok { + problem = "entries_compacted names step " + step + ", which is not in this session's chain" + break + } + for _, id := range ids { + if !slices.Contains(want, id) { + want = append(want, id) + } + } + } + got := slices.Clone(p.Summary.Provenance.DerivedFrom) + slices.Sort(want) + slices.Sort(got) + got = slices.Compact(got) + if problem == "" && !slices.Equal(got, want) { + problem = fmt.Sprintf("the summary's derived_from %v is not the union %v of the compacted entries' provenance_ids", got, want) + } + } + if problem == "" { + return d + } + g.event(ctx, EventLineageMismatch, req.Method, in.SessionID, in.RequestID, problem) + d.ReasonCodes = append(slices.Clip(d.ReasonCodes), disposition.ReasonLineageMismatch) + if d.Reasoning == "" { + d.Reasoning = problem + } + return d +} diff --git a/reference-implementations/agt-go/guardian/memory_session_context_store.go b/reference-implementations/agt-go/guardian/memory_session_context_store.go new file mode 100644 index 00000000..16b9466b --- /dev/null +++ b/reference-implementations/agt-go/guardian/memory_session_context_store.go @@ -0,0 +1,382 @@ +package guardian + +import ( + "container/list" + "context" + "fmt" + "slices" + "sync" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// MemorySessionContextStore is the default SessionContextStore: all state in +// process memory, lost on restart, and serialising a session's steps only +// within this process. Every bound is a refusal, never a silent loss: a +// session is removed only after Retention without a write, and when none +// can be removed a new session is refused. +type MemorySessionContextStore struct { + limits MemoryStoreLimits + + mu sync.Mutex + sessions map[string]*list.Element // of *memorySession, most recently written at the front + order *list.List + skills map[SkillApproval]struct{} + locks map[string]*sessionLock +} + +// MemoryStoreLimits bounds a MemorySessionContextStore. +type MemoryStoreLimits struct { + // MaxSessions is how many sessions the store holds. + MaxSessions int + // MaxEntries is how many ContextEntries one session holds. + MaxEntries int + // MaxReservations is how many requests one session remembers for replay + // detection, including requests answered without a ContextEntry. + MaxReservations int + // MaxSkillApprovals is how many skill approvals the store holds. + MaxSkillApprovals int + // Retention is how long a session is kept after its last write when + // the store needs its place. It must be at least twice the Guardian's + // skew window: a request accepted at the future edge remains fresh for + // two windows after receipt. + Retention time.Duration + // Now is the store's clock; nil means time.Now. + Now func() time.Time +} + +type memorySession struct { + id string + state Session + reserved map[string]struct{} // request_id and nonce values, each prefixed by its kind + reservations int + writtenAt time.Time +} + +// sessionLock is one session's lock, a channel so that waiting honours a +// context; refs counts the holder and the waiters. +type sessionLock struct { + held chan struct{} + refs int +} + +// NewMemorySessionContextStore returns an empty store bounded by limits. +func NewMemorySessionContextStore(limits MemoryStoreLimits) (*MemorySessionContextStore, error) { + switch { + case limits.MaxSessions < 1: + return nil, fmt.Errorf("the store needs MaxSessions of at least 1, got %d", limits.MaxSessions) + case limits.MaxEntries < 1: + return nil, fmt.Errorf("the store needs MaxEntries of at least 1, got %d", limits.MaxEntries) + case limits.MaxReservations < 1: + return nil, fmt.Errorf("the store needs MaxReservations of at least 1, got %d", limits.MaxReservations) + case limits.MaxSkillApprovals < 1: + return nil, fmt.Errorf("the store needs MaxSkillApprovals of at least 1, got %d", limits.MaxSkillApprovals) + case limits.Retention <= 0: + return nil, fmt.Errorf("the store needs a positive Retention, got %s", limits.Retention) + } + if limits.Now == nil { + limits.Now = time.Now + } + return &MemorySessionContextStore{ + limits: limits, + sessions: map[string]*list.Element{}, + order: list.New(), + skills: map[SkillApproval]struct{}{}, + locks: map[string]*sessionLock{}, + }, nil +} + +// Lock implements SessionContextStore. +func (m *MemorySessionContextStore) Lock(ctx context.Context, sessionID string) (context.Context, func(), error) { + m.mu.Lock() + l, ok := m.locks[sessionID] + if !ok { + l = &sessionLock{held: make(chan struct{}, 1)} + m.locks[sessionID] = l + } + l.refs++ + m.mu.Unlock() + + forget := func() { + m.mu.Lock() + l.refs-- + if l.refs == 0 { + delete(m.locks, sessionID) + } + m.mu.Unlock() + } + select { + case l.held <- struct{}{}: + case <-ctx.Done(): + forget() + return nil, nil, ctx.Err() + } + lockCtx, cancel := context.WithCancelCause(ctx) + var once sync.Once + return lockCtx, func() { + once.Do(func() { + cancel(nil) + <-l.held + forget() + }) + }, nil +} + +// Load implements SessionContextStore. +func (m *MemorySessionContextStore) Load(_ context.Context, sessionID string) (Session, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + el, ok := m.sessions[sessionID] + if !ok { + return Session{}, false, nil + } + s, err := el.Value.(*memorySession).state.clone() + return s, err == nil, err +} + +// Negotiate implements SessionContextStore. +func (m *MemorySessionContextStore) Negotiate(_ context.Context, sessionID string, r Reservation, h Handshake) error { + m.mu.Lock() + defer m.mu.Unlock() + if h.Server.SkewWindowMS < 0 || h.Server.SkewWindowMS > int64(m.limits.Retention/(2*time.Millisecond)) { + return fmt.Errorf("the store retention %s is less than twice the negotiated skew window %dms", m.limits.Retention, h.Server.SkewWindowMS) + } + if el, ok := m.sessions[sessionID]; ok { + if el.Value.(*memorySession).seen(r) { + return ErrReplayDetected + } + return ErrAlreadyNegotiated + } + if m.order.Len() >= m.limits.MaxSessions && !m.removeExpired() { + return ErrStoreFull + } + hs, err := cloneJSON(h) + if err != nil { + return err + } + s := &memorySession{id: sessionID, state: Session{Handshake: hs}, reserved: map[string]struct{}{}, writtenAt: m.limits.Now()} + s.reserve(r) + m.sessions[sessionID] = m.order.PushFront(s) + return nil +} + +// removeExpired removes the least recently written session whose retention +// has passed and whose lock nobody holds or awaits, and reports whether it +// did. +func (m *MemorySessionContextStore) removeExpired() bool { + for el := m.order.Back(); el != nil; el = el.Prev() { + s := el.Value.(*memorySession) + if m.limits.Now().Sub(s.writtenAt) <= m.limits.Retention { + return false + } + if _, locked := m.locks[s.id]; locked { + continue + } + m.order.Remove(el) + delete(m.sessions, s.id) + return true + } + return false +} + +// Reserve implements SessionContextStore. +func (m *MemorySessionContextStore) Reserve(_ context.Context, sessionID string, r Reservation) error { + m.mu.Lock() + defer m.mu.Unlock() + el, ok := m.sessions[sessionID] + if !ok { + return ErrSessionNotFound + } + s := el.Value.(*memorySession) + if s.seen(r) { + return ErrReplayDetected + } + if s.reservations >= m.limits.MaxReservations { + return ErrStoreFull + } + s.reserve(r) + m.written(el) + return nil +} + +// Append implements SessionContextStore. +func (m *MemorySessionContextStore) Append(_ context.Context, sessionID string, a Append) error { + m.mu.Lock() + defer m.mu.Unlock() + el, ok := m.sessions[sessionID] + if !ok { + return ErrHeadMoved + } + s := el.Value.(*memorySession) + switch { + case s.seen(a.Reservation): + return ErrReplayDetected + case s.reservations >= m.limits.MaxReservations: + return ErrStoreFull + case s.state.Head != a.ExpectedHead: + return ErrHeadMoved + case len(s.state.Entries) >= m.limits.MaxEntries: + return ErrStoreFull + } + entry, err := cloneEntry(a.Entry) + if err != nil { + return err + } + var intent *acs.Intent + if a.Intent != nil { + i, err := cloneJSON(*a.Intent) + if err != nil { + return err + } + intent = &i + } + s.reserve(a.Reservation) + s.state.Entries = append(s.state.Entries, entry) + s.state.Head = entry.EntryHash + if s.state.Lineage == nil { + s.state.Lineage = map[string][]string{} + } + lineage := slices.Clone(s.state.Lineage[entry.StepID]) + for _, id := range a.Lineage { + if !slices.Contains(lineage, id) { + lineage = append(lineage, id) + } + } + s.state.Lineage[entry.StepID] = lineage + if intent != nil { + s.state.Intent = intent + } + if a.Close { + s.state.Closed = true + } + m.written(el) + return nil +} + +// Conclude implements SessionContextStore. +func (m *MemorySessionContextStore) Conclude(_ context.Context, sessionID string, c Conclusion) error { + m.mu.Lock() + defer m.mu.Unlock() + el, ok := m.sessions[sessionID] + if !ok { + return ErrSessionNotFound + } + s := el.Value.(*memorySession) + if s.state.Head != c.ExpectedHead { + return ErrHeadMoved + } + if a := c.ApproveSkill; a != nil { + if _, ok := m.skills[*a]; !ok && len(m.skills) >= m.limits.MaxSkillApprovals { + return ErrStoreFull + } + m.skills[*a] = struct{}{} + } + if c.AskStepID != "" { + s.state.AskSteps = append(s.state.AskSteps, c.AskStepID) + } + if c.Deferred { + s.state.Deferrals++ + } + if c.PolicyState != nil { + s.state.PolicyState = slices.Clone(c.PolicyState) + } + m.written(el) + return nil +} + +// SkillApproved implements SessionContextStore. +func (m *MemorySessionContextStore) SkillApproved(_ context.Context, s SkillApproval) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.skills[s] + return ok, nil +} + +func (m *MemorySessionContextStore) written(el *list.Element) { + el.Value.(*memorySession).writtenAt = m.limits.Now() + m.order.MoveToFront(el) +} + +func (s *memorySession) seen(r Reservation) bool { + if _, ok := s.reserved["request_id:"+r.RequestID]; ok { + return true + } + if r.Nonce == "" { + return false + } + _, ok := s.reserved["nonce:"+r.Nonce] + return ok +} + +func (s *memorySession) reserve(r Reservation) { + s.reserved["request_id:"+r.RequestID] = struct{}{} + if r.Nonce != "" { + s.reserved["nonce:"+r.Nonce] = struct{}{} + } + s.reservations++ +} + +// clone returns a Session that shares no memory with s. +func (s Session) clone() (Session, error) { + out := s + var err error + if out.Handshake, err = cloneJSON(s.Handshake); err != nil { + return Session{}, err + } + if s.Intent != nil { + intent, err := cloneJSON(*s.Intent) + if err != nil { + return Session{}, err + } + out.Intent = &intent + } + out.Entries = make([]acs.ContextEntry, len(s.Entries)) + for i, e := range s.Entries { + if out.Entries[i], err = cloneEntry(e); err != nil { + return Session{}, err + } + } + out.AskSteps = slices.Clone(s.AskSteps) + if s.Lineage != nil { + out.Lineage = make(map[string][]string, len(s.Lineage)) + for k, v := range s.Lineage { + out.Lineage[k] = slices.Clone(v) + } + } + out.PolicyState = slices.Clone(s.PolicyState) + return out, nil +} + +// cloneEntry copies an entry; only the rare grant members go through JSON. +func cloneEntry(e acs.ContextEntry) (acs.ContextEntry, error) { + if e.PreviousHash != nil { + previous := *e.PreviousHash + e.PreviousHash = &previous + } + if e.ProvenanceSummary != nil || e.Approver != nil || e.IntentExtension != nil { + deep, err := cloneJSON(struct { + P *acs.ProvenanceSummary + A *acs.Approver + X *acs.IntentExtension + }{e.ProvenanceSummary, e.Approver, e.IntentExtension}) + if err != nil { + return e, err + } + e.ProvenanceSummary, e.Approver, e.IntentExtension = deep.P, deep.A, deep.X + } + return e, nil +} + +// cloneJSON copies v through its JSON encoding, which every stored type has. +func cloneJSON[T any](v T) (T, error) { + var out T + b, err := jsonv2.Marshal(v) + if err != nil { + return out, err + } + err = jsonv2.Unmarshal(b, &out) + return out, err +} diff --git a/reference-implementations/agt-go/guardian/memory_session_context_store_test.go b/reference-implementations/agt-go/guardian/memory_session_context_store_test.go new file mode 100644 index 00000000..fa9f7305 --- /dev/null +++ b/reference-implementations/agt-go/guardian/memory_session_context_store_test.go @@ -0,0 +1,177 @@ +package guardian_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian/guardiantest" +) + +func memoryLimits() guardian.MemoryStoreLimits { + return guardian.MemoryStoreLimits{MaxSessions: 8, MaxEntries: 64, MaxReservations: 128, MaxSkillApprovals: 8, Retention: time.Hour} +} + +func TestReserveAndAppendAtomicity(t *testing.T) { + guardiantest.TestSessionContextStore(t, func(t *testing.T) guardian.SessionContextStore { + store, err := guardian.NewMemorySessionContextStore(memoryLimits()) + if err != nil { + t.Fatal(err) + } + return store + }) +} + +// A full store refuses a new session rather than drop a live one, and gives +// a session's place away only after its retention has passed without a +// write. +func TestMemoryStoreRefusesWhenFull(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 9, 19, 10, 0, 0, 0, time.UTC) + limits := memoryLimits() + limits.MaxSessions = 2 + limits.Now = func() time.Time { return now } + store, err := guardian.NewMemorySessionContextStore(limits) + if err != nil { + t.Fatal(err) + } + negotiate := func(id string) error { + return store.Negotiate(ctx, id, guardian.Reservation{RequestID: "r0"}, guardian.Handshake{}) + } + for _, id := range []string{"a", "b"} { + if err := negotiate(id); err != nil { + t.Fatal(err) + } + } + if err := negotiate("c"); !errors.Is(err, guardian.ErrStoreFull) { + t.Fatalf("third session: %v, want ErrStoreFull", err) + } + + now = now.Add(30 * time.Minute) + // A write to a keeps it; b is the least recently written. + if err := store.Append(ctx, "a", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: acs.ContextEntry{EntryHash: "h"}}); err != nil { + t.Fatal(err) + } + now = now.Add(31 * time.Minute) + if err := negotiate("c"); err != nil { + t.Fatalf("after b's retention: %v", err) + } + for id, want := range map[string]bool{"a": true, "b": false, "c": true} { + if _, ok, _ := store.Load(ctx, id); ok != want { + t.Errorf("session %s present=%v, want %v", id, ok, want) + } + } + if err := negotiate("d"); !errors.Is(err, guardian.ErrStoreFull) { + t.Fatalf("a was written within its retention and must stay: %v", err) + } +} + +func TestMemoryStoreBoundsEntriesAndApprovals(t *testing.T) { + ctx := context.Background() + limits := memoryLimits() + limits.MaxEntries, limits.MaxSkillApprovals = 1, 1 + store, err := guardian.NewMemorySessionContextStore(limits) + if err != nil { + t.Fatal(err) + } + if err := store.Negotiate(ctx, "s1", guardian.Reservation{RequestID: "r0"}, guardian.Handshake{}); err != nil { + t.Fatal(err) + } + if err := store.Append(ctx, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r1"}, Entry: acs.ContextEntry{EntryHash: "h1"}}); err != nil { + t.Fatal(err) + } + err = store.Append(ctx, "s1", guardian.Append{Reservation: guardian.Reservation{RequestID: "r2"}, ExpectedHead: "h1", Entry: acs.ContextEntry{EntryHash: "h2"}}) + if !errors.Is(err, guardian.ErrStoreFull) { + t.Fatalf("second entry: %v, want ErrStoreFull", err) + } + approve := func(id string) error { + return store.Conclude(ctx, "s1", guardian.Conclusion{ExpectedHead: "h1", ApproveSkill: &guardian.SkillApproval{SkillID: id}}) + } + if err := approve("a"); err != nil { + t.Fatal(err) + } + if err := approve("b"); !errors.Is(err, guardian.ErrStoreFull) { + t.Fatalf("second approval: %v, want ErrStoreFull", err) + } +} + +func TestMemoryStoreBoundsReplayReservations(t *testing.T) { + ctx := context.Background() + limits := memoryLimits() + limits.MaxReservations = 2 + store, err := guardian.NewMemorySessionContextStore(limits) + if err != nil { + t.Fatal(err) + } + if err := store.Negotiate(ctx, "s1", guardian.Reservation{RequestID: "r0"}, guardian.Handshake{}); err != nil { + t.Fatal(err) + } + if err := store.Reserve(ctx, "s1", guardian.Reservation{RequestID: "r1"}); err != nil { + t.Fatal(err) + } + if err := store.Reserve(ctx, "s1", guardian.Reservation{RequestID: "r2"}); !errors.Is(err, guardian.ErrStoreFull) { + t.Fatalf("third reservation: %v, want ErrStoreFull", err) + } + if err := store.Reserve(ctx, "s1", guardian.Reservation{RequestID: "r1"}); !errors.Is(err, guardian.ErrReplayDetected) { + t.Fatalf("known reservation at capacity: %v, want ErrReplayDetected", err) + } +} + +// A session whose lock is held or awaited is never removed, however long +// its step runs: its holder still has to conclude it. +func TestMemoryStoreKeepsALockedSession(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 9, 19, 10, 0, 0, 0, time.UTC) + limits := memoryLimits() + limits.MaxSessions = 1 + limits.Now = func() time.Time { return now } + store, err := guardian.NewMemorySessionContextStore(limits) + if err != nil { + t.Fatal(err) + } + if err := store.Negotiate(ctx, "a", guardian.Reservation{RequestID: "r0"}, guardian.Handshake{}); err != nil { + t.Fatal(err) + } + _, release, err := store.Lock(ctx, "a") + if err != nil { + t.Fatal(err) + } + now = now.Add(2 * limits.Retention) + if err := store.Negotiate(ctx, "b", guardian.Reservation{RequestID: "r0"}, guardian.Handshake{}); !errors.Is(err, guardian.ErrStoreFull) { + t.Fatalf("a locked session was removed: %v", err) + } + release() + if err := store.Negotiate(ctx, "b", guardian.Reservation{RequestID: "r0"}, guardian.Handshake{}); err != nil { + t.Fatalf("after release: %v", err) + } +} + +func TestMemoryStoreRefusesNoCapacity(t *testing.T) { + for name, change := range map[string]func(*guardian.MemoryStoreLimits){ + "sessions": func(l *guardian.MemoryStoreLimits) { l.MaxSessions = 0 }, + "entries": func(l *guardian.MemoryStoreLimits) { l.MaxEntries = 0 }, + "reservations": func(l *guardian.MemoryStoreLimits) { l.MaxReservations = 0 }, + "approvals": func(l *guardian.MemoryStoreLimits) { l.MaxSkillApprovals = 0 }, + "retention": func(l *guardian.MemoryStoreLimits) { l.Retention = 0 }, + } { + limits := memoryLimits() + change(&limits) + if _, err := guardian.NewMemorySessionContextStore(limits); err == nil { + t.Errorf("%s: accepted a store with no capacity", name) + } + } +} + +func TestMemoryStoreRefusesSkewWindowBeyondRetention(t *testing.T) { + store, err := guardian.NewMemorySessionContextStore(memoryLimits()) + if err != nil { + t.Fatal(err) + } + handshake := guardian.Handshake{Server: acs.ServerHello{SkewWindowMS: int64(memoryLimits().Retention/time.Millisecond/2) + 1}} + if err := store.Negotiate(context.Background(), "s1", guardian.Reservation{RequestID: "r0"}, handshake); err == nil { + t.Fatal("accepted a skew window whose replay lifetime exceeds store retention") + } +} diff --git a/reference-implementations/agt-go/guardian/negotiated_timeout_test.go b/reference-implementations/agt-go/guardian/negotiated_timeout_test.go new file mode 100644 index 00000000..78f12a10 --- /dev/null +++ b/reference-implementations/agt-go/guardian/negotiated_timeout_test.go @@ -0,0 +1,23 @@ +package guardian + +import ( + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +func TestNegotiatedTimeout(t *testing.T) { + server := acs.ServerHello{TimeoutConfig: acs.TimeoutConfig{ + DefaultMS: 5000, + PerMethodMS: map[string]int64{ + acs.StepToolCallRequest: 75, + }, + }} + if got := negotiatedTimeout(server, acs.StepToolCallRequest); got != 75*time.Millisecond { + t.Fatalf("method timeout %s", got) + } + if got := negotiatedTimeout(server, acs.StepUserMessage); got != 5*time.Second { + t.Fatalf("default timeout %s", got) + } +} diff --git a/reference-implementations/agt-go/guardian/policy_engine.go b/reference-implementations/agt-go/guardian/policy_engine.go new file mode 100644 index 00000000..95ea7b37 --- /dev/null +++ b/reference-implementations/agt-go/guardian/policy_engine.go @@ -0,0 +1,119 @@ +package guardian + +import ( + "context" + "encoding/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// PolicyEngine decides every step (§12.1). The standard defines its +// interface and leaves the engine to the deployment: the Guardian gives it +// the request envelope, the SessionContext and the Intent, and receives a +// decision. +// +// Decide is called after the step's ContextEntry is appended and before the +// response is signed, under the negotiated decision timeout and the session +// store's lock context. An implementation must be safe for concurrent calls +// and must not mutate its input. It should stop when ctx ends; one that +// cannot be interrupted may run on. Either way the Guardian stops waiting at +// the decision timeout and keeps the call's place among Config.MaxEngineCalls +// until Decide returns, so calls that run on cannot pile up without bound. +// An error, a timeout or a decision +// without the fields its disposition requires is answered DENY: the step is +// already in the chain, and a transport error would let an Observed Agent on +// the default posture proceed. +type PolicyEngine interface { + // Policy describes what the engine's policy requires of every session; + // the ServerHello declares it. + Policy() PolicyDescription + + // Decide returns the decision for one step. + Decide(ctx context.Context, in PolicyInput) (PolicyDecision, error) +} + +// PolicyDescription is what a policy declares to every Observed Agent in the +// handshake (§4). +type PolicyDescription struct { + // RequiresProvenance refuses an Observed Agent that declares + // provenance_producer "none" with PROVENANCE_REQUIRED. + RequiresProvenance bool + // ApproverTypes lists the approver types the deployment routes Ask + // decisions to (approver_types_supported). + ApproverTypes []acs.ApproverType +} + +// PolicyInput is §12.1's input: the request envelope, the SessionContext and +// the Intent. Provenance travels inside the envelope's payload. +type PolicyInput struct { + // Request is the validated request envelope; its payload is raw JSON + // that has passed the method's payload schema. + Request acs.Request + // Session is the SessionContext after this step's ContextEntry was + // appended. + Session SessionContext +} + +// SessionContext is the standard's SessionContext (§8) as the engine sees it. +type SessionContext struct { + SessionID string + // ChainHash is the head after this step's entry. + ChainHash string + Entries []acs.ContextEntry + // Intent is nil until the session establishes one (§8.4). + Intent *acs.Intent + // Handshake is what the session negotiated. + Handshake Handshake + // PolicyState is what the engine returned as State for an earlier step + // of the session, nil until it returned any. + PolicyState json.RawMessage +} + +// PolicyDecision is §12.1's output: a decision, and what the deployment's +// policy knows that the wire does not carry. +type PolicyDecision struct { + acs.Decision + + // DelegateToAgent is delegate_to "agent": the deterministic layer asks + // the agent layer (§12.2) to decide. This Guardian has no agent layer, + // so it answers DENY with reason code agent_layer_unavailable. + DelegateToAgent bool + + // Client is what the Observed Agent can apply, which v0.1 leaves to + // deployment-defined means (§6.5, §9.2). The zero value is an Observed Agent + // that applies every disposition. + Client ClientCapability + + // Grant is an approver's intent extension that arrived for this step + // (§9.1), nil when none did. The engine authenticates the approver and + // applies the session's scope_mode before returning it, since both are + // policy. + Grant *IntentGrant + + // State, when not nil, is valid JSON kept with the session and given back + // as SessionContext.PolicyState on its later steps, so an engine whose + // policy carries state across steps keeps it where the chain is kept. + State json.RawMessage +} + +// ClientCapability records what an Observed Agent cannot apply, as the +// deployment determines it. +type ClientCapability struct { + // CannotApplyModify substitutes DENY with reason code + // modify_unsupported for a Modify decision, or ALLOW at + // steps/postCompact (§6.5). + CannotApplyModify bool + // CannotResolveAsk substitutes AskSubstitute for an Ask decision + // (§9.2). + CannotResolveAsk bool + // AskSubstitute is Defer or Deny; the policy chooses (§9.2). + AskSubstitute acs.Disposition +} + +// IntentGrant is an approved intent extension and who approved it (§9.1). +type IntentGrant struct { + Approver acs.Approver + Extension acs.IntentExtension + // AskStepID is the step_id of the step whose Ask the approver answered. + AskStepID string +} diff --git a/reference-implementations/agt-go/guardian/replay.go b/reference-implementations/agt-go/guardian/replay.go new file mode 100644 index 00000000..fab10a39 --- /dev/null +++ b/reference-implementations/agt-go/guardian/replay.go @@ -0,0 +1,19 @@ +package guardian + +import "time" + +// DefaultSkewWindow is the skew window §10.3 recommends and the ServerHello +// declares when the deployment sets none. +const DefaultSkewWindow = 300 * time.Second + +// withinSkew reports whether a request timestamp lies within window of now, +// in the past or the future (§10.3). It compares instants, never a +// difference: the difference of two far-apart times saturates, and its +// negation overflows. +func withinSkew(timestamp string, now time.Time, window time.Duration) bool { + t, err := time.Parse(time.RFC3339Nano, timestamp) + if err != nil { + return false + } + return !t.Before(now.Add(-window)) && !t.After(now.Add(window)) +} diff --git a/reference-implementations/agt-go/guardian/replay_test.go b/reference-implementations/agt-go/guardian/replay_test.go new file mode 100644 index 00000000..ba2a3e09 --- /dev/null +++ b/reference-implementations/agt-go/guardian/replay_test.go @@ -0,0 +1,37 @@ +package guardian + +import ( + "testing" + "time" +) + +func TestWithinSkew(t *testing.T) { + now := time.Date(2026, 9, 19, 10, 0, 0, 0, time.UTC) + tests := []struct { + name string + timestamp string + window time.Duration + want bool + }{ + {"now", "2026-09-19T10:00:00Z", DefaultSkewWindow, true}, + {"past_edge", "2026-09-19T09:55:00Z", DefaultSkewWindow, true}, + {"future_edge", "2026-09-19T10:05:00Z", DefaultSkewWindow, true}, + {"past_beyond", "2026-09-19T09:54:59.999Z", DefaultSkewWindow, false}, + {"future_beyond", "2026-09-19T10:05:00.001Z", DefaultSkewWindow, false}, + {"offset_zone", "2026-09-19T05:00:00-05:00", DefaultSkewWindow, true}, + {"zero_window_exact", "2026-09-19T10:00:00Z", 0, true}, + {"zero_window_late", "2026-09-19T10:00:00.001Z", 0, false}, + // Far enough that now.Sub saturates at the minimum duration, whose + // negation overflows back to a negative skew. + {"year_9999", "9999-12-31T23:59:59Z", DefaultSkewWindow, false}, + {"year_0001", "0001-01-01T00:00:00Z", DefaultSkewWindow, false}, + {"unreadable", "19 Sep 2026", DefaultSkewWindow, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := withinSkew(tt.timestamp, now, tt.window); got != tt.want { + t.Fatalf("withinSkew = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/reference-implementations/agt-go/guardian/session_context_store.go b/reference-implementations/agt-go/guardian/session_context_store.go new file mode 100644 index 00000000..7482a2b4 --- /dev/null +++ b/reference-implementations/agt-go/guardian/session_context_store.go @@ -0,0 +1,178 @@ +package guardian + +import ( + "context" + "encoding/json" + "errors" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// SessionContextStore keeps the Guardian's state: every session's handshake, +// its SessionContext (the ContextEntry sequence, its head and the Intent), the +// request_id and nonce values already seen, what its decisions left behind, +// and the skill registrations approved across sessions. +// +// The Guardian computes every entry_hash itself; a store decides only where +// the state is kept and who may write it. Every Guardian sharing a store +// takes Lock before it reads a session and releases it once the answer is +// built. A durable store may implement Lock with an expiring claim: it then +// cancels the returned context when it detects lost ownership and fences every +// mutation against that claim. Append and Conclude also check the expected +// head before they change the session. +// +// A store keeps a session, and every request_id and nonce it has reserved, +// for at least twice that session's negotiated skew window after its last +// write. Removed sooner, the session's own handshake could be replayed to +// open it again and its steps replayed into it. +type SessionContextStore interface { + // Lock orders work on a session across every Guardian sharing the store. + // lockCtx derives from ctx and ends when ctx ends, release is called, or + // the store detects that it lost ownership. The Guardian uses lockCtx for + // all work in the protected step. release is safe to call more than once. + // + // A store backed by an expiring claim must renew it while work continues + // and fence every mutation so a former owner cannot write. Cancellation is + // cooperative: Lock does not promise that external effects of a + // PolicyEngine stop exactly when a distributed claim expires. The session + // need not exist. + Lock(ctx context.Context, sessionID string) (lockCtx context.Context, release func(), err error) + + // Load returns a session's state, and false when the store holds none. + // The returned Session shares nothing the store later modifies. + Load(ctx context.Context, sessionID string) (Session, bool, error) + + // Negotiate records the handshake of a new session and reserves the + // handshake's request_id and nonce. When the session exists it changes + // nothing and returns ErrReplayDetected if r was seen in it, and + // ErrAlreadyNegotiated otherwise. It returns ErrStoreFull when the store + // cannot take another session. + Negotiate(ctx context.Context, sessionID string, r Reservation, h Handshake) error + + // Reserve reserves r in a session for a request answered without an + // entry, returning ErrReplayDetected when r was seen in the session and + // ErrSessionNotFound when there is no session. + Reserve(ctx context.Context, sessionID string, r Reservation) error + + // Append, in one operation that wholly succeeds or changes nothing: + // reserves a.RequestID and a.Nonce, returning ErrReplayDetected when + // either was seen in the session; checks that the session's head is + // a.ExpectedHead, returning ErrHeadMoved when it is not or when the + // session no longer exists; appends a.Entry and makes its entry_hash the + // head; adds a.Lineage to the lineage already recorded under Entry.StepID; + // replaces the Intent when a.Intent is set; and marks the session closed + // when a.Close is set. It returns ErrStoreFull when the session cannot take + // another entry. + Append(ctx context.Context, sessionID string, a Append) error + + // Conclude records, in one operation that wholly succeeds or changes + // nothing, what a step's final decision leaves: in the session, when its + // head is still c.ExpectedHead (ErrHeadMoved otherwise, ErrSessionNotFound + // when it is gone), and across sessions, c.ApproveSkill. It returns + // ErrStoreFull when the store cannot take another skill approval. + Conclude(ctx context.Context, sessionID string, c Conclusion) error + + // SkillApproved reports whether a Conclusion approved s. + SkillApproved(ctx context.Context, s SkillApproval) (bool, error) +} + +// Session is the state a SessionContextStore keeps for one session. +type Session struct { + // Handshake is the negotiated handshake. + Handshake Handshake + // Entries is the ContextEntry sequence in order. A store that archives + // entries (§8.5) may return only the retained ones. + Entries []acs.ContextEntry + // Head is the entry_hash of the last entry, empty before the first. + Head string + // Intent is the session's Intent, nil until one is established. + Intent *acs.Intent + // Closed is set once steps/sessionEnd has been appended. + Closed bool + // Deferrals is the number of Defer decisions the session has received. + Deferrals int + // AskSteps are the step_ids the session was answered Ask for, which an + // approver's intent extension must name (§9.1). + AskSteps []string + // Lineage maps every step_id to the provenance_ids its payload carried, + // which a postCompact summary's derived_from must equal the union of. A + // store that archives Entries keeps this map so compacted steps remain + // identifiable. + Lineage map[string][]string + // PolicyState is what the policy engine keeps across the session's + // steps, nil until the engine keeps anything. + PolicyState json.RawMessage +} + +// Handshake is a completed handshake: what the Observed Agent offered and +// what the Guardian answered. +type Handshake struct { + Client acs.ClientHello + Server acs.ServerHello + // AgentID is the authenticated agent_id that opened the session. Every + // later step must carry the same value, so one signed session has one + // policy identity. + AgentID string + // KeyID is the key_id the handshake was signed with. Every later step + // of the session must be signed with the same key, so one holder of a + // key cannot write into a session another key opened. + KeyID string +} + +// Reservation is the request_id, and the nonce when the request carried one, +// that a request consumes in its session (§10.3). +type Reservation struct { + RequestID string + Nonce string +} + +// Append is one ContextEntry and the session changes that go with it. +type Append struct { + Reservation + // ExpectedHead is the head Entry was sealed against, empty for the + // first entry of the session. + ExpectedHead string + Entry acs.ContextEntry + // Lineage is the provenance_ids the step's payload carried. Append adds + // them to the IDs already recorded under Entry.StepID because §9.1 gives an + // intent_extension the step_id of the ASK it answers. + Lineage []string + Intent *acs.Intent + Close bool +} + +// Conclusion is what a step's final decision leaves. +type Conclusion struct { + // ExpectedHead is the session's head after the step's entries. + ExpectedHead string + // AskStepID, when set, is recorded in Session.AskSteps. + AskStepID string + // Deferred counts one Defer decision. + Deferred bool + // PolicyState, when not nil, replaces Session.PolicyState. + PolicyState json.RawMessage + // ApproveSkill, when not nil, records an allowed steps/skillRegister, + // keyed by the pair steps/skillLoad is bound against, for every session. + ApproveSkill *SkillApproval +} + +// SkillApproval is the durable correlation key of an approved skill +// registration: the skill_id and the digest of its artifact +// (skill-load.json). +type SkillApproval struct { + SkillID string + DigestAlgorithm string + DigestValue string +} + +// Errors a SessionContextStore returns. +var ( + // ErrSessionLockLost is the cause of lockCtx ending when a store detects + // that another owner may write the session. + ErrSessionLockLost = errors.New("session lock lost") + ErrReplayDetected = errors.New("request_id or nonce already seen in this session") + ErrHeadMoved = errors.New("session head is not the expected head") + ErrAlreadyNegotiated = errors.New("session already negotiated") + ErrSessionNotFound = errors.New("session not found") + ErrStoreFull = errors.New("the store is at capacity") +) diff --git a/reference-implementations/agt-go/guardian/signer.go b/reference-implementations/agt-go/guardian/signer.go new file mode 100644 index 00000000..5db2d4ea --- /dev/null +++ b/reference-implementations/agt-go/guardian/signer.go @@ -0,0 +1,42 @@ +package guardian + +import ( + "context" + "errors" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// Signer verifies signed requests and signs responses to authenticated +// requests (§10). system/ping may be unsigned; responses produced before +// authentication are not signed. Unlike crypto.Signer, this interface verifies +// as well as signs; whoever implements it holds the keys, and the keys never +// leave it. +// +// The Guardian computes the signed input, the RFC 8785 canonicalization of +// the envelope with its signature removed, and a Signer only signs or checks +// those bytes, so no implementation can change what a signature covers. The +// Guardian may call Verify and Sign concurrently. Verify and Sign must return +// when ctx ends. Algorithms is read once by New; the Guardian owns its copy of +// the returned slice. +type Signer interface { + // Algorithms lists the signature algorithms Verify accepts, declared in + // the ServerHello as signature_algorithms_supported. + Algorithms() []string + + // Verify checks sig over input, the signed input of a request in the + // session sessionID. It returns an error wrapping ErrSignatureInvalid + // when the signature does not verify; any other error means the Signer + // could not decide, and the request is refused all the same. + Verify(ctx context.Context, sessionID string, input []byte, sig acs.Signature) error + + // Sign signs input, the signed input of a response in sessionID. + // verifiedKeyID names the key that verified the request. A symmetric + // signer may answer with that key; an asymmetric signer chooses its own + // response key. The returned Signature.KeyID identifies the actual key. + // verifiedKeyID is empty for an unsigned system/ping request. + Sign(ctx context.Context, sessionID, verifiedKeyID string, input []byte) (acs.Signature, error) +} + +// ErrSignatureInvalid reports a signature that does not verify. +var ErrSignatureInvalid = errors.New("signature invalid") diff --git a/reference-implementations/agt-go/guardian/step.go b/reference-implementations/agt-go/guardian/step.go new file mode 100644 index 00000000..146b5ec8 --- /dev/null +++ b/reference-implementations/agt-go/guardian/step.go @@ -0,0 +1,764 @@ +package guardian + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/chain" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/handshake" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" +) + +var denial = disposition.Deny + +// handshake negotiates a session (§4) and records it once. +func (g *Guardian) handshake(ctx context.Context, in *call, req acs.Request) reply { + var hello acs.ClientHello + if err := g.payload(in.Inbound, &hello); err != nil { + return errorReply(in.ID, acs.InvalidParams, "client_hello_invalid", "the ClientHello fails handshake.json: "+err.Error()) + } + lockCtx, release, err := g.cfg.Store.Lock(ctx, in.SessionID) + if err != nil { + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "store lock", err) + return errorReply(in.ID, acs.InternalError, "store_failed", "the session could not be recorded") + } + defer release() + ctx = lockCtx + sess, exists, err := g.cfg.Store.Load(ctx, in.SessionID) + if err != nil { + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "store load", err) + return errorReply(in.ID, acs.InternalError, "store_failed", "the session could not be read") + } + if exists { + if outside := g.timestampOutsideWindow(in, sess.Handshake.Server.SkewWindowMS); outside != nil { + return *outside + } + switch err := g.cfg.Store.Negotiate(ctx, in.SessionID, reservation(req), sess.Handshake); { + case errors.Is(err, ErrReplayDetected): + return replayDetected(in) + case err == nil: + err = errors.New("store negotiated a session that Load reported already exists") + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "store negotiate", err) + return errorReply(in.ID, acs.InternalError, "store_failed", "the session could not be recorded") + case !errors.Is(err, ErrAlreadyNegotiated): + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "store negotiate", err) + return errorReply(in.ID, acs.InternalError, "store_failed", "the session could not be recorded") + } + return g.handshakeAgain(ctx, in, req, hello, sess) + } + if outside := g.timestampOutsideWindow(in, g.cfg.SkewWindow.Milliseconds()); outside != nil { + return *outside + } + server, refusal := handshake.Negotiate(hello, g.offer()) + if refusal != nil { + data := refusal.Data + return reply{id: in.ID, err: &acs.Error{Code: refusal.Code, Message: data.Message, Data: &data}} + } + err = g.cfg.Store.Negotiate(ctx, in.SessionID, reservation(req), Handshake{Client: hello, Server: server, AgentID: req.Params.Metadata.AgentID, KeyID: in.KeyID}) + switch { + case errors.Is(err, ErrReplayDetected): + return replayDetected(in) + case errors.Is(err, ErrAlreadyNegotiated): + sess, ok, loadErr := g.cfg.Store.Load(ctx, in.SessionID) + if loadErr != nil || !ok { + if loadErr != nil { + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "store load", loadErr) + } + return errorReply(in.ID, acs.InternalError, "store_failed", "the session could not be read") + } + return g.handshakeAgain(ctx, in, req, hello, sess) + case errors.Is(err, ErrStoreFull): + return errorReply(in.ID, acs.SessionRefused, "capacity", "the Guardian holds as many sessions as it can; retry later") + case err != nil: + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "store negotiate", err) + return errorReply(in.ID, acs.InternalError, "store_failed", "the session could not be recorded") + } + return reply{id: in.ID, result: &server, sessionID: in.SessionID, keyID: in.KeyID} +} + +// handshakeAgain answers a handshake for a session that exists. An Observed +// Agent that restarts has lost its ServerHello; the same ClientHello, signed +// by the key that opened the open session, gets the negotiated ServerHello +// back and changes nothing. Anything else would renegotiate, which v0.1 does +// not define, and is refused. +func (g *Guardian) handshakeAgain(ctx context.Context, in *call, req acs.Request, hello acs.ClientHello, sess Session) reply { + refused := errorReply(in.ID, acs.SessionRefused, "already_negotiated", "the session is already negotiated with other terms, or has ended; v0.1 defines no renegotiation, so start a new session") + if sess.Closed || sess.Handshake.KeyID != in.KeyID || sess.Handshake.AgentID != req.Params.Metadata.AgentID { + return refused + } + if !sameClientHello(sess.Handshake.Client, hello) { + return refused + } + switch err := g.cfg.Store.Reserve(ctx, in.SessionID, reservation(req)); { + case errors.Is(err, ErrReplayDetected): + return replayDetected(in) + case errors.Is(err, ErrStoreFull): + return errorReply(in.ID, acs.SessionRefused, "capacity", "the session holds as many replay reservations as this Guardian keeps; start a new session") + case err != nil: + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "store reserve", err) + return errorReply(in.ID, acs.InternalError, "store_failed", "the session could not be recorded") + } + server := sess.Handshake.Server + return reply{id: in.ID, result: &server, sessionID: in.SessionID, keyID: in.KeyID} +} + +func (g *Guardian) offer() handshake.Offer { + return handshake.Offer{ + Version: acs.Version, + Hooks: method.Hooks(), + Wrapped: g.methods, + Transport: g.cfg.Transport, + SignatureAlgorithms: slices.Clone(g.algorithms), + TimeoutConfig: acs.TimeoutConfig{DefaultMS: g.cfg.DecisionTimeout.Milliseconds()}, + SkewWindowMS: g.cfg.SkewWindow.Milliseconds(), + OnDecisionFailure: g.cfg.OnDecisionFailure, + ApproverTypes: g.policy.ApproverTypes, + PolicyRequiresProvenance: g.policy.RequiresProvenance, + } +} + +func reservation(req acs.Request) Reservation { + r := Reservation{RequestID: req.Params.RequestID} + if req.Params.Nonce != nil { + r.Nonce = *req.Params.Nonce + } + return r +} + +func replayDetected(in *call) reply { + return errorReply(in.ID, acs.ReplayDetected, "replay_detected", "request_id or nonce was already used in this session") +} + +// step governs one hook or wrapped call under the session's lock: the +// ContextEntry is appended before the engine is asked, so a denied or +// failed step is in the chain, and everything the decision leaves is +// recorded before the answer is sent. +// +// Once a request is authenticated and names a negotiated method, every +// failure of the Guardian or its providers is answered with a signed DENY: +// an error would leave the Observed Agent to its failure posture, which by +// default proceeds. +func (g *Guardian) step(ctx context.Context, in *call, req acs.Request) (r reply) { + chainHash := "" + defer func() { + if p := recover(); p != nil { + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "step panic", fmt.Errorf("panic: %v", p)) + r = g.answerStep(ctx, in, denial(disposition.ReasonEvaluationFailed, "the Guardian failed while deciding the step"), chainHash) + } + }() + + lockCtx, release, err := g.cfg.Store.Lock(ctx, in.SessionID) + if err != nil { + return g.storeFailed(ctx, in, "store lock", err) + } + defer release() + ctx = lockCtx + + sess, ok, err := g.cfg.Store.Load(ctx, in.SessionID) + switch { + case err != nil: + return g.storeFailed(ctx, in, "store load", err) + case !ok: + return notNegotiated(in.ID, req.Method, "the session has not completed handshake/hello") + case sess.Handshake.KeyID != in.KeyID: + // The signature verified, so the answer is a signed decision: an + // error would leave the agent to its failure posture. Nothing is + // appended, since the session belongs to another key. + return g.answerStep(ctx, in, denial(disposition.ReasonKeyNotBound, "the request is signed with key_id "+in.KeyID+", and the session was opened with another; a session answers only the key that opened it"), "") + case sess.Handshake.AgentID != req.Params.Metadata.AgentID: + return g.answerStep(ctx, in, denial(disposition.ReasonAgentIDNotBound, "the request names agent_id "+req.Params.Metadata.AgentID+", and the session was opened by another agent; a session answers only the agent that opened it"), "") + } + chainHash = sess.Head + if outside := g.timestampOutsideWindow(in, sess.Handshake.Server.SkewWindowMS); outside != nil { + return *outside + } + switch { + case !slices.Contains(sess.Handshake.Server.MethodsEvaluated, req.Method): + return g.reserveRejected(ctx, in, req, notNegotiated(in.ID, req.Method, req.Method+" was not negotiated for this session")) + case !handshake.Compatible(req.Params.ACSVersion, sess.Handshake.Server.NegotiatedVersion): + r := errorReply(in.ID, acs.UnsupportedVersion, "unsupported_version", "acs_version "+req.Params.ACSVersion+" is not compatible with the negotiated version") + r.err.Data.SupportedVersions = []string{sess.Handshake.Server.NegotiatedVersion} + return g.reserveRejected(ctx, in, req, r) + } + if err := g.checkPayload(in, req); err != nil { + return g.reserveRejected(ctx, in, req, errorReply(in.ID, acs.InvalidParams, "payload_invalid", "the payload fails its schema: "+err.Error())) + } + if sess.Closed { + switch err := g.cfg.Store.Reserve(ctx, in.SessionID, reservation(req)); { + case errors.Is(err, ErrReplayDetected): + return replayDetected(in) + case errors.Is(err, ErrStoreFull): + return g.answerStep(ctx, in, denial(disposition.ReasonStoreFull, "the session holds as many replay reservations as this Guardian keeps"), sess.Head) + case err != nil: + return g.storeFailed(ctx, in, "store reserve", err) + } + return g.answerStep(ctx, in, denial(disposition.ReasonSessionClosed, "the session has ended; no step enters after steps/sessionEnd"), "") + } + + established, pre, err := g.preDecision(ctx, in, req, sess) + if err != nil { + return g.storeFailed(ctx, in, "store skill lookup", err) + } + entry, err := g.appendEntry(ctx, in, req, sess, established) + switch { + case errors.Is(err, ErrReplayDetected): + return replayDetected(in) + case errors.Is(err, ErrHeadMoved): + return g.answerStep(ctx, in, denial(disposition.ReasonSessionContended, "another writer extended the session while this step held its lock"), sess.Head) + case errors.Is(err, ErrStoreFull): + return g.answerStep(ctx, in, denial(disposition.ReasonStoreFull, "the session holds as many steps as this Guardian keeps"), sess.Head) + case err != nil: + return g.storeFailed(ctx, in, "store append", err) + } + g.recordUnresolvedReference(ctx, in, req, sess.Entries) + sess.Entries = append(slices.Clip(sess.Entries), entry) + sess.Head = entry.EntryHash + chainHash = sess.Head + if established != nil { + sess.Intent = established + } + + var pd PolicyDecision + if pre != nil { + pd.Decision = *pre + } else { + pd = g.decide(ctx, in, req, sess) + } + if _, ok := g.decisionReply(in, pd.Decision, sess.Head); !ok { + err := errors.New("the policy engine returned a decision that fails response-envelope.json") + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy engine decision", err) + pd = PolicyDecision{Decision: denial(disposition.ReasonEvaluationFailed, "the policy engine returned a decision that cannot be sent")} + } + d := g.guardianDecision(ctx, req.Method, in.SessionID, in.RequestID, g.substitute(ctx, in, req, sess, pd)) + if _, ok := g.decisionReply(in, d, sess.Head); !ok { + err := errors.New("the final decision fails response-envelope.json") + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy engine decision", err) + pd = PolicyDecision{Decision: denial(disposition.ReasonEvaluationFailed, "the Guardian could not produce a valid decision")} + d = g.guardianDecision(ctx, req.Method, in.SessionID, in.RequestID, + pd.Decision) + } + + if pd.Grant != nil && d.Disposition != acs.Deny { + head, err := g.grant(ctx, in, req, sess, *pd.Grant) + switch { + case errors.Is(err, errGrantInvalid): + pd = PolicyDecision{} + d = g.guardianDecision(ctx, req.Method, in.SessionID, in.RequestID, denial(disposition.ReasonGrantInvalid, err.Error())) + case err != nil: + return g.storeFailed(ctx, in, "store append intent extension", err, sess.Head) + default: + sess.Head = head + chainHash = head + } + } + switch err := g.conclude(ctx, in, req, pd, d, sess.Head); { + case errors.Is(err, ErrHeadMoved): + return g.answerStep(ctx, in, denial(disposition.ReasonSessionContended, "another writer extended the session while this step held its lock"), sess.Head) + case errors.Is(err, ErrStoreFull): + return g.answerStep(ctx, in, denial(disposition.ReasonStoreFull, "the Guardian holds as many approved skills as it keeps"), sess.Head) + case err != nil: + return g.storeFailed(ctx, in, "store conclude", err, sess.Head) + } + return g.answerStep(ctx, in, d, sess.Head) +} + +func (g *Guardian) reserveRejected(ctx context.Context, in *call, req acs.Request, rejected reply) reply { + switch err := g.cfg.Store.Reserve(ctx, in.SessionID, reservation(req)); { + case errors.Is(err, ErrReplayDetected): + return replayDetected(in) + case errors.Is(err, ErrStoreFull): + return g.answerStep(ctx, in, denial(disposition.ReasonStoreFull, "the session holds as many replay reservations as this Guardian keeps"), "") + case err != nil: + return g.storeFailed(ctx, in, "store reserve", err) + default: + return rejected + } +} + +// storeFailed answers an authenticated step the store failed: the detail is +// recorded, and the Observed Agent receives a signed DENY. +func (g *Guardian) storeFailed(ctx context.Context, in *call, what string, err error, chainHash ...string) reply { + g.failure(ctx, in.Method, in.SessionID, in.RequestID, what, err) + reason, message := disposition.ReasonStoreUnavailable, "the Guardian's session store failed" + if g.stop.Err() != nil { + reason, message = disposition.ReasonShuttingDown, "the Guardian stopped before it could decide" + } + head := "" + if len(chainHash) > 0 { + head = chainHash[0] + } + return g.answerStep(ctx, in, g.guardianDecision(ctx, in.Method, in.SessionID, in.RequestID, denial(reason, message)), head) +} + +// checkPayload validates the payload: a native hook against its schema, a +// wrapped call by its protocol's handler. +func (g *Guardian) checkPayload(in *call, req acs.Request) error { + if g.methods.Route(req.Method) == method.Hook { + return g.payload(in.Inbound, nil) + } + h, wrapped, ok := g.methods.WrappedHandler(req.Method) + if !ok { + return fmt.Errorf("no handler for %s", req.Method) + } + return h.Check(wrapped, req.Params.Payload) +} + +// preDecision applies the rules the standard gives the Guardian itself, +// before any policy: the Intent a step establishes or would change (§8.4), +// the Observed Agent's view of the chain (§8.6), and a skill load's binding +// to an approved registration (skill-load.json). A non-nil decision +// replaces the engine's. +func (g *Guardian) preDecision(ctx context.Context, in *call, req acs.Request, sess Session) (*acs.Intent, *acs.Decision, error) { + established, intentErr := g.intent(req, sess.Intent) + switch { + case intentErr != nil: + g.event(ctx, EventIntentMutationRejected, req.Method, in.SessionID, in.RequestID, intentErr.Error()) + d := denial(disposition.ReasonIntentMismatch, intentErr.Error()) + return nil, &d, nil + case chainMismatch(req, sess.Head): + message := fmt.Sprintf("the client's chain_hash %s is not the session's head %s", *req.Params.Metadata.SessionState.ChainHash, sess.Head) + g.event(ctx, EventChainMismatch, req.Method, in.SessionID, in.RequestID, message) + d := denial(disposition.ReasonChainMismatch, message) + return established, &d, nil + case req.Method == acs.StepSkillLoad: + var p acs.SkillLoadPayload + if err := jsonv2.Unmarshal(req.Params.Payload, &p); err != nil { + return nil, nil, err + } + approved, err := g.cfg.Store.SkillApproved(ctx, skillApproval(p.SkillID, p.Digest)) + if err != nil { + return nil, nil, err + } + if !approved { + d := denial(disposition.ReasonSkillUnverifiable, "no approved steps/skillRegister matches skill_id "+p.SkillID+" with this digest") + return established, &d, nil + } + } + return established, nil, nil +} + +func skillApproval(skillID string, d acs.Digest) SkillApproval { + return SkillApproval{SkillID: skillID, DigestAlgorithm: d.Algorithm, DigestValue: d.Value} +} + +// intent returns the Intent a step establishes (§8.4), or an error when the +// step would change an established one. Only steps/sessionStart and +// steps/agentTrigger carry an Intent. +func (g *Guardian) intent(req acs.Request, current *acs.Intent) (*acs.Intent, error) { + var carried *acs.Intent + switch req.Method { + case acs.StepSessionStart: + var p acs.SessionStartPayload + if err := jsonv2.Unmarshal(req.Params.Payload, &p); err != nil { + return nil, err + } + carried = p.Intent + case acs.StepAgentTrigger: + var p acs.AgentTriggerPayload + if err := jsonv2.Unmarshal(req.Params.Payload, &p); err != nil { + return nil, err + } + carried = p.Intent + } + switch { + case carried == nil: + return nil, nil + case current == nil: + return carried, nil + case sameIntent(*carried, *current): + return nil, nil + } + return nil, errors.New("the step carries an Intent that differs from the session's established Intent; Intent.parsed grows only through an approver's intent_extension (§8.4)") +} + +func sameClientHello(a, b acs.ClientHello) bool { + aMethods, bMethods := a.MethodsImplemented, b.MethodsImplemented + aVersions, bVersions := a.ACSVersionsSupported, b.ACSVersionsSupported + aTransports, bTransports := a.TransportsSupported, b.TransportsSupported + aProfiles, bProfiles := a.ProfilesSupported, b.ProfilesSupported + aWrapped, bWrapped := a.WrappedProtocols, b.WrappedProtocols + a.MethodsImplemented, b.MethodsImplemented = nil, nil + a.ACSVersionsSupported, b.ACSVersionsSupported = nil, nil + a.TransportsSupported, b.TransportsSupported = nil, nil + a.ProfilesSupported, b.ProfilesSupported = nil, nil + a.WrappedProtocols, b.WrappedProtocols = nil, nil + return reflect.DeepEqual(a, b) && sameStrings(aMethods, bMethods) && sameStrings(aVersions, bVersions) && + sameStrings(aTransports, bTransports) && sameStrings(aProfiles, bProfiles) && sameWrapped(aWrapped, bWrapped) +} + +func sameIntent(a, b acs.Intent) bool { + aParsed, bParsed := a.Parsed, b.Parsed + a.Parsed, b.Parsed = nil, nil + return reflect.DeepEqual(a, b) && sameCapabilities(aParsed, bParsed) +} + +func sameStrings(a, b []string) bool { + return sameSet(a, b) +} + +func sameWrapped(a, b []acs.WrappedProtocol) bool { + return sameSet(a, b) +} + +func sameCapabilities(a, b []acs.Capability) bool { + return sameSet(a, b) +} + +func sameSet[T comparable](a, b []T) bool { + aSet := make(map[T]struct{}, len(a)) + bSet := make(map[T]struct{}, len(b)) + for _, value := range a { + aSet[value] = struct{}{} + } + for _, value := range b { + bSet[value] = struct{}{} + } + return reflect.DeepEqual(aSet, bSet) +} + +func chainMismatch(req acs.Request, head string) bool { + state := req.Params.Metadata.SessionState + return state != nil && state.ChainHash != nil && *state.ChainHash != head +} + +// recordUnresolvedReference records a toolCallResult whose request_id_ref +// names no toolCallRequest among entries, the session's entries before this +// step (hooks.md). A store that archives entries holds fewer, and a +// reference to an archived request is recorded too. +func (g *Guardian) recordUnresolvedReference(ctx context.Context, in *call, req acs.Request, entries []acs.ContextEntry) { + if req.Method != acs.StepToolCallResult { + return + } + var p acs.ToolCallResultPayload + if jsonv2.Unmarshal(req.Params.Payload, &p) != nil || p.RequestIDRef == nil { + return + } + for _, e := range entries { + if e.StepType == acs.StepToolCallRequest && e.StepID == *p.RequestIDRef { + return + } + } + g.event(ctx, EventRequestIDRefUnresolved, req.Method, in.SessionID, in.RequestID, + fmt.Sprintf("request_id_ref %q names no toolCallRequest this Guardian holds for the session", *p.RequestIDRef)) +} + +// appendEntry seals the step's ContextEntry against the session's head and +// appends it with the step's reservation and lineage, in one store +// operation. +func (g *Guardian) appendEntry(ctx context.Context, in *call, req acs.Request, sess Session, intent *acs.Intent) (acs.ContextEntry, error) { + requestHash, err := chain.RequestHash(in.Params) + if err != nil { + return acs.ContextEntry{}, err + } + e := acs.ContextEntry{ + EntryID: req.Params.RequestID, + StepID: req.Params.RequestID, + StepType: req.Method, + RequestHash: requestHash, + Timestamp: g.cfg.Now().UTC().Format(time.RFC3339Nano), + } + if req.Params.Metadata.TurnID != nil { + e.TurnID = *req.Params.Metadata.TurnID + } + entry, err := chain.Seal(e, sess.Head) + if err != nil { + return acs.ContextEntry{}, err + } + lineage, err := provenanceIDs(req.Params.Payload) + if err != nil { + return acs.ContextEntry{}, err + } + return entry, g.cfg.Store.Append(ctx, in.SessionID, Append{ + Reservation: reservation(req), + ExpectedHead: sess.Head, + Entry: entry, + Lineage: lineage, + Intent: intent, + Close: req.Method == acs.StepSessionEnd, + }) +} + +// decide asks the engine. A failure, a delegation this Guardian cannot +// serve and a decision unfit to send are all answered DENY. +func (g *Guardian) decide(ctx context.Context, in *call, req acs.Request, sess Session) PolicyDecision { + input, err := cloneJSON(PolicyInput{Request: req, Session: SessionContext{ + SessionID: in.SessionID, + ChainHash: sess.Head, + Entries: sess.Entries, + Intent: sess.Intent, + Handshake: sess.Handshake, + PolicyState: sess.PolicyState, + }}) + if sess.PolicyState == nil { + input.Session.PolicyState = nil + } + if err != nil { + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy input", err) + return PolicyDecision{Decision: denial(disposition.ReasonEvaluationFailed, "the Guardian could not isolate the policy input")} + } + pd, err := g.callEngine(ctx, negotiatedTimeout(sess.Handshake.Server, req.Method), input) + switch { + case errors.Is(err, errEngineSaturated): + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy engine", err) + return PolicyDecision{Decision: denial(disposition.ReasonEngineSaturated, "the Guardian is running as many policy evaluations as it allows")} + case err != nil: + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy engine", err) + if g.stop.Err() != nil { + return PolicyDecision{Decision: denial(disposition.ReasonShuttingDown, "the Guardian stopped before the policy engine decided")} + } + return PolicyDecision{Decision: denial(disposition.ReasonEvaluationFailed, "the policy engine failed")} + case pd.DelegateToAgent: + return PolicyDecision{Decision: denial(disposition.ReasonAgentLayerUnavailable, "the policy delegated this step to an agent layer, which this Guardian does not have (§12.2)")} + case len(pd.State) > g.cfg.MaxPolicyStateBytes: + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy engine", fmt.Errorf("state of %d bytes exceeds %d", len(pd.State), g.cfg.MaxPolicyStateBytes)) + return PolicyDecision{Decision: denial(disposition.ReasonPolicyStateTooLarge, "the policy's state for this session outgrew what this Guardian keeps")} + case pd.State != nil && !json.Valid(pd.State): + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy engine", errors.New("state is not valid JSON")) + return PolicyDecision{Decision: denial(disposition.ReasonEvaluationFailed, "the policy engine returned state that is not valid JSON")} + } + pd.Decision = disposition.WithDeferDefaults(pd.Decision) + if err := g.fit(req.Method, sess.Handshake.Server.ApproverTypesSupported, pd.Decision); err != nil { + g.failure(ctx, req.Method, in.SessionID, in.RequestID, "policy engine decision", err) + return PolicyDecision{Decision: denial(disposition.ReasonEvaluationFailed, "the policy engine returned a decision that cannot be sent: "+err.Error())} + } + if req.Method == acs.StepPostCompact { + pd.Decision = g.checkLineage(ctx, in, req, sess, pd.Decision) + } + return pd +} + +// substitute applies the standard's rules to the engine's decision: what a +// client that cannot apply a disposition receives (§6.5, §9.2), and the +// bound on a session's deferrals (§6). +func (g *Guardian) substitute(ctx context.Context, in *call, req acs.Request, sess Session, pd PolicyDecision) acs.Decision { + d := pd.Decision + switch { + case d.Disposition == acs.Modify && pd.Client.CannotApplyModify: + g.event(ctx, EventModifyUnsupported, req.Method, in.SessionID, in.RequestID, "modification not delivered: "+d.Reasoning) + if req.Method == acs.StepPostCompact { + return disposition.ModifyUnsupportedAtPostCompact(d) + } + return disposition.ModifyUnsupported(d) + case d.Disposition == acs.Ask: + substitution := g.cfg.AskSubstitution + if substitution == AskSubstitutionNone && pd.Client.CannotResolveAsk { + substitution = AskSubstitutionDeny + if pd.Client.AskSubstitute == acs.Defer { + substitution = AskSubstitutionDefer + } + } + if substitution != AskSubstitutionNone { + replacement := disposition.AskAsDeny(d) + if substitution == AskSubstitutionDefer { + replacement = disposition.AskAsDefer(d) + } + g.event(ctx, EventAskSubstituted, req.Method, in.SessionID, in.RequestID, fmt.Sprintf("ask answered %s for a client that cannot route an approval", replacement.Disposition)) + d = replacement + } + } + if d.Disposition == acs.Defer && sess.Deferrals+1 > g.cfg.MaxDeferrals { + return denial(disposition.ReasonDeferralBoundExceeded, fmt.Sprintf("the session has deferred %d times, the most this Guardian allows (§6)", sess.Deferrals)) + } + return d +} + +// conclude records what the final decision leaves in the session: an ASK +// step an approver may answer, a deferral, the engine's state, and an +// approved skill registration. +func (g *Guardian) conclude(ctx context.Context, in *call, req acs.Request, pd PolicyDecision, d acs.Decision, head string) error { + c := Conclusion{ExpectedHead: head, Deferred: d.Disposition == acs.Defer, PolicyState: pd.State} + if d.Disposition == acs.Ask { + c.AskStepID = req.Params.RequestID + } + if req.Method == acs.StepSkillRegister && (d.Disposition == acs.Allow || d.Disposition == acs.Modify) { + var p acs.SkillRegisterPayload + if err := jsonv2.Unmarshal(req.Params.Payload, &p); err != nil { + return err + } + approval := skillApproval(p.SkillID, p.Definition.Digest) + c.ApproveSkill = &approval + } + if !c.Deferred && c.AskStepID == "" && c.PolicyState == nil && c.ApproveSkill == nil { + return nil + } + return g.cfg.Store.Conclude(ctx, in.SessionID, c) +} + +var errEngineSaturated = errors.New("every engine call place is taken") + +// callEngine runs the engine under the decision timeout and ctx, which +// Shutdown cancels, and turns a panic into an error. It stops waiting when +// ctx ends even if the engine does not, so an engine that ignores +// cancellation cannot hold the answer; the call keeps its place in +// engineCalls until the engine returns, and gives it back before its +// outcome is delivered, so the step that answer lets a client send next +// never finds that place still taken. +func (g *Guardian) callEngine(ctx context.Context, timeout time.Duration, in PolicyInput) (PolicyDecision, error) { + select { + case g.engineCalls <- struct{}{}: + default: + return PolicyDecision{}, errEngineSaturated + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + type outcome struct { + pd PolicyDecision + err error + } + done := make(chan outcome, 1) + go func() { + var o outcome + func() { + defer func() { + if p := recover(); p != nil { + o = outcome{err: fmt.Errorf("panic: %v", p)} + } + }() + o.pd, o.err = g.cfg.Engine.Decide(ctx, in) + }() + <-g.engineCalls + done <- o + }() + select { + case o := <-done: + if o.err == nil && ctx.Err() != nil { + o.err = context.Cause(ctx) + } + return o.pd, o.err + case <-ctx.Done(): + return PolicyDecision{}, context.Cause(ctx) + } +} + +// fit checks an engine's decision against the rules of §6 and §6.3 and +// against what the ServerHello declared. +func (g *Guardian) fit(methodName string, approverTypes []acs.ApproverType, d acs.Decision) error { + check := disposition.Check + if handler, _, ok := g.methods.WrappedHandler(methodName); ok { + check = func(d acs.Decision) error { + return disposition.CheckWithOverridePointer(d, handler.ParameterOverridePointer) + } + } + if err := check(d); err != nil { + return err + } + if d.Disposition == acs.Ask && !slices.Contains(approverTypes, d.AskDetails.Approver.Type) { + return fmt.Errorf("approver type %q is not in approver_types_supported", d.AskDetails.Approver.Type) + } + return nil +} + +var errGrantInvalid = errors.New("the intent extension is not valid") + +// grant records an approver's intent extension (§9.1). The engine has +// authenticated the approver and applied the session's scope_mode, which +// are policy; the Guardian checks that the grant answers an ASK this +// session was given and names an approver type it routes to. A +// session-scoped extension grows Intent.parsed and is written as its own +// ContextEntry holding the approver, the capabilities and the extension's +// provenance; one scoped to this request changes nothing that persists. It +// returns the session's head after the grant. +func (g *Guardian) grant(ctx context.Context, in *call, req acs.Request, sess Session, grant IntentGrant) (string, error) { + switch { + case !slices.Contains(sess.AskSteps, grant.AskStepID): + return "", fmt.Errorf("%w: step %s was not answered ask in this session", errGrantInvalid, grant.AskStepID) + case !slices.Contains(sess.Handshake.Server.ApproverTypesSupported, grant.Approver.Type): + return "", fmt.Errorf("%w: approver type %q is not in approver_types_supported", errGrantInvalid, grant.Approver.Type) + case len(grant.Extension.Capabilities) == 0: + return "", fmt.Errorf("%w: it grants no capability", errGrantInvalid) + case grant.Extension.Scope != acs.ScopeThisRequest && grant.Extension.Scope != acs.ScopeSession: + return "", fmt.Errorf("%w: intent extension scope %q is not defined", errGrantInvalid, grant.Extension.Scope) + } + if grant.Extension.Scope != acs.ScopeSession { + return sess.Head, nil + } + intent := acs.Intent{} + if sess.Intent != nil { + intent = *sess.Intent + } + intent.Parsed = append(slices.Clip(intent.Parsed), grant.Extension.Capabilities...) + approver, extension := grant.Approver, grant.Extension + entry, err := chain.Seal(acs.ContextEntry{ + EntryID: req.Params.RequestID + "/" + acs.StepTypeIntentExtension, + StepID: grant.AskStepID, + StepType: acs.StepTypeIntentExtension, + Timestamp: g.cfg.Now().UTC().Format(time.RFC3339Nano), + Approver: &approver, + IntentExtension: &extension, + }, sess.Head) + if err != nil { + return "", err + } + var lineage []string + if extension.Provenance != nil { + lineage = []string{extension.Provenance.ProvenanceID} + } + err = g.cfg.Store.Append(ctx, in.SessionID, Append{ + Reservation: Reservation{RequestID: entry.EntryID}, + ExpectedHead: sess.Head, + Entry: entry, + Lineage: lineage, + Intent: &intent, + }) + if err != nil { + return "", err + } + return entry.EntryHash, nil +} + +func negotiatedTimeout(server acs.ServerHello, methodName string) time.Duration { + milliseconds := server.TimeoutConfig.DefaultMS + if override, ok := server.TimeoutConfig.PerMethodMS[methodName]; ok { + milliseconds = override + } + return time.Duration(milliseconds) * time.Millisecond +} + +// guardianDecision applies the hook's permitted dispositions (hooks.md): a +// disposition the hook does not permit becomes DENY where DENY is permitted, +// and ALLOW with the reason recorded where it is not, as at an audit-only +// hook. +func (g *Guardian) guardianDecision(ctx context.Context, methodName, sessionID, requestID string, d acs.Decision) acs.Decision { + rule := g.methods.HookRule(methodName) + if rule.Permits(d.Disposition) { + return d + } + message := fmt.Sprintf("%s is not a disposition %s permits", d.Disposition, methodName) + if d.Reasoning != "" { + message += ": " + d.Reasoning + } + g.event(ctx, EventDispositionNotPermitted, methodName, sessionID, requestID, message) + codes := append(slices.Clip(d.ReasonCodes), disposition.ReasonDispositionNotPermitted) + if rule.Permits(acs.Deny) { + out := denial(disposition.ReasonDispositionNotPermitted, message) + out.ReasonCodes = codes + out.PolicyReferences = d.PolicyReferences + return out + } + return acs.Decision{Disposition: acs.Allow, Reasoning: message, ReasonCodes: codes, PolicyReferences: d.PolicyReferences} +} + +// answerStep answers a step with its decision and the published head, +// falling back to the Guardian's own denial when the decision would not +// pass the response schema. +func (g *Guardian) answerStep(ctx context.Context, in *call, d acs.Decision, chainHash string) reply { + d = g.guardianDecision(ctx, in.Method, in.SessionID, in.RequestID, d) + if r, ok := g.decisionReply(in, d, chainHash); ok { + return r + } + fallback := g.guardianDecision(ctx, in.Method, in.SessionID, in.RequestID, + denial(disposition.ReasonEvaluationFailed, "the decision does not pass response-envelope.json")) + if r, ok := g.decisionReply(in, fallback, chainHash); ok { + return r + } + return errorReply(in.ID, acs.InternalError, "response_invalid", "the Guardian could not build a valid answer") +} diff --git a/reference-implementations/agt-go/guardian/wrapped_test.go b/reference-implementations/agt-go/guardian/wrapped_test.go new file mode 100644 index 00000000..39a8c6c2 --- /dev/null +++ b/reference-implementations/agt-go/guardian/wrapped_test.go @@ -0,0 +1,128 @@ +package guardian + +import ( + "context" + "encoding/json" + "errors" + "slices" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" +) + +// echoProtocol is a test-only handler registered under the name MCP, the +// only wrapped protocol handshake.json lets a client declare with wrapping +// semantics. It reads its message and nothing else, which is all a handler +// contributes; it implements none of MCP. +type echoProtocol struct{ checked []string } + +func (*echoProtocol) Protocol() string { return "MCP" } + +func (*echoProtocol) Supports(m string) bool { return m == "say" } + +func (p *echoProtocol) Check(m string, payload json.RawMessage) error { + p.checked = append(p.checked, m) + var msg struct { + Text *string `json:"text"` + } + if err := json.Unmarshal(payload, &msg); err != nil || msg.Text == nil { + return errors.New("the test message carries text") + } + return nil +} + +func (*echoProtocol) ParameterOverridePointer(argument string) string { + return "/arguments/" + argument +} + +type allowEngine struct{ methods []string } + +func (*allowEngine) Policy() PolicyDescription { return PolicyDescription{} } + +func (e *allowEngine) Decide(_ context.Context, in PolicyInput) (PolicyDecision, error) { + e.methods = append(e.methods, in.Request.Method) + return PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow}}, nil +} + +func TestWrappedProtocolRouting(t *testing.T) { + const ( + call = "protocols/MCP/say" + explicit = "wrapped:mcp-2025-06-18/say" + unsupported = "protocols/MCP/shout" + ) + hello := observedagent.DefaultHello(append(method.Hooks(), call, explicit, unsupported)...) + hello.WrappedProtocols = []acs.WrappedProtocol{{Protocol: "MCP", Version: "2025-06-18"}} + signer, err := NewHMACSigner(HMACKey{ID: "k", Secret: []byte("0123456789abcdef0123456789abcdef")}) + if err != nil { + t.Fatal(err) + } + start := func(t *testing.T, table *method.Table) (*observedagent.Client, *allowEngine, acs.ServerHello) { + engine := &allowEngine{} + g, err := newGuardian(Config{Engine: engine, Signer: signer}, table) + if err != nil { + t.Fatal(err) + } + c := &observedagent.Client{ + Transport: func(ctx context.Context, body []byte) ([]byte, error) { return g.Handle(ctx, body), nil }, + Signer: signer, AgentID: "a", SessionID: observedagent.NewUUID(), + } + o, err := c.Handshake(context.Background(), hello) + if err != nil || o.Hello == nil { + t.Fatalf("handshake: %v %s", err, o.Raw) + } + return c, engine, *o.Hello + } + + t.Run("registered", func(t *testing.T) { + handler := &echoProtocol{} + c, engine, server := start(t, method.NewTable(handler)) + if !slices.Contains(server.MethodsEvaluated, call) || !slices.Contains(server.MethodsEvaluated, explicit) || slices.Contains(server.MethodsEvaluated, unsupported) { + t.Fatalf("methods_evaluated %v: want %s and %s, not %s", server.MethodsEvaluated, call, explicit, unsupported) + } + for _, m := range []string{call, explicit} { + o, err := c.Send(context.Background(), observedagent.Request{Method: m, Payload: map[string]any{"text": "hi"}}) + if err != nil { + t.Fatal(err) + } + if o.Result == nil || !o.Verified || o.Result.Disposition != acs.Allow || o.Result.ChainHash == "" { + t.Fatalf("%s got %s", m, o.Raw) + } + } + if !slices.Equal(engine.methods, []string{call, explicit}) || !slices.Equal(handler.checked, []string{"say", "say"}) { + t.Fatalf("engine saw %v, handler checked %v", engine.methods, handler.checked) + } + o, err := c.Send(context.Background(), observedagent.Request{Method: call, Payload: map[string]any{}}) + if err != nil { + t.Fatal(err) + } + if o.Error == nil || o.Error.Code != acs.InvalidParams { + t.Fatalf("an invalid wrapped message got %s, want -32602", o.Raw) + } + o, err = c.Send(context.Background(), observedagent.Request{Method: unsupported, Payload: map[string]any{"text": "hi"}}) + if err != nil { + t.Fatal(err) + } + if o.Error == nil || o.Error.Code != acs.CapabilityNotNegotiated { + t.Fatalf("a method the handler does not read got %s, want -32003", o.Raw) + } + }) + + t.Run("unregistered", func(t *testing.T) { + c, engine, server := start(t, method.NewTable()) + if slices.Contains(server.MethodsEvaluated, call) { + t.Fatalf("methods_evaluated %v lists an unregistered protocol", server.MethodsEvaluated) + } + o, err := c.Send(context.Background(), observedagent.Request{Method: call, Payload: map[string]any{"text": "hi"}}) + if err != nil { + t.Fatal(err) + } + if o.Error == nil || o.Error.Code != acs.CapabilityNotNegotiated { + t.Fatalf("got %s, want -32003", o.Raw) + } + if len(engine.methods) != 0 { + t.Fatal("the engine was called") + } + }) +} diff --git a/reference-implementations/agt-go/internal/chain/chain.go b/reference-implementations/agt-go/internal/chain/chain.go new file mode 100644 index 00000000..6d63aec3 --- /dev/null +++ b/reference-implementations/agt-go/internal/chain/chain.go @@ -0,0 +1,106 @@ +// Package chain computes the SessionContext digests of §8: request_hash +// (§8.1) and entry_hash (§8.2). +package chain + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/jcs" +) + +// RequestHash is the lowercase-hex SHA-256 of the RFC 8785 canonicalization +// of a request envelope's params. +func RequestHash(params []byte) (string, error) { + return jcs.SHA256Hex(params) +} + +// EntryHash computes the §8.2 digest of e: +// +// lowercase-hex(SHA-256(content_bytes || prev_hash_bytes)) +// +// where content_bytes is the RFC 8785 canonicalization of e with entry_hash +// and previous_hash removed, and prev_hash_bytes is the raw 32 bytes of +// e.PreviousHash, or nothing when e is the first entry of its session. The +// EntryHash field of e is ignored. +func EntryHash(e acs.ContextEntry) (string, error) { + prev, err := previousBytes(e.PreviousHash) + if err != nil { + return "", err + } + content, err := contentBytes(e) + if err != nil { + return "", err + } + h := sha256.New() + h.Write(content) + h.Write(prev) + return hex.EncodeToString(h.Sum(nil)), nil +} + +// Seal returns e with PreviousHash set to previous and EntryHash computed. +// previous is empty for the first entry of a session. +func Seal(e acs.ContextEntry, previous string) (acs.ContextEntry, error) { + e.PreviousHash = nil + if previous != "" { + e.PreviousHash = &previous + } + hash, err := EntryHash(e) + if err != nil { + return acs.ContextEntry{}, err + } + e.EntryHash = hash + return e, nil +} + +// contentBytes removes the two members through a generic object, so a member +// added to acs.ContextEntry later is committed to without a change here. +func contentBytes(e acs.ContextEntry) ([]byte, error) { + b, err := json.Marshal(e) + if err != nil { + return nil, fmt.Errorf("encode context entry: %w", err) + } + var object map[string]any + if err := json.Unmarshal(b, &object); err != nil { + return nil, fmt.Errorf("decode context entry: %w", err) + } + delete(object, "entry_hash") + delete(object, "previous_hash") + b, err = json.Marshal(object) + if err != nil { + return nil, fmt.Errorf("encode context entry content: %w", err) + } + return jcs.Canonicalize(b) +} + +var errBadPreviousHash = errors.New("previous_hash is not 64 lowercase hex digits") + +func previousBytes(previous *string) ([]byte, error) { + if previous == nil { + return nil, nil + } + if !IsDigest(*previous) { + return nil, fmt.Errorf("%w: %q", errBadPreviousHash, *previous) + } + return hex.DecodeString(*previous) +} + +// IsDigest reports whether s has the shape of a chain digest: 64 lowercase +// hexadecimal digits. +func IsDigest(s string) bool { + if len(s) != 64 { + return false + } + for i := range len(s) { + c := s[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} diff --git a/reference-implementations/agt-go/internal/chain/chain_test.go b/reference-implementations/agt-go/internal/chain/chain_test.go new file mode 100644 index 00000000..b7ddde81 --- /dev/null +++ b/reference-implementations/agt-go/internal/chain/chain_test.go @@ -0,0 +1,177 @@ +package chain + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// testdata/vectors.json was computed from §8.1 and §8.2 with Python's json +// and hashlib, independently of this package: json.dumps with sorted keys, +// compact separators and ensure_ascii=False is the RFC 8785 form for objects +// whose values are strings and integers, which is all the vectors hold. +type vectors struct { + RequestHash struct { + Params jsontext.Value `json:"params"` + Canonical string `json:"canonical"` + Hash string `json:"hash"` + } `json:"request_hash"` + Chain []struct { + Entry acs.ContextEntry `json:"entry"` + PreviousHash *string `json:"previous_hash"` + CanonicalContent string `json:"canonical_content"` + EntryHash string `json:"entry_hash"` + } `json:"chain"` +} + +func loadVectors(t *testing.T) vectors { + t.Helper() + b, err := os.ReadFile("testdata/vectors.json") + if err != nil { + t.Fatal(err) + } + var v vectors + if err := json.Unmarshal(b, &v); err != nil { + t.Fatal(err) + } + if len(v.Chain) != 3 { + t.Fatalf("read %d chain vectors, want 3", len(v.Chain)) + } + return v +} + +func TestRequestHashVector(t *testing.T) { + v := loadVectors(t) + got, err := RequestHash(v.RequestHash.Params) + if err != nil { + t.Fatal(err) + } + if got != v.RequestHash.Hash { + t.Fatalf("got %s, want %s", got, v.RequestHash.Hash) + } +} + +func TestVector(t *testing.T) { + v := loadVectors(t) + previous := "" + for i, vec := range v.Chain { + if (vec.PreviousHash == nil) != (i == 0) { + t.Fatalf("vector %d: only the first entry has no previous_hash", i) + } + content, err := contentBytes(vec.Entry) + if err != nil { + t.Fatal(err) + } + if string(content) != vec.CanonicalContent { + t.Errorf("vector %d content:\n got %s\nwant %s", i, content, vec.CanonicalContent) + } + sealed, err := Seal(vec.Entry, previous) + if err != nil { + t.Fatal(err) + } + if sealed.EntryHash != vec.EntryHash { + t.Errorf("vector %d: entry_hash %s, want %s", i, sealed.EntryHash, vec.EntryHash) + } + if i > 0 && *sealed.PreviousHash != *vec.PreviousHash { + t.Errorf("vector %d: previous_hash %s, want %s", i, *sealed.PreviousHash, *vec.PreviousHash) + } + previous = sealed.EntryHash + } +} + +func TestEntryHashIgnoresItsOwnHash(t *testing.T) { + e := acs.ContextEntry{EntryID: "1", StepID: "s", StepType: acs.StepSessionStart} + a, err := EntryHash(e) + if err != nil { + t.Fatal(err) + } + e.EntryHash = strings.Repeat("f", 64) + b, err := EntryHash(e) + if err != nil { + t.Fatal(err) + } + if a != b { + t.Fatal("entry_hash changed the digest") + } +} + +// TestEntryHashCommitsToEveryMember changes one member at a time and expects +// a different digest, the tamper-evidence §8.2 exists for. +func TestEntryHashCommitsToEveryMember(t *testing.T) { + prev := strings.Repeat("a", 64) + count := int64(1) + base := acs.ContextEntry{ + EntryID: "1", StepID: "s", StepType: acs.StepToolCallRequest, + RequestHash: strings.Repeat("b", 64), Timestamp: "2026-09-19T10:00:00Z", + ProvenanceSummary: &acs.ProvenanceSummary{EntryCount: &count}, + PreviousHash: &prev, + } + want, err := EntryHash(base) + if err != nil { + t.Fatal(err) + } + otherPrev := strings.Repeat("c", 64) + otherCount := int64(2) + tests := []struct { + name string + mutate func(*acs.ContextEntry) + }{ + {"entry_id", func(e *acs.ContextEntry) { e.EntryID = "2" }}, + {"step_id", func(e *acs.ContextEntry) { e.StepID = "t" }}, + {"step_type", func(e *acs.ContextEntry) { e.StepType = acs.StepToolCallResult }}, + {"request_hash", func(e *acs.ContextEntry) { e.RequestHash = strings.Repeat("d", 64) }}, + {"timestamp", func(e *acs.ContextEntry) { e.Timestamp = "2026-09-19T10:00:01Z" }}, + {"provenance_summary", func(e *acs.ContextEntry) { e.ProvenanceSummary = &acs.ProvenanceSummary{EntryCount: &otherCount} }}, + {"turn_id", func(e *acs.ContextEntry) { e.TurnID = "turn-2" }}, + {"approver", func(e *acs.ContextEntry) { + e.Approver = &acs.Approver{Type: acs.ApproverHuman, ID: "approver-1"} + }}, + {"intent_extension", func(e *acs.ContextEntry) { + e.IntentExtension = &acs.IntentExtension{ + Capabilities: []acs.Capability{{Tool: "Bash", Operation: "execute"}}, + Scope: acs.ScopeSession, + } + }}, + {"previous_hash", func(e *acs.ContextEntry) { e.PreviousHash = &otherPrev }}, + {"first_entry", func(e *acs.ContextEntry) { e.PreviousHash = nil }}, + } + changed := map[string]bool{} + for _, tt := range tests { + changed[tt.name] = true + } + entry := reflect.TypeFor[acs.ContextEntry]() + for i := range entry.NumField() { + member, _, _ := strings.Cut(entry.Field(i).Tag.Get("json"), ",") + if member != "entry_hash" && !changed[member] { + t.Errorf("%s is a member of acs.ContextEntry that no case changes", member) + } + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := base + tt.mutate(&e) + got, err := EntryHash(e) + if err != nil { + t.Fatal(err) + } + if got == want { + t.Fatal("digest did not change") + } + }) + } +} + +func TestEntryHashRefusesMalformedPrevious(t *testing.T) { + for _, prev := range []string{"", "abc", strings.Repeat("A", 64), strings.Repeat("g", 64)} { + e := acs.ContextEntry{EntryID: "1", PreviousHash: &prev} + if _, err := EntryHash(e); err == nil { + t.Errorf("accepted previous_hash %q", prev) + } + } +} diff --git a/reference-implementations/agt-go/internal/chain/testdata/vectors.json b/reference-implementations/agt-go/internal/chain/testdata/vectors.json new file mode 100644 index 00000000..15f017cd --- /dev/null +++ b/reference-implementations/agt-go/internal/chain/testdata/vectors.json @@ -0,0 +1,73 @@ +{ + "request_hash": { + "params": { + "acs_version": "0.1.0", + "request_id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "timestamp": "2026-09-19T10:00:00Z", + "metadata": { + "session_id": "5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30", + "agent_id": "agent" + }, + "payload": { + "tool": { + "name": "Bash" + }, + "arguments": { + "command": { + "value": "echo rm -rf /" + } + }, + "raw_command": "echo rm -rf /" + } + }, + "canonical": "{\"acs_version\":\"0.1.0\",\"metadata\":{\"agent_id\":\"agent\",\"session_id\":\"5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30\"},\"payload\":{\"arguments\":{\"command\":{\"value\":\"echo rm -rf /\"}},\"raw_command\":\"echo rm -rf /\",\"tool\":{\"name\":\"Bash\"}},\"request_id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"timestamp\":\"2026-09-19T10:00:00Z\"}", + "hash": "1f73e15e340da6bc294e18e38557aef0241921fd3985c94266e1b7416c743058" + }, + "chain": [ + { + "entry": { + "entry_id": "1", + "step_id": "0b2f6f7e-0c1d-4e5f-8a9b-1c2d3e4f5a60", + "step_type": "steps/sessionStart", + "timestamp": "2026-09-19T10:00:00Z" + }, + "previous_hash": null, + "canonical_content": "{\"entry_id\":\"1\",\"step_id\":\"0b2f6f7e-0c1d-4e5f-8a9b-1c2d3e4f5a60\",\"step_type\":\"steps/sessionStart\",\"timestamp\":\"2026-09-19T10:00:00Z\"}", + "entry_hash": "1552a42d04ec72f9c18945400488e3f50fba5b3256537db01b705eea66e30373" + }, + { + "entry": { + "entry_id": "2", + "step_id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "step_type": "steps/toolCallRequest", + "request_hash": "1f73e15e340da6bc294e18e38557aef0241921fd3985c94266e1b7416c743058", + "timestamp": "2026-09-19T10:00:01Z", + "provenance_summary": { + "origins_seen": [ + "user_input", + "tool_output" + ], + "entry_count": 2, + "entry_count_by_origin": { + "user_input": 1, + "tool_output": 1 + } + } + }, + "previous_hash": "1552a42d04ec72f9c18945400488e3f50fba5b3256537db01b705eea66e30373", + "canonical_content": "{\"entry_id\":\"2\",\"provenance_summary\":{\"entry_count\":2,\"entry_count_by_origin\":{\"tool_output\":1,\"user_input\":1},\"origins_seen\":[\"user_input\",\"tool_output\"]},\"request_hash\":\"1f73e15e340da6bc294e18e38557aef0241921fd3985c94266e1b7416c743058\",\"step_id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"step_type\":\"steps/toolCallRequest\",\"timestamp\":\"2026-09-19T10:00:01Z\"}", + "entry_hash": "14e6d50a913e8295606bc0d86ee9cde870040e0e0a319b13ae15836128d0d111" + }, + { + "entry": { + "entry_id": "é-3", + "step_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", + "step_type": "steps/toolCallResult", + "timestamp": "2026-09-19T10:00:02Z" + }, + "previous_hash": "14e6d50a913e8295606bc0d86ee9cde870040e0e0a319b13ae15836128d0d111", + "canonical_content": "{\"entry_id\":\"é-3\",\"step_id\":\"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f\",\"step_type\":\"steps/toolCallResult\",\"timestamp\":\"2026-09-19T10:00:02Z\"}", + "entry_hash": "86e28e694a7e762f205dc0739e4764d9cb32ff2c2c03a56ca88ecbbe0f505229" + } + ] +} diff --git a/reference-implementations/agt-go/internal/disposition/disposition.go b/reference-implementations/agt-go/internal/disposition/disposition.go new file mode 100644 index 00000000..6297c273 --- /dev/null +++ b/reference-implementations/agt-go/internal/disposition/disposition.go @@ -0,0 +1,201 @@ +// Package disposition holds the standard's rules for a decision: the fields +// each disposition requires (§6), the composition of a modification (§6.3), +// and the decisions the Guardian substitutes when an Observed Agent cannot apply one +// (§6.5, §9.2). +package disposition + +import ( + "errors" + "fmt" + "maps" + "slices" + "strings" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// Reason codes the Guardian puts on the decisions it makes itself. +const ( + ReasonModifyUnsupported = "modify_unsupported" + ReasonApproverUnavailable = "approver_unavailable" + ReasonChainMismatch = "chain_mismatch" + ReasonEnvelopeInvalid = "envelope_invalid" + ReasonEvaluationFailed = "evaluation_failed" + ReasonAgentLayerUnavailable = "agent_layer_unavailable" + ReasonDispositionNotPermitted = "disposition_not_permitted" + ReasonDeferralBoundExceeded = "deferral_bound_exceeded" + ReasonIntentMismatch = "intent_mismatch" + ReasonSessionClosed = "session_closed" + ReasonStoreUnavailable = "store_unavailable" + ReasonSessionContended = "session_contended" + ReasonStoreFull = "store_full" + ReasonSkillUnverifiable = "skill_unverifiable" + ReasonLineageMismatch = "lineage_mismatch" + ReasonGrantInvalid = "grant_invalid" + ReasonShuttingDown = "shutting_down" + ReasonEngineSaturated = "engine_saturated" + ReasonPolicyStateTooLarge = "policy_state_too_large" + ReasonKeyNotBound = "key_not_bound" + ReasonAgentIDNotBound = "agent_id_not_bound" +) + +// ErrUnfit reports a decision that breaks a rule of §6 or §6.3. +var ErrUnfit = errors.New("decision is not fit to send") + +// Check reports what makes d unfit to send, nil when nothing does: an +// unknown disposition, a missing field its disposition requires, or a +// modification that breaks §6.3's composition rules. +func Check(d acs.Decision) error { + return CheckWithOverridePointer(d, toolArgumentPointer) +} + +// CheckWithOverridePointer applies Check using the payload path an override +// addresses for the request format being governed. +func CheckWithOverridePointer(d acs.Decision, overridePointer func(string) string) error { + switch d.Disposition { + case acs.Allow: + return nil + case acs.Deny, acs.Modify, acs.Ask, acs.Defer: + default: + return fmt.Errorf("%w: unknown disposition %q", ErrUnfit, d.Disposition) + } + if d.Reasoning == "" { + return fmt.Errorf("%w: %s requires reasoning", ErrUnfit, d.Disposition) + } + switch d.Disposition { + case acs.Modify: + if d.Modifications == nil { + return fmt.Errorf("%w: modify requires modifications", ErrUnfit) + } + return checkModifications(*d.Modifications, overridePointer) + case acs.Ask: + if d.AskDetails == nil { + return fmt.Errorf("%w: ask requires ask_details", ErrUnfit) + } + case acs.Defer: + if d.DeferDetails == nil { + return fmt.Errorf("%w: defer requires defer_details", ErrUnfit) + } + } + return nil +} + +// checkModifications applies §6.3: modified_content stands alone, and +// redactions and parameter_overrides address disjoint fields. +func checkModifications(m acs.Modifications, overridePointer func(string) string) error { + structured := len(m.Redactions) > 0 || len(m.ParameterOverrides) > 0 + switch { + case m.ModifiedContent != nil && (m.Redactions != nil || m.ParameterOverrides != nil): + return fmt.Errorf("%w: modified_content combined with structured edits", ErrUnfit) + case m.ModifiedContent == nil && !structured: + return fmt.Errorf("%w: modifications carries no edit", ErrUnfit) + } + for _, r := range m.Redactions { + for key := range m.ParameterOverrides { + if overlaps(r.Path, overridePointer(key)) { + return fmt.Errorf("%w: redaction %q and parameter override %q address overlapping fields", ErrUnfit, r.Path, key) + } + } + } + return nil +} + +// toolArgumentPointer is the payload field a parameter override addresses: the +// tool argument of that name (modifications.json). +func toolArgumentPointer(argument string) string { + return "/arguments/" + strings.NewReplacer("~", "~0", "/", "~1").Replace(argument) +} + +// overlaps reports whether one JSON pointer addresses the same field as the +// other or an ancestor or descendant of it. +func overlaps(a, b string) bool { + return a == b || strings.HasPrefix(b, a+"/") || strings.HasPrefix(a, b+"/") || a == "" || b == "" +} + +// Deny is the Guardian's own denial. +func Deny(reason, reasoning string) acs.Decision { + return acs.Decision{Disposition: acs.Deny, Reasoning: reasoning, ReasonCodes: []string{reason}, PolicyReferences: []acs.PolicyReference{}} +} + +// ModifyUnsupported is §6.5's substitute for a Modify an Observed Agent cannot apply: +// DENY with reason code modify_unsupported and reasoning that names the +// intended modification. +func ModifyUnsupported(intended acs.Decision) acs.Decision { + d := Deny(ReasonModifyUnsupported, "The Observed Agent cannot apply the modification the policy required ("+describe(intended.Modifications)+"), so the step is denied. "+intended.Reasoning) + d.PolicyReferences = intended.PolicyReferences + return d +} + +// ModifyUnsupportedAtPostCompact is §6.5's exception: postCompact permits no +// DENY, so the summary stands, and the Guardian records the rewrite it could +// not deliver. +func ModifyUnsupportedAtPostCompact(intended acs.Decision) acs.Decision { + return acs.Decision{ + Disposition: acs.Allow, + Reasoning: "The Observed Agent cannot apply the modification the policy required (" + describe(intended.Modifications) + "); the summary stands unmodified.", + ReasonCodes: []string{ReasonModifyUnsupported}, + PolicyReferences: intended.PolicyReferences, + } +} + +// AskAsDefer is §9.2's first substitute for an Ask an Observed Agent cannot resolve: +// DEFER with timeout_decision deny, resolved out of band within the Ask's +// own timeout. +func AskAsDefer(ask acs.Decision) acs.Decision { + method := acs.ResolveTimeout + var timeoutMS int64 + if ask.AskDetails != nil { + timeoutMS = ask.AskDetails.TimeoutSeconds * 1000 + if ask.AskDetails.Approver.Type == acs.ApproverHuman { + method = acs.ResolveHumanApproval + } + } + return acs.Decision{ + Disposition: acs.Defer, + Reasoning: "The Observed Agent cannot route an approval, so the step is deferred until it is approved out of band. " + ask.Reasoning, + ReasonCodes: []string{ReasonApproverUnavailable}, + PolicyReferences: ask.PolicyReferences, + DeferDetails: &acs.DeferDetails{ + Reason: acs.DeferPendingDependency, + ResolutionMethod: method, + ResolutionTimeoutMS: timeoutMS, + TimeoutDecision: acs.Deny, + }, + } +} + +// AskAsDeny is §9.2's second substitute: DENY with reason code +// approver_unavailable and reasoning that names the missing capability. +func AskAsDeny(ask acs.Decision) acs.Decision { + d := Deny(ReasonApproverUnavailable, "The step needs approval and the Observed Agent cannot route an approval request, so the step is denied. "+ask.Reasoning) + d.PolicyReferences = ask.PolicyReferences + return d +} + +// WithDeferDefaults fills timeout_decision, which §6 requires on every +// Defer and defaults to deny. +func WithDeferDefaults(d acs.Decision) acs.Decision { + if d.Disposition == acs.Defer && d.DeferDetails != nil && d.DeferDetails.TimeoutDecision == "" { + details := *d.DeferDetails + details.TimeoutDecision = acs.Deny + d.DeferDetails = &details + } + return d +} + +func describe(m *acs.Modifications) string { + if m == nil { + return "none" + } + var parts []string + if m.ModifiedContent != nil { + parts = append(parts, "replace the payload") + } + for _, r := range m.Redactions { + parts = append(parts, "redact "+r.Path) + } + for _, key := range slices.Sorted(maps.Keys(m.ParameterOverrides)) { + parts = append(parts, "override argument "+key) + } + return strings.Join(parts, ", ") +} diff --git a/reference-implementations/agt-go/internal/disposition/disposition_test.go b/reference-implementations/agt-go/internal/disposition/disposition_test.go new file mode 100644 index 00000000..c753820a --- /dev/null +++ b/reference-implementations/agt-go/internal/disposition/disposition_test.go @@ -0,0 +1,69 @@ +package disposition + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +func TestCheck(t *testing.T) { + content := "x" + override := map[string]json.RawMessage{"command": json.RawMessage(`"ls"`)} + tests := []struct { + name string + d acs.Decision + fit bool + }{ + {"allow", acs.Decision{Disposition: acs.Allow}, true}, + {"unknown", acs.Decision{Disposition: "maybe"}, false}, + {"deny_without_reasoning", acs.Decision{Disposition: acs.Deny}, false}, + {"deny", acs.Decision{Disposition: acs.Deny, Reasoning: "r"}, true}, + {"modify_without_modifications", acs.Decision{Disposition: acs.Modify, Reasoning: "r"}, false}, + {"modify_empty", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{}}, false}, + {"modify_replacement", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{ModifiedContent: &content}}, true}, + {"modify_replacement_and_override", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{ModifiedContent: &content, ParameterOverrides: override}}, false}, + {"modify_disjoint", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/raw_command"}}, ParameterOverrides: override}}, true}, + {"modify_same_field", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/arguments/command"}}, ParameterOverrides: override}}, false}, + {"modify_descendant", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/arguments/command/value"}}, ParameterOverrides: override}}, false}, + {"modify_ancestor", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/arguments"}}, ParameterOverrides: override}}, false}, + {"modify_root", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: ""}}, ParameterOverrides: override}}, false}, + {"modify_sibling_prefix", acs.Decision{Disposition: acs.Modify, Reasoning: "r", Modifications: &acs.Modifications{Redactions: []acs.Redaction{{Path: "/arguments/commander"}}, ParameterOverrides: override}}, true}, + {"ask_without_details", acs.Decision{Disposition: acs.Ask, Reasoning: "r"}, false}, + {"defer_without_details", acs.Decision{Disposition: acs.Defer, Reasoning: "r"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Check(tt.d) + if (err == nil) != tt.fit || (err != nil && !errors.Is(err, ErrUnfit)) { + t.Fatalf("Check: %v, want fit=%v", err, tt.fit) + } + }) + } +} + +func TestSubstitutes(t *testing.T) { + refs := []acs.PolicyReference{{PolicyID: "p", RuleID: "r"}} + modify := acs.Decision{Disposition: acs.Modify, Reasoning: "why", PolicyReferences: refs, Modifications: &acs.Modifications{ + Redactions: []acs.Redaction{{Path: "/b"}}, ParameterOverrides: map[string]json.RawMessage{"z": nil, "a": nil}, + }} + d := ModifyUnsupported(modify) + if d.Disposition != acs.Deny || d.ReasonCodes[0] != ReasonModifyUnsupported || d.Reasoning != "The Observed Agent cannot apply the modification the policy required (redact /b, override argument a, override argument z), so the step is denied. why" || len(d.PolicyReferences) != 1 { + t.Fatalf("%+v", d) + } + if d := ModifyUnsupportedAtPostCompact(modify); d.Disposition != acs.Allow || d.ReasonCodes[0] != ReasonModifyUnsupported { + t.Fatalf("%+v", d) + } + ask := acs.Decision{Disposition: acs.Ask, Reasoning: "approve", AskDetails: &acs.AskDetails{Approver: acs.Approver{Type: acs.ApproverAgent, ID: "a"}, TimeoutSeconds: 2}} + if d := AskAsDefer(ask); d.DeferDetails.ResolutionMethod != acs.ResolveTimeout || d.DeferDetails.ResolutionTimeoutMS != 2000 || d.DeferDetails.TimeoutDecision != acs.Deny { + t.Fatalf("%+v", d.DeferDetails) + } + if d := AskAsDeny(ask); d.Disposition != acs.Deny || d.ReasonCodes[0] != ReasonApproverUnavailable { + t.Fatalf("%+v", d) + } + deferral := acs.Decision{Disposition: acs.Defer, DeferDetails: &acs.DeferDetails{}} + if got := WithDeferDefaults(deferral); got.DeferDetails.TimeoutDecision != acs.Deny || deferral.DeferDetails.TimeoutDecision != "" { + t.Fatal("WithDeferDefaults must fill a copy") + } +} diff --git a/reference-implementations/agt-go/internal/docscheck/check.go b/reference-implementations/agt-go/internal/docscheck/check.go new file mode 100644 index 00000000..a36b2e38 --- /dev/null +++ b/reference-implementations/agt-go/internal/docscheck/check.go @@ -0,0 +1,167 @@ +package docscheck + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +var ( + localLink = regexp.MustCompile(`\]\(([^)]+)\)`) + documentedTest = regexp.MustCompile("`((?:[-a-z]+/)*[-a-z]+)\\.(Test\\w+)`") + testFunc = regexp.MustCompile(`(?m)^func (Test\w+)\(`) +) + +func Check(root string) error { + documents, err := markdownFiles(root) + if err != nil { + return err + } + tests := map[string]bool{} + var problems []string + for _, document := range documents { + body, err := os.ReadFile(document) + if err != nil { + return err + } + problems = append(problems, checkLinks(document, string(body))...) + for _, match := range documentedTest.FindAllStringSubmatch(string(body), -1) { + packageName, testName := match[1], match[2] + key := packageName + "." + testName + if !tests[key] { + if err := collectTests(packageDir(root, packageName), packageName, tests); err != nil { + return err + } + } + if !tests[key] { + problems = append(problems, fmt.Sprintf("%s names %s, which does not exist", relative(root, document), key)) + } + } + } + conformance, err := os.ReadFile(filepath.Join(root, "docs", "conformance.md")) + if err != nil { + return err + } + problems = append(problems, checkConformance(string(conformance))...) + if len(problems) > 0 { + return fmt.Errorf("%s", strings.Join(problems, "; ")) + } + return nil +} + +func markdownFiles(root string) ([]string, error) { + var documents []string + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && (entry.Name() == ".acs" || entry.Name() == "vendor") { + return filepath.SkipDir + } + if !entry.IsDir() && strings.EqualFold(filepath.Ext(path), ".md") { + documents = append(documents, path) + } + return nil + }) + return documents, err +} + +func checkLinks(document, body string) []string { + var problems []string + for _, match := range localLink.FindAllStringSubmatch(body, -1) { + target := strings.TrimSpace(match[1]) + if target == "" || strings.HasPrefix(target, "#") || strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") { + continue + } + target, _, _ = strings.Cut(target, "#") + if _, err := os.Stat(filepath.Join(filepath.Dir(document), filepath.FromSlash(target))); err != nil { + problems = append(problems, fmt.Sprintf("%s links to %s, which does not exist", filepath.Base(document), match[1])) + } + } + return problems +} + +func checkConformance(body string) []string { + const header = "| ACS-Core item | Status | Tests | Note |" + start := strings.Index(body, header) + if start < 0 { + return []string{"docs/conformance.md has no ACS-Core table with a Tests column"} + } + rows := 0 + items := map[string]bool{} + var problems []string + for _, line := range strings.Split(body[start+len(header):], "\n") { + if !strings.HasPrefix(line, "|") { + if rows > 0 { + break + } + continue + } + columns := strings.Split(line, "|") + if len(columns) != 6 || strings.TrimSpace(columns[1]) == "---" { + continue + } + item := strings.TrimSpace(columns[1]) + status := strings.TrimSpace(columns[2]) + tests := documentedTest.FindAllStringSubmatch(columns[3], -1) + if item == "" || status == "" { + problems = append(problems, "docs/conformance.md contains an incomplete ACS-Core row") + continue + } + if items[item] { + problems = append(problems, fmt.Sprintf("docs/conformance.md repeats ACS-Core item %q", item)) + } + items[item] = true + rows++ + if status != "Not claimed" && len(tests) == 0 { + problems = append(problems, fmt.Sprintf("docs/conformance.md claims %q without naming a test", item)) + } + } + if rows == 0 { + problems = append(problems, "docs/conformance.md has no ACS-Core rows") + } + return problems +} + +func packageDir(root, name string) string { + direct := filepath.Join(root, filepath.FromSlash(name)) + if _, err := os.Stat(direct); err == nil { + return direct + } + found := direct + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err == nil && entry.IsDir() && entry.Name() == name { + found = path + return filepath.SkipDir + } + return err + }) + return found +} + +func collectTests(dir, prefix string, tests map[string]bool) error { + files, err := filepath.Glob(filepath.Join(dir, "*.go")) + if err != nil { + return err + } + for _, file := range files { + body, err := os.ReadFile(file) + if err != nil { + return err + } + for _, match := range testFunc.FindAllSubmatch(body, -1) { + tests[prefix+"."+string(match[1])] = true + } + } + return nil +} + +func relative(root, path string) string { + name, err := filepath.Rel(root, path) + if err != nil { + return path + } + return filepath.ToSlash(name) +} diff --git a/reference-implementations/agt-go/internal/docscheck/check_test.go b/reference-implementations/agt-go/internal/docscheck/check_test.go new file mode 100644 index 00000000..f3ef5852 --- /dev/null +++ b/reference-implementations/agt-go/internal/docscheck/check_test.go @@ -0,0 +1,55 @@ +package docscheck + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRepositoryDocuments(t *testing.T) { + if err := Check("../.."); err != nil { + t.Fatal(err) + } +} + +func TestConformanceRequiresTestsForClaims(t *testing.T) { + problems := checkConformance("| ACS-Core item | Status | Tests | Note |\n| --- | --- | --- | --- |\n| Handshake | Met | | note |\n") + if len(problems) != 1 || !strings.Contains(problems[0], "without naming a test") { + t.Fatalf("problems = %v", problems) + } +} + +func TestCheckReportsMissingLinksAndTests(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "docs"), 0o755); err != nil { + t.Fatal(err) + } + conformance := "| ACS-Core item | Status | Tests | Note |\n| --- | --- | --- | --- |\n| Handshake | Met | `guardian.TestMissing` | note |\n[missing](absent.md)\n" + if err := os.WriteFile(filepath.Join(root, "docs", "conformance.md"), []byte(conformance), 0o600); err != nil { + t.Fatal(err) + } + err := Check(root) + if err == nil || !strings.Contains(err.Error(), "absent.md") || !strings.Contains(err.Error(), "guardian.TestMissing") { + t.Fatalf("error = %v", err) + } +} + +func TestCheckReportsMissingTestInAnyDocument(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "docs"), 0o755); err != nil { + t.Fatal(err) + } + conformance := "| ACS-Core item | Status | Tests | Note |\n| --- | --- | --- | --- |\n| Handshake | Not claimed | | note |\n" + if err := os.WriteFile(filepath.Join(root, "docs", "conformance.md"), []byte(conformance), 0o600); err != nil { + t.Fatal(err) + } + canonicalForm := "# Canonical form\n\nCovered by `guardian.TestMissing`.\n" + if err := os.WriteFile(filepath.Join(root, "docs", "canonical-form.md"), []byte(canonicalForm), 0o600); err != nil { + t.Fatal(err) + } + err := Check(root) + if err == nil || !strings.Contains(err.Error(), "canonical-form.md names guardian.TestMissing") { + t.Fatalf("error = %v", err) + } +} diff --git a/reference-implementations/agt-go/internal/envelope/parse.go b/reference-implementations/agt-go/internal/envelope/parse.go new file mode 100644 index 00000000..b9b8fa91 --- /dev/null +++ b/reference-implementations/agt-go/internal/envelope/parse.go @@ -0,0 +1,141 @@ +package envelope + +import ( + "bytes" + "errors" + "fmt" + + "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" +) + +// Errors Parse returns, each answered by its own JSON-RPC code. +var ( + // ErrNotJSON: the body is not one I-JSON text (Parse error). + ErrNotJSON = errors.New("body is not a JSON text") + // ErrBatch: the body is a JSON array, a batch this Guardian does not + // support (§3, Invalid Request). + ErrBatch = errors.New("batch requests are not supported") + // ErrNotObject: the body is JSON but not an object (Invalid Request). + ErrNotObject = errors.New("request is not a JSON object") +) + +// nullID is the identifier of a response whose request's identifier could +// not be determined. +var nullID = jsontext.Value("null") + +// Inbound is a request as far as it can be read before it is authenticated +// and validated. Every field is untrusted: it is read so the Guardian can +// address an answer and verify a signature, never to decide. +type Inbound struct { + // Body is the request as received. + Body []byte + // Tree is the whole request decoded for schema validation. + Tree any + // ID is the JSON-RPC identifier as its JSON text when it is a string or + // a number, and null otherwise. + ID jsontext.Value + // Params is params as its JSON text, nil when absent. + Params jsontext.Value + // The members below are empty when absent or not strings. + Method string + ACSVersion string + RequestID string + Timestamp string + Nonce string + SessionID string +} + +// NullID reports whether the identifier could not be determined. +func (in *Inbound) NullID() bool { return bytes.Equal(in.ID, nullID) } + +// ValidateJSONRPCRequest verifies the JSON-RPC boundary before the Guardian +// interprets an ACS envelope or attempts signature verification. ACS uses +// -32600 for this boundary, while invalid ACS fields remain request-envelope +// validation failures. +func (in *Inbound) ValidateJSONRPCRequest() error { + top := in.Tree.(map[string]any) + version, ok := top["jsonrpc"].(string) + if !ok || version != "2.0" { + return errors.New(`jsonrpc must be "2.0"`) + } + method, ok := top["method"].(string) + if !ok || method == "" { + return errors.New("method must be a non-empty string") + } + if id, present := top["id"]; present { + switch id.(type) { + case nil, string, float64: + default: + return errors.New("id must be a string, number or null") + } + } + if _, response := top["result"]; response { + return errors.New("a request must not contain result") + } + if _, response := top["error"]; response { + return errors.New("a request must not contain error") + } + if params, present := top["params"]; present { + switch params.(type) { + case map[string]any, []any: + default: + return errors.New("params must be an object or an array") + } + } + return nil +} + +// Parse reads body. The decoder refuses duplicate member names, invalid +// UTF-8 and unpaired surrogates, so every later reader sees the one meaning +// the signature covers. +func Parse(body []byte) (*Inbound, error) { + trimmed := bytes.TrimLeft(body, " \t\r\n") + if len(trimmed) > 0 && trimmed[0] == '[' { + if !jsontext.Value(body).IsValid() { + return nil, ErrNotJSON + } + return nil, ErrBatch + } + in := &Inbound{Body: body, ID: nullID} + if err := json.Unmarshal(body, &in.Tree); err != nil { + return nil, fmt.Errorf("%w: %v", ErrNotJSON, err) + } + if _, ok := in.Tree.(map[string]any); !ok { + return nil, ErrNotObject + } + var top struct { + ID jsontext.Value `json:"id"` + Method any `json:"method"` + Params jsontext.Value `json:"params"` + } + if err := json.Unmarshal(body, &top); err != nil { + return nil, fmt.Errorf("%w: %v", ErrNotJSON, err) + } + if kind := top.ID.Kind(); kind == '"' || kind == '0' { + in.ID = top.ID + } + in.Method, _ = top.Method.(string) + in.Params = top.Params + if top.Params.Kind() != '{' { + return in, nil + } + var params struct { + ACSVersion any `json:"acs_version"` + RequestID any `json:"request_id"` + Timestamp any `json:"timestamp"` + Nonce any `json:"nonce"` + Metadata any `json:"metadata"` + } + if err := json.Unmarshal(top.Params, ¶ms); err != nil { + return nil, fmt.Errorf("%w: %v", ErrNotJSON, err) + } + in.ACSVersion, _ = params.ACSVersion.(string) + in.RequestID, _ = params.RequestID.(string) + in.Timestamp, _ = params.Timestamp.(string) + in.Nonce, _ = params.Nonce.(string) + if metadata, ok := params.Metadata.(map[string]any); ok { + in.SessionID, _ = metadata["session_id"].(string) + } + return in, nil +} diff --git a/reference-implementations/agt-go/internal/envelope/parse_test.go b/reference-implementations/agt-go/internal/envelope/parse_test.go new file mode 100644 index 00000000..86f1abe4 --- /dev/null +++ b/reference-implementations/agt-go/internal/envelope/parse_test.go @@ -0,0 +1,21 @@ +package envelope + +import "testing" + +func TestValidateJSONRPCRequestRejectsNonScalarIdentifiers(t *testing.T) { + for name, id := range map[string]string{ + "boolean": "true", + "object": `{}`, + "array": `[]`, + } { + t.Run(name, func(t *testing.T) { + in, err := Parse([]byte(`{"jsonrpc":"2.0","method":"steps/toolCallRequest","id":` + id + `,"params":{}}`)) + if err != nil { + t.Fatal(err) + } + if err := in.ValidateJSONRPCRequest(); err == nil { + t.Fatalf("accepted JSON-RPC id %s", id) + } + }) + } +} diff --git a/reference-implementations/agt-go/internal/envelope/signing.go b/reference-implementations/agt-go/internal/envelope/signing.go new file mode 100644 index 00000000..78d0adbc --- /dev/null +++ b/reference-implementations/agt-go/internal/envelope/signing.go @@ -0,0 +1,82 @@ +// Package envelope reads the parts of an ACS envelope the Guardian needs +// before it trusts the envelope, and builds the §10 signed input. +package envelope + +import ( + "errors" + "fmt" + + "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/jcs" +) + +// ErrMalformedSignature reports a signature member that is not a signature +// envelope of §10. +var ErrMalformedSignature = errors.New("malformed signature") + +// RequestSigningInput returns the §10 signed input of a request, the RFC 8785 +// canonicalization of the envelope with params.signature removed, and the +// signature the request carried, nil when it carried none. +func RequestSigningInput(raw []byte) ([]byte, *acs.Signature, error) { + return signingInput(raw, "params") +} + +// ResponseSigningInput returns the §10 signed input of a response, the +// envelope with its signature removed - result.signature, or +// error.signature for an error - and the signature it carried. +func ResponseSigningInput(raw []byte) ([]byte, *acs.Signature, error) { + return signingInput(raw, "") +} + +// signingInput removes the signature from holder; an empty holder means a +// response's, error when it has one and result otherwise. +func signingInput(raw []byte, holder string) ([]byte, *acs.Signature, error) { + var envelope map[string]any + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, nil, fmt.Errorf("decode envelope: %w", err) + } + if holder == "" { + holder = "result" + if _, isError := envelope["error"]; isError { + holder = "error" + } + } + var sig *acs.Signature + if member, ok := envelope[holder].(map[string]any); ok { + if value, present := member["signature"]; present { + s, err := decodeSignature(value) + if err != nil { + return nil, nil, err + } + sig = s + delete(member, "signature") + } + } + b, err := json.Marshal(envelope) + if err != nil { + return nil, nil, fmt.Errorf("encode envelope: %w", err) + } + input, err := jcs.Canonicalize(b) + if err != nil { + return nil, nil, err + } + return input, sig, nil +} + +func decodeSignature(value any) (*acs.Signature, error) { + object, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: not an object", ErrMalformedSignature) + } + var s acs.Signature + for name, dst := range map[string]*string{"algorithm": &s.Algorithm, "value": &s.Value, "key_id": &s.KeyID} { + v, ok := object[name].(string) + if !ok || v == "" { + return nil, fmt.Errorf("%w: %s must be a non-empty string", ErrMalformedSignature, name) + } + *dst = v + } + return &s, nil +} diff --git a/reference-implementations/agt-go/internal/envelope/signing_test.go b/reference-implementations/agt-go/internal/envelope/signing_test.go new file mode 100644 index 00000000..7b8f66ff --- /dev/null +++ b/reference-implementations/agt-go/internal/envelope/signing_test.go @@ -0,0 +1,142 @@ +package envelope + +import ( + "errors" + "os" + "testing" + + "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" +) + +// testdata/vectors.json was computed with Python's json, hmac and hashlib, +// independently of this module; its HKDF matches RFC 5869 test case 3. +type vectors struct { + Request vector `json:"request"` + Response vector `json:"response"` +} + +type vector struct { + Signed jsontext.Value `json:"signed"` + SigningInput string `json:"signing_input"` +} + +func loadVectors(t *testing.T) vectors { + t.Helper() + b, err := os.ReadFile("testdata/vectors.json") + if err != nil { + t.Fatal(err) + } + var v vectors + if err := json.Unmarshal(b, &v); err != nil { + t.Fatal(err) + } + return v +} + +func TestSigningInputVectors(t *testing.T) { + v := loadVectors(t) + tests := []struct { + name string + input func([]byte) ([]byte, error) + vec vector + }{ + {"request", func(b []byte) ([]byte, error) { in, _, err := RequestSigningInput(b); return in, err }, v.Request}, + {"response", func(b []byte) ([]byte, error) { in, _, err := ResponseSigningInput(b); return in, err }, v.Response}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.input(tt.vec.Signed) + if err != nil { + t.Fatal(err) + } + if string(got) != tt.vec.SigningInput { + t.Fatalf("got %s\nwant %s", got, tt.vec.SigningInput) + } + }) + } +} + +func TestRequestSigningInputReturnsTheSignature(t *testing.T) { + v := loadVectors(t) + _, sig, err := RequestSigningInput(v.Request.Signed) + if err != nil { + t.Fatal(err) + } + if sig == nil || sig.Algorithm != "HMAC-SHA256" || sig.KeyID != "k1" || sig.Value == "" { + t.Fatalf("signature %+v", sig) + } +} + +// TestSigningInputCoversEverythingButTheSignature: the signature itself is +// the only member that may change without changing the input. +func TestSigningInputCoversEverythingButTheSignature(t *testing.T) { + base := `{"jsonrpc":"2.0","method":"steps/userMessage","id":1,"params":{"request_id":"r","metadata":{"session_id":"s"},"signature":{"algorithm":"HMAC-SHA256","value":"AAAA","key_id":"k"}}}` + want, _, err := RequestSigningInput([]byte(base)) + if err != nil { + t.Fatal(err) + } + same := `{"params":{"signature":{"algorithm":"HMAC-SHA256","value":"BBBB","key_id":"k2"},"metadata":{"session_id":"s"},"request_id":"r"},"id":1,"method":"steps/userMessage","jsonrpc":"2.0"}` + got, _, err := RequestSigningInput([]byte(same)) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatal("the signature value or member order changed the signed input") + } + for _, changed := range []string{ + `{"jsonrpc":"2.0","method":"steps/userMessage","id":1,"params":{"request_id":"r","metadata":{"session_id":"t"},"signature":{"algorithm":"HMAC-SHA256","value":"AAAA","key_id":"k"}}}`, + `{"jsonrpc":"2.0","method":"steps/agentResponse","id":1,"params":{"request_id":"r","metadata":{"session_id":"s"},"signature":{"algorithm":"HMAC-SHA256","value":"AAAA","key_id":"k"}}}`, + `{"jsonrpc":"2.0","method":"steps/userMessage","id":2,"params":{"request_id":"r","metadata":{"session_id":"s"},"signature":{"algorithm":"HMAC-SHA256","value":"AAAA","key_id":"k"}}}`, + `{"jsonrpc":"2.0","method":"steps/userMessage","id":1,"params":{"request_id":"q","metadata":{"session_id":"s"},"signature":{"algorithm":"HMAC-SHA256","value":"AAAA","key_id":"k"}}}`, + } { + got, _, err := RequestSigningInput([]byte(changed)) + if err != nil { + t.Fatal(err) + } + if string(got) == string(want) { + t.Errorf("%s signs the same input as the original", changed) + } + } +} + +func TestMalformedSignature(t *testing.T) { + for _, raw := range []string{ + `{"params":{"signature":"abc"}}`, + `{"params":{"signature":{"algorithm":"HMAC-SHA256","value":"x"}}}`, + `{"params":{"signature":{"algorithm":"HMAC-SHA256","value":"","key_id":"k"}}}`, + `{"params":{"signature":{"algorithm":1,"value":"x","key_id":"k"}}}`, + } { + if _, _, err := RequestSigningInput([]byte(raw)); !errors.Is(err, ErrMalformedSignature) { + t.Errorf("%s: %v, want ErrMalformedSignature", raw, err) + } + } +} + +func TestUnsignedRequest(t *testing.T) { + _, sig, err := RequestSigningInput([]byte(`{"params":{"request_id":"r"}}`)) + if err != nil || sig != nil { + t.Fatalf("signature %v, error %v", sig, err) + } +} + +func TestRefusesDuplicateMembers(t *testing.T) { + if _, _, err := RequestSigningInput([]byte(`{"params":{"a":1},"params":{"a":2}}`)); err == nil { + t.Fatal("accepted an envelope two parsers would read differently") + } +} + +// An error's signature is removed from error, not result, and a result's +// from result. +func TestResponseSigningInputHolder(t *testing.T) { + sig := `{"algorithm":"HMAC-SHA256","value":"AA==","key_id":"k"}` + for name, tt := range map[string]struct{ raw, want string }{ + "error": {`{"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"m","signature":` + sig + `}}`, `{"error":{"code":-32005,"message":"m"},"id":1,"jsonrpc":"2.0"}`}, + "result": {`{"jsonrpc":"2.0","id":1,"result":{"decision":"allow","signature":` + sig + `}}`, `{"id":1,"jsonrpc":"2.0","result":{"decision":"allow"}}`}, + } { + input, got, err := ResponseSigningInput([]byte(tt.raw)) + if err != nil || got == nil || got.KeyID != "k" || string(input) != tt.want { + t.Errorf("%s: input %s, signature %+v, %v", name, input, got, err) + } + } +} diff --git a/reference-implementations/agt-go/internal/envelope/testdata/vectors.json b/reference-implementations/agt-go/internal/envelope/testdata/vectors.json new file mode 100644 index 00000000..afe5cbf2 --- /dev/null +++ b/reference-implementations/agt-go/internal/envelope/testdata/vectors.json @@ -0,0 +1,62 @@ +{ + "ikm_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "key_id": "k1", + "session_id": "5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30", + "session_key_hex": "4e86230ba407bc623132e84189722dc2b4ab73f217ff67e0492c82fdf0d66309", + "request": { + "signed": { + "jsonrpc": "2.0", + "method": "steps/toolCallRequest", + "id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "params": { + "acs_version": "0.1.0", + "request_id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "timestamp": "2026-09-19T10:00:00Z", + "metadata": { + "agent_id": "agent", + "session_id": "5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30" + }, + "payload": { + "tool": { + "name": "Bash" + }, + "arguments": { + "command": { + "value": "echo rm -rf /" + } + }, + "raw_command": "echo rm -rf /" + }, + "signature": { + "algorithm": "HMAC-SHA256", + "value": "yJ3BXf/Hfc3jSnSMgOoNVGQ+5LKBKakvctDwE52lrM0=", + "key_id": "k1" + } + } + }, + "signing_input": "{\"id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"jsonrpc\":\"2.0\",\"method\":\"steps/toolCallRequest\",\"params\":{\"acs_version\":\"0.1.0\",\"metadata\":{\"agent_id\":\"agent\",\"session_id\":\"5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30\"},\"payload\":{\"arguments\":{\"command\":{\"value\":\"echo rm -rf /\"}},\"raw_command\":\"echo rm -rf /\",\"tool\":{\"name\":\"Bash\"}},\"request_id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"timestamp\":\"2026-09-19T10:00:00Z\"}}" + }, + "response": { + "signed": { + "jsonrpc": "2.0", + "id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "decision": "deny", + "reasoning": "blocked", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "chain_hash": "14e6d50a913e8295606bc0d86ee9cde870040e0e0a319b13ae15836128d0d111", + "signature": { + "algorithm": "HMAC-SHA256", + "value": "Shh4i+RWlfCNfAoFlE7gp5QUoyCI8jz3HMKHzH/2gG0=", + "key_id": "k1" + } + } + }, + "signing_input": "{\"id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"jsonrpc\":\"2.0\",\"result\":{\"acs_version\":\"0.1.0\",\"chain_hash\":\"14e6d50a913e8295606bc0d86ee9cde870040e0e0a319b13ae15836128d0d111\",\"decision\":\"deny\",\"reason_codes\":[\"destructive_shell_command_blocked\"],\"reasoning\":\"blocked\",\"request_id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"type\":\"final\"}}" + } +} diff --git a/reference-implementations/agt-go/internal/handshake/handshake.go b/reference-implementations/agt-go/internal/handshake/handshake.go new file mode 100644 index 00000000..7db0f11d --- /dev/null +++ b/reference-implementations/agt-go/internal/handshake/handshake.go @@ -0,0 +1,178 @@ +// Package handshake negotiates a session (§4): it answers a ClientHello with +// the ServerHello the Guardian can honour, or with the refusal §17.1 names. +package handshake + +import ( + "slices" + "strconv" + "strings" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" +) + +// Offer is everything the Guardian can do, before it reads the client's +// half. +type Offer struct { + // Version is the ACS version the Guardian implements. + Version string + // Hooks lists the native hooks the Guardian evaluates. + Hooks []string + // Wrapped is the wrapped protocols the Guardian has handlers for. + Wrapped Wrapped + // Transport is the transport the Guardian is serving this session on. + Transport string + SignatureAlgorithms []string + TimeoutConfig acs.TimeoutConfig + SkewWindowMS int64 + OnDecisionFailure acs.FailurePosture + ApproverTypes []acs.ApproverType + PolicyRequiresProvenance bool +} + +// Wrapped is what negotiation needs of the wrapped-protocol registry. +type Wrapped interface { + // Evaluates reports whether a registered handler reads a wrapped + // method, in either of its forms. + Evaluates(method string) bool +} + +// Refusal is a handshake the Guardian refuses, with the §17.1 code and data. +type Refusal struct { + Code acs.ErrorCode + Data acs.ErrorData +} + +// CoreMethods returns the ACS-Core hook minimum; a subagent-capable Observed +// Agent also implements subagentStart. +// A Guardian negotiates the subset each Observed Agent actually offers. +func CoreMethods() []string { + return []string{ + acs.StepSessionStart, + acs.StepToolCallRequest, + acs.StepToolCallResult, + acs.StepAgentResponse, + acs.StepSessionEnd, + acs.StepUserMessage, + } +} + +// Negotiate answers client with a ServerHello, or refuses it. +func Negotiate(client acs.ClientHello, o Offer) (acs.ServerHello, *Refusal) { + version, ok := selectVersion(client.ACSVersionsSupported, o.Version) + if !ok { + return acs.ServerHello{}, &Refusal{Code: acs.UnsupportedVersion, Data: acs.ErrorData{ + Reason: "unsupported_version", + Message: "none of the offered ACS versions is compatible with this Guardian's version", + SupportedVersions: []string{o.Version}, + }} + } + if o.PolicyRequiresProvenance && client.ProvenanceProducer == acs.ProvenanceNone { + return acs.ServerHello{}, &Refusal{Code: acs.ProvenanceRequired, Data: acs.ErrorData{ + Reason: "provenance_required", + Message: "the policy requires provenance and the client declared provenance_producer none", + }} + } + if !slices.Contains(client.TransportsSupported, o.Transport) { + return acs.ServerHello{}, &Refusal{Code: acs.SessionRefused, Data: acs.ErrorData{ + Reason: "transport_unsupported", + Message: "the Observed Agent does not support " + o.Transport + ", the transport this Guardian serves", + }} + } + profiles := []string{} + if slices.Contains(client.ProfilesSupported, acs.ProfileCore) { + profiles = append(profiles, acs.ProfileCore) + } + return acs.ServerHello{ + NegotiatedVersion: version, + MethodsEvaluated: evaluated(client, o), + SelectedTransport: o.Transport, + SignatureAlgorithmsSupported: o.SignatureAlgorithms, + TimeoutConfig: o.TimeoutConfig, + SkewWindowMS: o.SkewWindowMS, + OnDecisionFailure: o.OnDecisionFailure, + ApproverTypesSupported: o.ApproverTypes, + PolicyRequiresProvenance: o.PolicyRequiresProvenance, + ProfilesAccepted: profiles, + }, nil +} + +// evaluated is the client's methods_implemented narrowed to what the +// Guardian evaluates: its hooks, and the wrapped methods a handler reads, of +// a protocol the client declared in wrapped_protocols. +func evaluated(client acs.ClientHello, o Offer) []string { + methods := []string{} + for _, m := range client.MethodsImplemented { + if slices.Contains(methods, m) { + continue + } + if slices.Contains(o.Hooks, m) || (o.Wrapped != nil && o.Wrapped.Evaluates(m) && declares(client, m)) { + methods = append(methods, m) + } + } + return methods +} + +// declares reports whether the client listed the protocol of wrapped +// method m in wrapped_protocols, at the version an explicit-version method +// pins. +func declares(client acs.ClientHello, m string) bool { + protocol, version, _, _ := method.SplitWrapped(m) + return slices.ContainsFunc(client.WrappedProtocols, func(w acs.WrappedProtocol) bool { + return strings.EqualFold(string(w.Protocol), protocol) && (version == "" || w.Version == version) + }) +} + +// Compatible reports whether two X.Y.Z versions may talk. Semantic +// versioning treats 0.x as initial development, where a minor release may +// break, so 0.x versions must share X.Y; from 1.0.0 on, sharing X is enough. +func Compatible(version, ours string) bool { + a, okA := parse(version) + b, okB := parse(ours) + return okA && okB && compatible(a, b) +} + +func compatible(a, b [3]int) bool { + return a[0] == b[0] && (a[0] >= 1 || a[1] == b[1]) +} + +// selectVersion picks the Guardian's own version when the client offers it, +// and otherwise the highest offered version compatible with it. +func selectVersion(offered []string, ours string) (string, bool) { + if slices.Contains(offered, ours) { + return ours, true + } + oursN, ok := parse(ours) + if !ok { + return "", false + } + var best string + var bestN [3]int + for _, v := range offered { + n, ok := parse(v) + if !ok || !compatible(n, oursN) { + continue + } + if best == "" || slices.Compare(n[:], bestN[:]) > 0 { + best, bestN = v, n + } + } + return best, best != "" +} + +// parse reads an X.Y.Z version into its three numbers. +func parse(v string) ([3]int, bool) { + var n [3]int + parts := strings.Split(v, ".") + if len(parts) != 3 { + return n, false + } + for i, part := range parts { + x, err := strconv.Atoi(part) + if err != nil { + return n, false + } + n[i] = x + } + return n, true +} diff --git a/reference-implementations/agt-go/internal/handshake/handshake_test.go b/reference-implementations/agt-go/internal/handshake/handshake_test.go new file mode 100644 index 00000000..af56fad1 --- /dev/null +++ b/reference-implementations/agt-go/internal/handshake/handshake_test.go @@ -0,0 +1,119 @@ +package handshake + +import ( + "slices" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +func TestSelectVersion(t *testing.T) { + tests := []struct { + offered []string + want string + ok bool + }{ + {[]string{"0.1.0"}, "0.1.0", true}, + {[]string{"0.2.0", "0.1.0"}, "0.1.0", true}, + {[]string{"0.1.3", "0.1.10", "0.1.2"}, "0.1.10", true}, + {[]string{"0.1.7"}, "0.1.7", true}, + {[]string{"0.9.0"}, "", false}, + {[]string{"0.2.0"}, "", false}, + {[]string{"1.0.0"}, "", false}, + {[]string{"x.y.z", "0.1"}, "", false}, + } + for _, tt := range tests { + got, ok := selectVersion(tt.offered, "0.1.0") + if got != tt.want || ok != tt.ok { + t.Errorf("%v: got %q, %v", tt.offered, got, ok) + } + } +} + +func TestCompatible(t *testing.T) { + tests := map[[2]string]bool{ + {"0.1.0", "0.1.0"}: true, + {"0.1.9", "0.1.0"}: true, + {"0.2.0", "0.1.0"}: false, + {"0.0.1", "0.1.0"}: false, + {"1.4.0", "1.0.0"}: true, + {"2.0.0", "1.0.0"}: false, + {"1.0.0", "0.1.0"}: false, + {"1.0", "1.0.0"}: false, + } + for pair, want := range tests { + if got := Compatible(pair[0], pair[1]); got != want { + t.Errorf("Compatible(%s, %s) = %v, want %v", pair[0], pair[1], got, want) + } + } +} + +type wrapped []string + +func (w wrapped) Evaluates(m string) bool { return slices.Contains(w, m) } + +func coreHello(extra ...string) acs.ClientHello { + return acs.ClientHello{ + ACSVersionsSupported: []string{acs.Version}, + MethodsImplemented: append(CoreMethods(), extra...), + TransportsSupported: []string{acs.TransportHTTP}, + ProvenanceProducer: acs.ProvenanceNone, + WrappedProtocols: []acs.WrappedProtocol{{Protocol: "MCP", Version: "2025-06-18"}}, + ProfilesSupported: []string{acs.ProfileCore}, + } +} + +func TestEvaluatedMethods(t *testing.T) { + client := coreHello(acs.StepToolCallRequest, "steps/unknown", "protocols/MCP/tools/call", "protocols/MCP/tools/list", "protocols/A2A/x", acs.MethodSystemPing) + hooks := CoreMethods() + o := Offer{Version: acs.Version, Hooks: hooks, Wrapped: wrapped{"protocols/MCP/tools/call", "protocols/A2A/x"}, Transport: acs.TransportHTTP} + server, refusal := Negotiate(client, o) + if refusal != nil { + t.Fatal(refusal) + } + // A2A is not in wrapped_protocols; tools/list has no handler that reads + // it; the duplicate is listed once. + want := append(CoreMethods(), "protocols/MCP/tools/call") + if !slices.Equal(server.MethodsEvaluated, want) { + t.Fatalf("methods_evaluated %v, want %v", server.MethodsEvaluated, want) + } + if !slices.Equal(server.ProfilesAccepted, []string{acs.ProfileCore}) { + t.Fatalf("profiles_accepted %v", server.ProfilesAccepted) + } + // An explicit-version method is evaluated only at the version the client + // declared. + pinned := coreHello("wrapped:mcp-2025-06-18/tools/call", "wrapped:mcp-2099-01-01/tools/call") + o.Wrapped = wrapped{"wrapped:mcp-2025-06-18/tools/call", "wrapped:mcp-2099-01-01/tools/call"} + server, _ = Negotiate(pinned, o) + if want := append(CoreMethods(), "wrapped:mcp-2025-06-18/tools/call"); !slices.Equal(server.MethodsEvaluated, want) { + t.Fatalf("methods_evaluated %v, want %v", server.MethodsEvaluated, want) + } + o.Wrapped = wrapped{"protocols/MCP/tools/call"} + o.Hooks = nil + if server, _ := Negotiate(client, o); server.MethodsEvaluated == nil || len(server.MethodsEvaluated) != 1 { + t.Fatalf("methods_evaluated %v", server.MethodsEvaluated) + } +} + +// A Guardian negotiates the capabilities an Observed Agent actually offers. +// ACS-Core is a deployment claim, not a precondition that turns a tool-only +// harness into a rejected session. +func TestPartialObservedAgentIsNegotiated(t *testing.T) { + o := Offer{Version: acs.Version, Hooks: CoreMethods(), Transport: acs.TransportHTTP} + client := acs.ClientHello{ + ACSVersionsSupported: []string{acs.Version}, + MethodsImplemented: []string{acs.StepToolCallRequest, acs.StepToolCallResult}, + TransportsSupported: []string{acs.TransportHTTP}, + ProvenanceProducer: acs.ProvenanceNone, + } + server, refusal := Negotiate(client, o) + if refusal != nil { + t.Fatalf("partial client refused: %+v", refusal) + } + if !slices.Equal(server.MethodsEvaluated, client.MethodsImplemented) { + t.Fatalf("methods_evaluated %v, want %v", server.MethodsEvaluated, client.MethodsImplemented) + } + if len(server.ProfilesAccepted) != 0 { + t.Fatalf("profiles_accepted %v, want none", server.ProfilesAccepted) + } +} diff --git a/reference-implementations/agt-go/internal/hostadapter/client.go b/reference-implementations/agt-go/internal/hostadapter/client.go new file mode 100644 index 00000000..2f9198f5 --- /dev/null +++ b/reference-implementations/agt-go/internal/hostadapter/client.go @@ -0,0 +1,518 @@ +package hostadapter + +import ( + "bytes" + "context" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/schema" +) + +const sessionNamespace = "11c79ebd-8469-4b4e-8de0-72674add484c" + +type Config struct { + GuardianURL string + // HMACKeyID is the key_id the deployment gave the secret; it has no + // default (§10). + HMACKeyID string + HMACSecretFile string + StateDir string + AuditLog string + AgentID string + Methods []string + Timeout time.Duration + MaxBodyBytes int64 + // Stderr receives an audit record as one JSON line when AuditLog cannot + // be written; nil means os.Stderr. + Stderr io.Writer +} + +type Step struct { + SessionKey string + TurnID string + CallID string + Method string + Payload any +} + +type Evaluation struct { + Decision acs.Decision + ModifiedPayload json.RawMessage +} + +type Client struct { + config Config + signer *guardian.HMACSigner +} + +type sessionState struct { + Hello acs.ServerHello `json:"hello"` +} + +var methods = method.NewTable() + +var ( + serverHelloSchemas *schema.Registry + serverHelloSchemasErr error + serverHelloSchemasOnce sync.Once +) + +type handshakeRefusal struct{ message string } + +func (e handshakeRefusal) Error() string { return e.message } + +// errHandshake marks a handshake that failed other than by a refusal. +var errHandshake = errors.New("handshake failed") + +func handshakeFailure(err error) error { return fmt.Errorf("%w: %w", errHandshake, err) } + +// The failure classes a decision_failure record names (§6.5). +const ( + failureHandshake = "handshake_failure" + failureTimeout = "timeout" + failureUnreachable = "unreachable" + failureMalformed = "malformed_response" + failureGuardian = "guardian_error" + failureSignature = "signature_invalid" + failureOther = "other" +) + +func New(config Config) (*Client, error) { + if config.GuardianURL == "" || config.HMACKeyID == "" || config.HMACSecretFile == "" || config.StateDir == "" || config.AuditLog == "" || config.AgentID == "" { + return nil, errors.New("guardian URL, HMAC key ID, HMAC secret file, state directory, audit log and agent ID must not be empty") + } + if len(config.Methods) == 0 { + return nil, errors.New("at least one ACS method is required") + } + if config.Timeout <= 0 || config.MaxBodyBytes <= 0 { + return nil, errors.New("timeout and maximum body size must be positive") + } + secret, err := guardian.ReadHMACSecretFile(config.HMACSecretFile) + if err != nil { + return nil, fmt.Errorf("HMAC secret file: %w", err) + } + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: config.HMACKeyID, Secret: secret}) + if err != nil { + return nil, err + } + if config.Stderr == nil { + config.Stderr = os.Stderr + } + return &Client{config: config, signer: signer}, nil +} + +func (c *Client) Evaluate(ctx context.Context, step Step) (Evaluation, error) { + if step.SessionKey == "" || step.TurnID == "" || step.CallID == "" || step.Method == "" || step.Payload == nil { + return Evaluation{}, errors.New("session key, turn ID, call ID, method and payload are required") + } + if !contains(c.config.Methods, step.Method) { + return Evaluation{}, fmt.Errorf("ACS method %q was not configured", step.Method) + } + sessionID := SessionUUID(c.config.AgentID, step.SessionKey) + client := &observedagent.Client{ + Transport: httpTransport(c.config.GuardianURL, c.config.MaxBodyBytes), + Signer: c.signer, + KeyID: c.config.HMACKeyID, + AgentID: c.config.AgentID, + SessionID: sessionID, + } + state, ok := loadState(c.config.StateDir, c.config.AgentID, step.SessionKey) + if !ok { + var err error + state, err = c.handshake(ctx, client, c.config.Timeout) + if err != nil { + var refusal handshakeRefusal + if errors.As(err, &refusal) { + return Evaluation{Decision: c.substitute(step, "session_refused", acs.Decision{Reasoning: "the Guardian did not accept this ACS session"})}, nil + } + // The caller gets the handshake error. A record the audit log refused + // has already gone to stderr (recordAuditEvent). + _ = c.recordAuditEvent(step, "decision_failure", acs.Decision{Disposition: refusalAt(step.Method), Reasoning: err.Error()}, failureHandshake) + return Evaluation{}, err + } + _ = saveState(c.config.StateDir, c.config.AgentID, step.SessionKey, state) + } + + deadline := time.Now().Add(decisionTimeout(state.Hello, step.Method, c.config.Timeout)) + request := observedagent.Request{ + Method: step.Method, + Payload: step.Payload, + RequestID: RequestUUID(sessionID, step.Method, step.CallID), + TurnID: step.TurnID, + } + result, err := send(ctx, client, deadline, request) + if err == nil && result.Error != nil && result.Verified && result.Error.Code == acs.CapabilityNotNegotiated { + removeState(c.config.StateDir, c.config.AgentID, step.SessionKey) + refreshed, handshakeErr := c.handshake(ctx, client, time.Until(deadline)) + if handshakeErr == nil { + state = refreshed + _ = saveState(c.config.StateDir, c.config.AgentID, step.SessionKey, state) + result, err = send(ctx, client, deadline, request) + } else { + var refusal handshakeRefusal + if errors.As(handshakeErr, &refusal) { + return Evaluation{Decision: c.substitute(step, "session_refused", acs.Decision{Reasoning: "the Guardian did not accept this ACS session"})}, nil + } + err = handshakeErr + } + } + posture := state.Hello.OnDecisionFailure + if refusalAt(step.Method) != acs.Deny { + posture = acs.FailureProceed + } + decision, verified := observedagent.Honour(result, err, posture) + if !verified && decision.Disposition == acs.Deny && refusalAt(step.Method) != acs.Deny { + decision = acs.Decision{Disposition: acs.Allow, Reasoning: "the Guardian refused the request, and this hook permits no DENY (§6.4)"} + } + if !verified { + if auditErr := c.recordAuditEvent(step, "decision_failure", decision, failureClass(result, err)); auditErr != nil { + return Evaluation{Decision: acs.Decision{Disposition: refusalAt(step.Method), Reasoning: "the Guardian decision failed and " + recordMissing(auditErr)}}, nil + } + } + if result.InvalidModification { + return Evaluation{Decision: c.substitute(step, "modify_invalid", decision)}, nil + } + evaluation := Evaluation{Decision: decision} + if err != nil || result.Result == nil || !result.Verified || decision.Disposition != acs.Modify { + return evaluation, nil + } + raw, err := json.Marshal(step.Payload) + if err != nil { + return Evaluation{}, err + } + evaluation.ModifiedPayload, err = observedagent.Apply(decision, raw) + if err != nil { + return Evaluation{Decision: c.RefuseModification(step, "the host cannot apply the Guardian modification")}, nil + } + return evaluation, nil +} + +// RefuseModification answers a MODIFY the host cannot apply as §6.5 requires: +// DENY with modify_unsupported, or ALLOW at postCompact, where the original +// summary stands; either is recorded in the local audit log. +func (c *Client) RefuseModification(step Step, reasoning string) acs.Decision { + decision := acs.Decision{Reasoning: reasoning, ReasonCodes: []string{disposition.ReasonModifyUnsupported}} + return c.substitute(step, "modify_unsupported", decision) +} + +// substitute records the refusal the host puts in place of a Guardian +// decision. The refusal stands when the record cannot be written; the +// reasoning says so. +func (c *Client) substitute(step Step, event string, decision acs.Decision) acs.Decision { + decision.Disposition = refusalAt(step.Method) + if err := c.recordAuditEvent(step, event, decision, ""); err != nil { + decision.Reasoning += "; " + recordMissing(err) + } + return decision +} + +// refusalAt is how the host refuses a step: DENY, or ALLOW at a hook that +// permits no DENY (hooks.md) because its action has already happened. +func refusalAt(stepMethod string) acs.Disposition { + if methods.HookRule(stepMethod).Permits(acs.Deny) { + return acs.Deny + } + return acs.Allow +} + +func ToolCallRequest(toolName string, rawInput json.RawMessage) (acs.ToolCallRequestPayload, error) { + var arguments map[string]json.RawMessage + if err := json.Unmarshal(rawInput, &arguments); err != nil || arguments == nil { + return acs.ToolCallRequestPayload{}, errors.New("tool input must be a JSON object") + } + payload := acs.ToolCallRequestPayload{Tool: acs.Tool{Name: toolName}, Arguments: make(map[string]acs.ToolArgument, len(arguments))} + for name, value := range arguments { + payload.Arguments[name] = acs.ToolArgument{Value: value} + } + if command, ok := arguments["command"]; ok { + var value string + if json.Unmarshal(command, &value) == nil { + payload.RawCommand = &value + } + } + return payload, nil +} + +func ToolCallResult(toolName, requestIDRef, exitStatus string, output json.RawMessage) acs.ToolCallResultPayload { + return acs.ToolCallResultPayload{ + Tool: acs.Tool{Name: toolName}, + RequestIDRef: &requestIDRef, + ExitStatus: exitStatus, + Outputs: []acs.ToolOutput{{Value: output}}, + } +} + +func SessionUUID(agentID, sessionKey string) string { + return uuidV5(sessionNamespace, agentID+"\x00"+sessionKey) +} + +func RequestUUID(sessionID, method, callID string) string { + return uuidV5(sessionID, method+"\x00"+callID) +} + +func (c *Client) handshake(ctx context.Context, client *observedagent.Client, timeout time.Duration) (sessionState, error) { + if timeout <= 0 { + return sessionState{}, handshakeFailure(errors.New("the negotiated decision timeout expired")) + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + result, err := client.Handshake(ctx, observedagent.DefaultHello(c.config.Methods...)) + if err != nil { + return sessionState{}, handshakeFailure(err) + } + if result.Error != nil && result.Verified && handshakeErrorIsRefusal(result.Error.Code) { + return sessionState{}, handshakeRefusal{message: "Guardian refused the handshake"} + } + if result.Error != nil { + return sessionState{}, handshakeFailure(fmt.Errorf("the Guardian returned error %d", result.Error.Code)) + } + if result.Hello == nil || !result.Verified { + return sessionState{}, handshakeFailure(errors.New("the Guardian did not return a verified ServerHello")) + } + return sessionState{Hello: *result.Hello}, nil +} + +func send(ctx context.Context, client *observedagent.Client, deadline time.Time, request observedagent.Request) (observedagent.Outcome, error) { + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + return client.Send(ctx, request) +} + +func httpTransport(url string, maxBytes int64) observedagent.Transport { + return func(ctx context.Context, body []byte) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("guardian answered HTTP %d", response.StatusCode) + } + responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(responseBody)) > maxBytes { + return nil, fmt.Errorf("guardian response exceeds %d bytes", maxBytes) + } + return responseBody, nil + } +} + +// failureClass names why no verified decision arrived; err wins over the +// answer, and an answer that is neither a result nor an error is malformed. +func failureClass(result observedagent.Outcome, err error) string { + var opErr *net.OpError + var dnsErr *net.DNSError + switch { + case errors.Is(err, errHandshake): + return failureHandshake + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, os.ErrDeadlineExceeded): + return failureTimeout + case errors.Is(err, observedagent.ErrMalformedResponse): + return failureMalformed + case errors.As(err, &opErr), errors.As(err, &dnsErr): + return failureUnreachable + case err != nil: + return failureOther + case result.Error != nil && result.Verified: + return failureGuardian + case result.Error != nil || result.Result != nil: + return failureSignature + default: + return failureMalformed + } +} + +type auditRecord struct { + Event string `json:"event"` + RecordedAt time.Time `json:"recorded_at"` + SessionKey string `json:"session_key"` + TurnID string `json:"turn_id"` + CallID string `json:"call_id"` + Method string `json:"method"` + // Disposition is what the host does with the step. + Disposition acs.Disposition `json:"disposition"` + Reason string `json:"reason"` + ReasonCodes []string `json:"reason_codes,omitempty"` + // FailureClass is set on a decision_failure record: why no verified + // decision arrived. + FailureClass string `json:"failure_class,omitempty"` + // AuditError is set only on the stderr copy of a record the audit log + // refused. + AuditError string `json:"audit_error,omitempty"` +} + +// recordAuditEvent appends the record to the audit log. When the log refuses +// it, the host refuses the step as refusalAt does, and the record of that +// refusal goes to Config.Stderr instead; at a hook that permits no DENY the +// step still proceeds, and stderr is then its only record (§6.4). The +// error returned names every sink that failed. +func (c *Client) recordAuditEvent(step Step, event string, decision acs.Decision, failureClass string) error { + record := auditRecord{ + Event: event, RecordedAt: time.Now().UTC(), SessionKey: step.SessionKey, + TurnID: step.TurnID, CallID: step.CallID, Method: step.Method, + Disposition: decision.Disposition, Reason: decision.Reasoning, ReasonCodes: decision.ReasonCodes, + FailureClass: failureClass, + } + err := appendJSONLine(c.config.AuditLog, record) + if err != nil { + record.Disposition = refusalAt(step.Method) + record.AuditError = err.Error() + if stderrErr := json.NewEncoder(c.config.Stderr).Encode(record); stderrErr != nil { + err = errors.Join(err, fmt.Errorf("%w: %w", errStderrRefused, stderrErr)) + } + } + return err +} + +var errStderrRefused = errors.New("stderr") + +// recordMissing says where the record of a refusal went, for its reasoning. +// It names no path: a host may show the reasoning to the agent. +func recordMissing(err error) string { + if errors.Is(err, errStderrRefused) { + return "the local audit record could not be written, to the audit log or to stderr" + } + return "the audit log could not be written, and the record went to stderr" +} + +func appendJSONLine(path string, v any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err := json.NewEncoder(file).Encode(v); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +func decisionTimeout(hello acs.ServerHello, method string, fallback time.Duration) time.Duration { + if timeout := hello.TimeoutConfig.PerMethodMS[method]; timeout > 0 { + return time.Duration(timeout) * time.Millisecond + } + if hello.TimeoutConfig.DefaultMS <= 0 { + return fallback + } + return time.Duration(hello.TimeoutConfig.DefaultMS) * time.Millisecond +} + +func handshakeErrorIsRefusal(code acs.ErrorCode) bool { + return code == acs.SessionRefused || code == acs.UnsupportedVersion || code == acs.ProvenanceRequired +} + +func uuidV5(namespace, value string) string { + namespaceBytes, _ := hex.DecodeString(strings.ReplaceAll(namespace, "-", "")) + hash := sha1.Sum(append(namespaceBytes, []byte(value)...)) + hash[6] = hash[6]&0x0f | 0x50 + hash[8] = hash[8]&0x3f | 0x80 + encoded := hex.EncodeToString(hash[:]) + return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32] +} + +func statePath(dir, agentID, sessionKey string) string { + digest := sha256.Sum256([]byte(agentID + "\x00" + sessionKey)) + return filepath.Join(dir, hex.EncodeToString(digest[:])+".json") +} + +func loadState(dir, agentID, sessionKey string) (sessionState, bool) { + data, err := os.ReadFile(statePath(dir, agentID, sessionKey)) + if err != nil { + return sessionState{}, false + } + var state sessionState + if err := jsonv2.Unmarshal(data, &state); err != nil { + return sessionState{}, false + } + if state.Hello.OnDecisionFailure == "" { + state.Hello.OnDecisionFailure = acs.FailureProceed + } + if validateServerHello(state.Hello) != nil { + return sessionState{}, false + } + return state, true +} + +func validateServerHello(hello acs.ServerHello) error { + raw, err := jsonv2.Marshal(hello) + if err != nil { + return err + } + serverHelloSchemasOnce.Do(func() { serverHelloSchemas, serverHelloSchemasErr = schema.Load() }) + if serverHelloSchemasErr != nil { + return serverHelloSchemasErr + } + return serverHelloSchemas.ValidateJSON(schema.ServerHello, raw) +} + +func saveState(dir, agentID, sessionKey string, state sessionState) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + data, err := jsonv2.Marshal(state) + if err != nil { + return err + } + file, err := os.CreateTemp(dir, ".session-") + if err != nil { + return err + } + path := file.Name() + defer os.Remove(path) + if err := file.Chmod(0o600); err != nil { + file.Close() + return err + } + if _, err := file.Write(append(data, '\n')); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(path, statePath(dir, agentID, sessionKey)) +} + +func removeState(dir, agentID, sessionKey string) { + _ = os.Remove(statePath(dir, agentID, sessionKey)) +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/reference-implementations/agt-go/internal/hostadapter/client_test.go b/reference-implementations/agt-go/internal/hostadapter/client_test.go new file mode 100644 index 00000000..be9c350f --- /dev/null +++ b/reference-implementations/agt-go/internal/hostadapter/client_test.go @@ -0,0 +1,508 @@ +package hostadapter + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/guardian" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/envelope" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/method" +) + +func TestDecisionTimeoutUsesMethodOverride(t *testing.T) { + hello := acs.ServerHello{TimeoutConfig: acs.TimeoutConfig{ + DefaultMS: 1000, + PerMethodMS: map[string]int64{acs.StepToolCallRequest: 25}, + }} + if got := decisionTimeout(hello, acs.StepToolCallRequest, 10*time.Second); got != 25*time.Millisecond { + t.Fatalf("method timeout = %s, want 25ms", got) + } +} + +func TestHandshakeErrorIsRefusal(t *testing.T) { + for name, test := range map[string]struct { + code acs.ErrorCode + want bool + }{ + "session_refused": {acs.SessionRefused, true}, + "unsupported_version": {acs.UnsupportedVersion, true}, + "provenance_required": {acs.ProvenanceRequired, true}, + "internal_error": {acs.InternalError, false}, + } { + t.Run(name, func(t *testing.T) { + if got := handshakeErrorIsRefusal(test.code); got != test.want { + t.Fatalf("handshakeErrorIsRefusal(%d) = %v, want %v", test.code, got, test.want) + } + }) + } +} + +func TestLoadStateDefaultsAnOmittedFailurePosture(t *testing.T) { + dir := t.TempDir() + hello := acs.ServerHello{ + NegotiatedVersion: acs.Version, + MethodsEvaluated: []string{acs.StepToolCallRequest}, + SelectedTransport: acs.TransportHTTP, + TimeoutConfig: acs.TimeoutConfig{DefaultMS: 1000}, + } + if err := saveState(dir, "agent", "session", sessionState{Hello: hello}); err != nil { + t.Fatal(err) + } + state, ok := loadState(dir, "agent", "session") + if !ok || state.Hello.OnDecisionFailure != acs.FailureProceed { + t.Fatalf("state = %+v, loaded = %v", state, ok) + } +} + +func TestSessionUUIDIsStableAndAgentScoped(t *testing.T) { + first := SessionUUID("codex", "session") + if first != SessionUUID("codex", "session") { + t.Fatal("the same host session produced different ACS session IDs") + } + if first == SessionUUID("opencode", "session") { + t.Fatal("two agent identities produced the same ACS session ID") + } + if len(first) != 36 || first[14] != '5' { + t.Fatalf("session ID = %q, want a version 5 UUID", first) + } +} + +func TestRequestUUIDIsMethodScoped(t *testing.T) { + sessionID := SessionUUID("agent", "session") + request := RequestUUID(sessionID, acs.StepToolCallRequest, "call") + result := RequestUUID(sessionID, acs.StepToolCallResult, "call") + if request == result { + t.Fatal("request and result hooks produced the same request ID") + } +} + +func TestRefusalProceedsWhereTheHookPermitsNoDeny(t *testing.T) { + for _, hook := range method.Hooks() { + t.Run(hook, func(t *testing.T) { + auditLog := filepath.Join(t.TempDir(), "audit.jsonl") + client := testClient(t, failingGuardian(t), auditLog, hook) + decision := client.RefuseModification(Step{SessionKey: "session", TurnID: "turn", CallID: "call", Method: hook}, "cannot apply") + want := acs.Deny + if !methods.HookRule(hook).Permits(acs.Deny) { + want = acs.Allow + } + if decision.Disposition != want || decision.ReasonCodes[0] != disposition.ReasonModifyUnsupported { + t.Fatalf("decision = %+v, want %s modify_unsupported", decision, want) + } + if audit := readFile(t, auditLog); !strings.Contains(audit, `"event":"modify_unsupported"`) { + t.Fatalf("audit log = %q", audit) + } + }) + } + if methods.HookRule(acs.StepPostCompact).Permits(acs.Deny) { + t.Fatal("the method table permits DENY at postCompact, so this test no longer covers §6.5's exception") + } +} + +// A decision failure proceeds, and is recorded, at a hook whose action has +// already happened. When the audit log refuses the record, the host refuses +// the step as far as the hook allows, and the record of that outcome goes to +// stderr instead. +func TestDecisionFailureProceedsWhereTheHookPermitsNoDeny(t *testing.T) { + blocker := unwritableDir(t) + for name, tt := range map[string]struct { + method string + posture acs.FailurePosture + auditLog string + want acs.Disposition + wantRecord bool + wantStderr bool + }{ + "post_compact": {acs.StepPostCompact, acs.FailureDeny, filepath.Join(t.TempDir(), "audit.jsonl"), acs.Allow, true, false}, + "post_compact_audit_unwritten": {acs.StepPostCompact, acs.FailureDeny, filepath.Join(blocker, "audit.jsonl"), acs.Allow, false, true}, + "tool_call_request": {acs.StepToolCallRequest, acs.FailureDeny, filepath.Join(t.TempDir(), "audit.jsonl"), acs.Deny, true, false}, + "tool_call_request_deny_unwritten": {acs.StepToolCallRequest, acs.FailureDeny, filepath.Join(blocker, "audit.jsonl"), acs.Deny, false, true}, + "tool_call_request_proceed_unwritten": {acs.StepToolCallRequest, acs.FailureProceed, filepath.Join(blocker, "audit.jsonl"), acs.Deny, false, true}, + } { + t.Run(name, func(t *testing.T) { + client := testClient(t, failingGuardian(t), tt.auditLog, tt.method) + var stderr bytes.Buffer + client.config.Stderr = &stderr + hello := acs.ServerHello{ + NegotiatedVersion: acs.Version, + MethodsEvaluated: []string{tt.method}, + SelectedTransport: acs.TransportHTTP, + TimeoutConfig: acs.TimeoutConfig{DefaultMS: 1000}, + OnDecisionFailure: tt.posture, + } + if err := saveState(client.config.StateDir, client.config.AgentID, "session", sessionState{Hello: hello}); err != nil { + t.Fatal(err) + } + evaluation, err := client.Evaluate(context.Background(), Step{SessionKey: "session", TurnID: "turn", CallID: "call", Method: tt.method, Payload: map[string]any{}}) + if err != nil { + t.Fatal(err) + } + if evaluation.Decision.Disposition != tt.want { + t.Fatalf("decision = %+v, want %s", evaluation.Decision, tt.want) + } + if tt.wantRecord && !strings.Contains(readFile(t, tt.auditLog), `"event":"decision_failure"`) { + t.Fatal("the decision failure was not recorded") + } + assertStderrRecord(t, stderr.String(), tt.wantStderr, "decision_failure", tt.method, tt.want) + }) + } +} + +// Every decision failure leaves one decision_failure record that names its +// class (§6.5), at the deny posture, where the step is denied. +func TestDecisionFailureRecordsItsClass(t *testing.T) { + payload := bashPayload(t) + tests := map[string]struct { + guardianURL func(t *testing.T) string + negotiated bool + decisionMS int64 + payload any + want string + }{ + "timeout": {blockingGuardian, true, 50, payload, failureTimeout}, + "unreachable": {closedGuardian, true, 1000, payload, failureUnreachable}, + "guardian_error": {realGuardian, false, 1000, map[string]any{}, failureGuardian}, + "signature_invalid": {tamperingGuardian, false, 1000, payload, failureSignature}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + auditLog := filepath.Join(t.TempDir(), "audit.jsonl") + client := testClient(t, tt.guardianURL(t), auditLog, acs.StepToolCallRequest) + if tt.negotiated { + hello := acs.ServerHello{ + NegotiatedVersion: acs.Version, + MethodsEvaluated: []string{acs.StepToolCallRequest}, + SelectedTransport: acs.TransportHTTP, + TimeoutConfig: acs.TimeoutConfig{DefaultMS: tt.decisionMS}, + OnDecisionFailure: acs.FailureDeny, + } + if err := saveState(client.config.StateDir, client.config.AgentID, "session", sessionState{Hello: hello}); err != nil { + t.Fatal(err) + } + } + evaluation, err := client.Evaluate(context.Background(), Step{SessionKey: "session", TurnID: "turn", CallID: "call", Method: acs.StepToolCallRequest, Payload: tt.payload}) + if err != nil { + t.Fatal(err) + } + if evaluation.Decision.Disposition != acs.Deny { + t.Fatalf("decision = %+v, want DENY", evaluation.Decision) + } + assertOneFailure(t, auditLog, tt.want, acs.Deny) + }) + } +} + +// A handshake that fails without a refusal leaves a handshake_failure record +// and returns the error to the caller. +func TestHandshakeFailureRecordsItsClass(t *testing.T) { + auditLog := filepath.Join(t.TempDir(), "audit.jsonl") + client := testClient(t, closedGuardian(t), auditLog, acs.StepToolCallRequest) + payload := bashPayload(t) + if _, err := client.Evaluate(context.Background(), Step{SessionKey: "session", TurnID: "turn", CallID: "call", Method: acs.StepToolCallRequest, Payload: payload}); err == nil { + t.Fatal("a failed handshake returned no error") + } + assertOneFailure(t, auditLog, failureHandshake, acs.Deny) +} + +func assertOneFailure(t *testing.T, auditLog, class string, disposition acs.Disposition) { + t.Helper() + var records []auditRecord + for _, line := range strings.Split(strings.TrimSuffix(readFile(t, auditLog), "\n"), "\n") { + var record auditRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatalf("audit line %q: %v", line, err) + } + records = append(records, record) + } + if len(records) != 1 || records[0].Event != "decision_failure" || records[0].FailureClass != class || records[0].Disposition != disposition { + t.Fatalf("audit records = %+v, want one decision_failure of class %s with disposition %s", records, class, disposition) + } +} + +// bashPayload is a valid tool call the Guardian evaluates. +func bashPayload(t *testing.T) acs.ToolCallRequestPayload { + t.Helper() + payload, err := ToolCallRequest("Bash", json.RawMessage(`{"command":"ls"}`)) + if err != nil { + t.Fatal(err) + } + return payload +} + +// blockingGuardian answers no request: every step times out at the client. +// It reads the body first, so the server notices the client going away. +func blockingGuardian(t *testing.T) string { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + <-r.Context().Done() + })) + t.Cleanup(server.Close) + return server.URL +} + +// closedGuardian is an address no server listens on. +func closedGuardian(t *testing.T) string { + t.Helper() + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + return server.URL +} + +// realGuardian is a Guardian that denies on failure, serving its real answers. +func realGuardian(t *testing.T) string { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: testKeyID, Secret: []byte(testSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: allowEngine{}, Signer: signer, OnDecisionFailure: acs.FailureDeny}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(g) + t.Cleanup(server.Close) + return server.URL +} + +// tamperingGuardian is realGuardian with each decision's reasoning changed +// after signing, so no answer to a step verifies. +func tamperingGuardian(t *testing.T) string { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: testKeyID, Secret: []byte(testSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: allowEngine{}, Signer: signer, OnDecisionFailure: acs.FailureDeny}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder := httptest.NewRecorder() + g.ServeHTTP(recorder, r) + body := bytes.Replace(recorder.Body.Bytes(), []byte(`"reasoning":"`+allowReasoning), []byte(`"reasoning":"tampered`), 1) + _, _ = w.Write(body) + })) + t.Cleanup(server.Close) + return server.URL +} + +// A refusal the host substitutes at a hook that permits no DENY is an ALLOW; +// when the audit log refuses its record, the record goes to stderr. +func TestSubstitutedAllowReachesStderrWhenTheAuditLogFails(t *testing.T) { + client := testClient(t, failingGuardian(t), filepath.Join(unwritableDir(t), "audit.jsonl"), acs.StepPostCompact) + var stderr bytes.Buffer + client.config.Stderr = &stderr + decision := client.RefuseModification(Step{SessionKey: "session", TurnID: "turn", CallID: "call", Method: acs.StepPostCompact, Payload: map[string]any{}}, "cannot apply") + if decision.Disposition != acs.Allow { + t.Fatalf("decision = %+v, want ALLOW", decision) + } + assertStderrRecord(t, stderr.String(), true, "modify_unsupported", acs.StepPostCompact, acs.Allow) +} + +// A signed MODIFY that fails §6.3 is refused as far as the hook allows: at +// postCompact the step proceeds unmodified and modify_invalid is recorded, +// in the audit log or, when that refuses it, on stderr. +func TestInvalidModificationProceedsWhereTheHookPermitsNoDeny(t *testing.T) { + for name, tt := range map[string]struct { + auditLog string + wantStderr bool + }{ + "audit_written": {filepath.Join(t.TempDir(), "audit.jsonl"), false}, + "audit_unwritten": {filepath.Join(unwritableDir(t), "audit.jsonl"), true}, + } { + t.Run(name, func(t *testing.T) { + client := testClient(t, invalidModifyGuardian(t, acs.StepPostCompact), tt.auditLog, acs.StepPostCompact) + var stderr bytes.Buffer + client.config.Stderr = &stderr + evaluation, err := client.Evaluate(context.Background(), Step{SessionKey: "session", TurnID: "turn", CallID: "call", Method: acs.StepPostCompact, Payload: map[string]any{}}) + if err != nil { + t.Fatal(err) + } + if evaluation.Decision.Disposition != acs.Allow || evaluation.ModifiedPayload != nil { + t.Fatalf("evaluation = %+v, want ALLOW with nothing modified", evaluation) + } + if !tt.wantStderr && !strings.Contains(readFile(t, tt.auditLog), `"event":"modify_invalid"`) { + t.Fatal("modify_invalid was not recorded") + } + assertStderrRecord(t, stderr.String(), tt.wantStderr, "modify_invalid", acs.StepPostCompact, acs.Allow) + if tt.wantStderr && !strings.HasSuffix(evaluation.Decision.Reasoning, "the record went to stderr") { + t.Fatalf("reasoning = %q, want it to say the record went to stderr", evaluation.Decision.Reasoning) + } + }) + } +} + +// When neither the audit log nor stderr takes the record, the refusal's +// reasoning names both failures, so the host can tell the record is missing. +func TestRefusalNamesEverySinkThatFailed(t *testing.T) { + client := testClient(t, failingGuardian(t), filepath.Join(unwritableDir(t), "audit.jsonl"), acs.StepPostCompact) + client.config.Stderr = failingWriter{} + hello := acs.ServerHello{ + NegotiatedVersion: acs.Version, + MethodsEvaluated: []string{acs.StepPostCompact}, + SelectedTransport: acs.TransportHTTP, + TimeoutConfig: acs.TimeoutConfig{DefaultMS: 1000}, + OnDecisionFailure: acs.FailureDeny, + } + if err := saveState(client.config.StateDir, client.config.AgentID, "session", sessionState{Hello: hello}); err != nil { + t.Fatal(err) + } + evaluation, err := client.Evaluate(context.Background(), Step{SessionKey: "session", TurnID: "turn", CallID: "call", Method: acs.StepPostCompact, Payload: map[string]any{}}) + if err != nil { + t.Fatal(err) + } + reasoning := evaluation.Decision.Reasoning + if evaluation.Decision.Disposition != acs.Allow || !strings.HasSuffix(reasoning, "to the audit log or to stderr") { + t.Fatalf("decision = %+v, want ALLOW naming the audit log and stderr failures", evaluation.Decision) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("closed") } + +func assertStderrRecord(t *testing.T, stderr string, want bool, event, method string, outcome acs.Disposition) { + t.Helper() + if !want { + if stderr != "" { + t.Fatalf("stderr = %q, want nothing", stderr) + } + return + } + lines := strings.Split(strings.TrimSuffix(stderr, "\n"), "\n") + if len(lines) != 1 { + t.Fatalf("stderr has %d lines, want one JSON line: %q", len(lines), stderr) + } + var record auditRecord + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatalf("stderr is not a JSON line: %v: %q", err, stderr) + } + if record.Event != event || record.Method != method || record.SessionKey != "session" || record.Disposition != outcome || record.AuditError == "" { + t.Fatalf("stderr record = %+v, want event %s at %s with outcome %s and the audit error", record, event, method, outcome) + } +} + +// unwritableDir returns a path under which no file can be created: it names +// a regular file. +func unwritableDir(t *testing.T) string { + t.Helper() + blocker := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatal(err) + } + return blocker +} + +func failingGuardian(t *testing.T) string { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + return server.URL +} + +type allowEngine struct{} + +const allowReasoning = "allowed by the test engine" + +func (allowEngine) Policy() guardian.PolicyDescription { return guardian.PolicyDescription{} } + +func (allowEngine) Decide(context.Context, guardian.PolicyInput) (guardian.PolicyDecision, error) { + return guardian.PolicyDecision{Decision: acs.Decision{Disposition: acs.Allow, Reasoning: allowReasoning}}, nil +} + +// invalidModifyGuardian answers the handshake as a real Guardian and every +// step of hook with a signed MODIFY that combines a replacement with edits, +// which §6.3 forbids. +func invalidModifyGuardian(t *testing.T, hook string) string { + t.Helper() + signer, err := guardian.NewHMACSigner(guardian.HMACKey{ID: testKeyID, Secret: []byte(testSecret)}) + if err != nil { + t.Fatal(err) + } + g, err := guardian.New(guardian.Config{Engine: allowEngine{}, Signer: signer}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + var request acs.Request + if err := json.Unmarshal(body, &request); err != nil { + t.Error(err) + return + } + if request.Method != hook { + r.Body = io.NopCloser(bytes.NewReader(body)) + g.ServeHTTP(w, r) + return + } + unsigned := `{"jsonrpc":"2.0","id":` + string(request.ID) + `,"result":{"type":"final","acs_version":"0.1.0","request_id":"` + request.Params.RequestID + `","decision":"modify","reasoning":"rewrite summary","modifications":{"modified_content":"{}","parameter_overrides":{}}}}` + input, _, err := envelope.ResponseSigningInput([]byte(unsigned)) + if err != nil { + t.Error(err) + return + } + signature, err := signer.Sign(r.Context(), request.Params.Metadata.SessionID, "", input) + if err != nil { + t.Error(err) + return + } + signed, err := json.Marshal(signature) + if err != nil { + t.Error(err) + return + } + _, _ = w.Write([]byte(strings.TrimSuffix(unsigned, "}}") + `,"signature":` + string(signed) + `}}`)) + })) + t.Cleanup(server.Close) + return server.URL +} + +const ( + testSecret = "0123456789abcdef0123456789abcdef" + testKeyID = "2026-10" +) + +func testClient(t *testing.T, guardianURL, auditLog, hook string) *Client { + t.Helper() + secret := filepath.Join(t.TempDir(), "hmac") + if err := os.WriteFile(secret, []byte(base64.StdEncoding.EncodeToString([]byte(testSecret))), 0o600); err != nil { + t.Fatal(err) + } + client, err := New(Config{ + GuardianURL: guardianURL, HMACKeyID: testKeyID, HMACSecretFile: secret, StateDir: t.TempDir(), AuditLog: auditLog, + AgentID: "agent", Methods: []string{hook}, Timeout: time.Second, MaxBodyBytes: 1 << 20, + }) + if err != nil { + t.Fatal(err) + } + return client +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/reference-implementations/agt-go/internal/hostadapter/modification.go b/reference-implementations/agt-go/internal/hostadapter/modification.go new file mode 100644 index 00000000..9263d649 --- /dev/null +++ b/reference-implementations/agt-go/internal/hostadapter/modification.go @@ -0,0 +1,58 @@ +package hostadapter + +import ( + "encoding/json" + "errors" + "reflect" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +func ModifiedToolCallInput(updated json.RawMessage, original acs.ToolCallRequestPayload) (map[string]any, error) { + var changed acs.ToolCallRequestPayload + if err := json.Unmarshal(updated, &changed); err != nil { + return nil, err + } + if !reflect.DeepEqual(changed.Tool, original.Tool) || + !reflect.DeepEqual(changed.Operation, original.Operation) || + !reflect.DeepEqual(changed.Capability, original.Capability) || + !reflect.DeepEqual(changed.RawCommand, original.RawCommand) || + !reflect.DeepEqual(changed.Intent, original.Intent) || + len(changed.Arguments) != len(original.Arguments) { + return nil, errors.New("modification changes a field the host cannot apply") + } + input := make(map[string]any, len(changed.Arguments)) + for name, before := range original.Arguments { + after, ok := changed.Arguments[name] + if !ok || !reflect.DeepEqual(after.Provenance, before.Provenance) { + return nil, errors.New("modification changes a field the host cannot apply") + } + var value any + if err := json.Unmarshal(after.Value, &value); err != nil { + return nil, err + } + input[name] = value + } + return input, nil +} + +func ModifiedToolCallResult(updated json.RawMessage, original acs.ToolCallResultPayload) (any, error) { + var changed acs.ToolCallResultPayload + if err := json.Unmarshal(updated, &changed); err != nil { + return nil, err + } + if !reflect.DeepEqual(changed.Tool, original.Tool) || + !reflect.DeepEqual(changed.Operation, original.Operation) || + !reflect.DeepEqual(changed.RequestIDRef, original.RequestIDRef) || + changed.ExitStatus != original.ExitStatus || + !reflect.DeepEqual(changed.DurationMS, original.DurationMS) || + len(changed.Outputs) != 1 || len(original.Outputs) != 1 || + !reflect.DeepEqual(changed.Outputs[0].Provenance, original.Outputs[0].Provenance) { + return nil, errors.New("modification changes a field the host cannot apply") + } + var result any + if err := json.Unmarshal(changed.Outputs[0].Value, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/reference-implementations/agt-go/internal/hostadapter/modification_test.go b/reference-implementations/agt-go/internal/hostadapter/modification_test.go new file mode 100644 index 00000000..b375cd23 --- /dev/null +++ b/reference-implementations/agt-go/internal/hostadapter/modification_test.go @@ -0,0 +1,54 @@ +package hostadapter + +import ( + "encoding/json" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +func TestModifiedToolCallInput(t *testing.T) { + original, err := ToolCallRequest("Bash", json.RawMessage(`{"command":"ls"}`)) + if err != nil { + t.Fatal(err) + } + changed := original + argument := changed.Arguments["command"] + argument.Value = json.RawMessage(`"pwd"`) + changed.Arguments = map[string]acs.ToolArgument{"command": argument} + updated, err := json.Marshal(changed) + if err != nil { + t.Fatal(err) + } + input, err := ModifiedToolCallInput(updated, original) + if err != nil || input["command"] != "pwd" { + t.Fatalf("valid modification: %v %#v", err, input) + } + + changed.Tool.Name = "Other" + updated, _ = json.Marshal(changed) + if _, err := ModifiedToolCallInput(updated, original); err == nil { + t.Fatal("a tool identity change was accepted") + } +} + +func TestModifiedToolCallResult(t *testing.T) { + original := ToolCallResult("Bash", "request", "success", json.RawMessage(`{"stdout":"old"}`)) + changed := original + changed.Outputs = append([]acs.ToolOutput(nil), original.Outputs...) + changed.Outputs[0].Value = json.RawMessage(`{"stdout":"new"}`) + updated, err := json.Marshal(changed) + if err != nil { + t.Fatal(err) + } + result, err := ModifiedToolCallResult(updated, original) + if err != nil || result.(map[string]any)["stdout"] != "new" { + t.Fatalf("valid modification: %v %#v", err, result) + } + + changed.ExitStatus = "failure" + updated, _ = json.Marshal(changed) + if _, err := ModifiedToolCallResult(updated, original); err == nil { + t.Fatal("an exit-status change was accepted") + } +} diff --git a/reference-implementations/agt-go/internal/jcs/jcs.go b/reference-implementations/agt-go/internal/jcs/jcs.go new file mode 100644 index 00000000..464a870b --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/jcs.go @@ -0,0 +1,42 @@ +// Package jcs produces the RFC 8785 JSON Canonicalization Scheme form that +// ACS hashes and signs: the §8.2 chain digest, the §10 signed input and +// ContextEntry.request_hash. +// +// The canonicalizer is the one in the upstream of Go's encoding/json/v2, +// which implements RFC 8785 directly. Go's encoding/json v1 is not used for +// canonical bytes: it escapes <, > and & and U+2028 and U+2029, and it +// formats numbers its own way. +package jcs + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/go-json-experiment/json/jsontext" +) + +// Canonicalize returns the RFC 8785 canonical form of the JSON text b. +// +// The input must be I-JSON (RFC 7493), as RFC 8785 requires: a text with a +// duplicate member name, invalid UTF-8 or an unpaired surrogate is refused, +// because two parsers would disagree about what it says and a signature over +// it would then cover two different messages. +func Canonicalize(b []byte) ([]byte, error) { + v := jsontext.Value(append([]byte(nil), b...)) + if err := v.Canonicalize(); err != nil { + return nil, fmt.Errorf("canonicalize: %w", err) + } + return v, nil +} + +// SHA256Hex returns the lowercase-hex SHA-256 of the canonical form of b, +// the digest §8.1 prescribes for request_hash. +func SHA256Hex(b []byte) (string, error) { + c, err := Canonicalize(b) + if err != nil { + return "", err + } + sum := sha256.Sum256(c) + return hex.EncodeToString(sum[:]), nil +} diff --git a/reference-implementations/agt-go/internal/jcs/jcs_test.go b/reference-implementations/agt-go/internal/jcs/jcs_test.go new file mode 100644 index 00000000..3c97a71b --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/jcs_test.go @@ -0,0 +1,162 @@ +package jcs + +import ( + "bufio" + "compress/gzip" + "encoding/binary" + "encoding/hex" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// The vectors under testdata/ are published by the author of RFC 8785 with +// the reference implementation, at +// https://github.com/cyberphone/json-canonicalization (commit 19d51d7, +// Apache License 2.0): input/ and output/ are its testdata directories, and +// es6-numbers.txt.gz is the first 5,000 lines of its es6testfile100m.txt.gz. +// They are the second implementation this canonicalizer is checked against. + +func TestReferenceVectors(t *testing.T) { + inputs, err := filepath.Glob("testdata/input/*.json") + if err != nil { + t.Fatal(err) + } + if len(inputs) != 6 { + t.Fatalf("found %d input vectors, want 6", len(inputs)) + } + for _, in := range inputs { + name := filepath.Base(in) + t.Run(strings.TrimSuffix(name, ".json"), func(t *testing.T) { + input, err := os.ReadFile(in) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(filepath.Join("testdata/output", name)) + if err != nil { + t.Fatal(err) + } + got, err := Canonicalize(input) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Errorf("got %s\nwant %s", got, want) + } + }) + } +} + +// TestES6Numbers feeds each IEEE-754 value in Go's own exponent notation and +// expects the ECMAScript serialization RFC 8785 §3.2.2.3 prescribes. +func TestES6Numbers(t *testing.T) { + f, err := os.Open("testdata/es6-numbers.txt.gz") + if err != nil { + t.Fatal(err) + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + lines := 0 + scanner := bufio.NewScanner(gz) + for scanner.Scan() { + hexBits, want, ok := strings.Cut(scanner.Text(), ",") + if !ok { + t.Fatalf("malformed line %q", scanner.Text()) + } + lines++ + value := math.Float64frombits(parseBits(t, hexBits)) + input := strconv.FormatFloat(value, 'e', -1, 64) + got, err := Canonicalize([]byte(input)) + if err != nil { + t.Fatalf("%s (%s): %v", hexBits, input, err) + } + if string(got) != want { + t.Errorf("%s: got %s, want %s", hexBits, got, want) + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + if lines != 5000 { + t.Fatalf("read %d number vectors, want 5000", lines) + } +} + +func parseBits(t *testing.T, s string) uint64 { + t.Helper() + b, err := hex.DecodeString(strings.Repeat("0", 16-len(s)) + s) + if err != nil { + t.Fatal(err) + } + return binary.BigEndian.Uint64(b) +} + +// TestRFC8785Example is the example of RFC 8785 §3.2.4. +func TestRFC8785Example(t *testing.T) { + input := `{ + "numbers": [333333333.33333329, 1E30, 4.50, + 2e-3, 0.000000000000000000000000001], + "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", + "literals": [null, true, false] +}` + want := `{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\u000f\nA'B\"\\\\\"/"}` + got, err := Canonicalize([]byte(input)) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("got %s\nwant %s", got, want) + } +} + +func TestRefusesNonIJSON(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"duplicate_member", `{"a":1,"a":2}`}, + {"nested_duplicate_member", `{"x":{"a":1,"a":1}}`}, + {"unpaired_high_surrogate", `"\ud800"`}, + {"unpaired_low_surrogate", `"\udc00x"`}, + {"invalid_utf8", "\"\xff\""}, + {"trailing_garbage", `{} {}`}, + {"not_json", `{a:1}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, err := Canonicalize([]byte(tt.input)); err == nil { + t.Fatalf("accepted %q as %q", tt.input, got) + } + }) + } +} + +func TestDoesNotModifyInput(t *testing.T) { + input := []byte(`{"b": 1, "a": 2}`) + before := string(input) + if _, err := Canonicalize(input); err != nil { + t.Fatal(err) + } + if string(input) != before { + t.Fatalf("input changed to %q", input) + } +} + +func TestSHA256Hex(t *testing.T) { + // SHA-256 of the canonical text {"a":1,"b":2}, computed independently + // with: printf '{"a":1,"b":2}' | sha256sum + const want = "43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777" + got, err := SHA256Hex([]byte(`{ "b": 2.0, "a": 1 }`)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("got %s, want %s", got, want) + } +} diff --git a/reference-implementations/agt-go/internal/jcs/testdata/es6-numbers.txt.gz b/reference-implementations/agt-go/internal/jcs/testdata/es6-numbers.txt.gz new file mode 100644 index 00000000..7a69e2d2 Binary files /dev/null and b/reference-implementations/agt-go/internal/jcs/testdata/es6-numbers.txt.gz differ diff --git a/reference-implementations/agt-go/internal/jcs/testdata/input/arrays.json b/reference-implementations/agt-go/internal/jcs/testdata/input/arrays.json new file mode 100644 index 00000000..20e62263 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/input/arrays.json @@ -0,0 +1,8 @@ +[ + 56, + { + "d": true, + "10": null, + "1": [ ] + } +] diff --git a/reference-implementations/agt-go/internal/jcs/testdata/input/french.json b/reference-implementations/agt-go/internal/jcs/testdata/input/french.json new file mode 100644 index 00000000..4ff6d3de --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/input/french.json @@ -0,0 +1,6 @@ +{ + "peach": "This sorting order", + "péché": "is wrong according to French", + "pêche": "but canonicalization MUST", + "sin": "ignore locale" +} diff --git a/reference-implementations/agt-go/internal/jcs/testdata/input/structures.json b/reference-implementations/agt-go/internal/jcs/testdata/input/structures.json new file mode 100644 index 00000000..eb71efb8 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/input/structures.json @@ -0,0 +1,8 @@ +{ + "1": {"f": {"f": "hi","F": 5} ,"\n": 56.0}, + "10": { }, + "": "empty", + "a": { }, + "111": [ {"e": "yes","E": "no" } ], + "A": { } +} \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/jcs/testdata/input/unicode.json b/reference-implementations/agt-go/internal/jcs/testdata/input/unicode.json new file mode 100644 index 00000000..4b5bc769 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/input/unicode.json @@ -0,0 +1,3 @@ +{ + "Unnormalized Unicode":"A\u030a" +} diff --git a/reference-implementations/agt-go/internal/jcs/testdata/input/values.json b/reference-implementations/agt-go/internal/jcs/testdata/input/values.json new file mode 100644 index 00000000..f7712c2f --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/input/values.json @@ -0,0 +1,5 @@ +{ + "numbers": [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001], + "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", + "literals": [null, true, false] +} \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/jcs/testdata/input/weird.json b/reference-implementations/agt-go/internal/jcs/testdata/input/weird.json new file mode 100644 index 00000000..53fabe67 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/input/weird.json @@ -0,0 +1,11 @@ +{ + "\u20ac": "Euro Sign", + "\r": "Carriage Return", + "\u000a": "Newline", + "1": "One", + "\u0080": "Control\u007f", + "\ud83d\ude02": "Smiley", + "\u00f6": "Latin Small Letter O With Diaeresis", + "\ufb33": "Hebrew Letter Dalet With Dagesh", + "": "Browser Challenge" +} diff --git a/reference-implementations/agt-go/internal/jcs/testdata/output/arrays.json b/reference-implementations/agt-go/internal/jcs/testdata/output/arrays.json new file mode 100644 index 00000000..5efb93db --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/output/arrays.json @@ -0,0 +1 @@ +[56,{"1":[],"10":null,"d":true}] \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/jcs/testdata/output/french.json b/reference-implementations/agt-go/internal/jcs/testdata/output/french.json new file mode 100644 index 00000000..2e15cd1b --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/output/french.json @@ -0,0 +1 @@ +{"peach":"This sorting order","péché":"is wrong according to French","pêche":"but canonicalization MUST","sin":"ignore locale"} \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/jcs/testdata/output/structures.json b/reference-implementations/agt-go/internal/jcs/testdata/output/structures.json new file mode 100644 index 00000000..dc21e242 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/output/structures.json @@ -0,0 +1 @@ +{"":"empty","1":{"\n":56,"f":{"F":5,"f":"hi"}},"10":{},"111":[{"E":"no","e":"yes"}],"A":{},"a":{}} \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/jcs/testdata/output/unicode.json b/reference-implementations/agt-go/internal/jcs/testdata/output/unicode.json new file mode 100644 index 00000000..ee60fd12 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/output/unicode.json @@ -0,0 +1 @@ +{"Unnormalized Unicode":"Å"} \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/jcs/testdata/output/values.json b/reference-implementations/agt-go/internal/jcs/testdata/output/values.json new file mode 100644 index 00000000..29b720b6 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/output/values.json @@ -0,0 +1 @@ +{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\u000f\nA'B\"\\\\\"/"} \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/jcs/testdata/output/weird.json b/reference-implementations/agt-go/internal/jcs/testdata/output/weird.json new file mode 100644 index 00000000..62c83a33 --- /dev/null +++ b/reference-implementations/agt-go/internal/jcs/testdata/output/weird.json @@ -0,0 +1 @@ +{"\n":"Newline","\r":"Carriage Return","1":"One","":"Browser Challenge","€":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😂":"Smiley","דּ":"Hebrew Letter Dalet With Dagesh"} \ No newline at end of file diff --git a/reference-implementations/agt-go/internal/mcp/mcp.go b/reference-implementations/agt-go/internal/mcp/mcp.go new file mode 100644 index 00000000..38169769 --- /dev/null +++ b/reference-implementations/agt-go/internal/mcp/mcp.go @@ -0,0 +1,120 @@ +// Package mcp reads wrapped MCP messages, protocols/MCP/* (extend_mcp.md). +// A wrapped call carries one MCP JSON-RPC 2.0 message intact in +// params.payload: a request or notification the Observed Agent is about to +// send, or the response it received, which it wraps under the method the +// response answers. +package mcp + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" +) + +// Protocol is the name MCP carries in protocols/MCP/* and in +// wrapped_protocols. +const Protocol = "MCP" + +// methodName is the form every MCP method name takes: slash-separated +// segments of letters, digits and underscores (initialize, +// tools/call, notifications/resources/list_changed). +var methodName = regexp.MustCompile(`^[A-Za-z0-9_]+(/[A-Za-z0-9_]+)*$`) + +// Handler is the wrapped-protocol handler for MCP. It reads every MCP +// method, since it checks the JSON-RPC shape every MCP message shares and +// leaves the method's meaning to the policy engine; a list of methods would +// refuse the next MCP revision's. +type Handler struct{} + +// Protocol implements method.WrappedHandler. +func (Handler) Protocol() string { return Protocol } + +// Supports implements method.WrappedHandler. +func (Handler) Supports(method string) bool { return methodName.MatchString(method) } + +// Message is the part of a wrapped MCP message the Guardian and the policy +// engine read. A request or notification has Method; a response has Result +// or Error. +type Message struct { + JSONRPC string `json:"jsonrpc"` + ID jsontext.Value `json:"id,omitzero"` + Method string `json:"method,omitzero"` + Params json.RawMessage `json:"params,omitzero"` + Result json.RawMessage `json:"result,omitzero"` + Error json.RawMessage `json:"error,omitzero"` +} + +// IsResponse reports whether m is a response rather than a request or a +// notification. +func (m Message) IsResponse() bool { return m.Method == "" } + +// Read decodes payload as the wrapped MCP message of method and checks it +// against JSON-RPC 2.0: version "2.0"; a request's method equal to the +// wrapped method and its params, when present, an object; a response's id +// present and exactly one of result, an object, and error, an object with +// an integer code and a string message. +func Read(method string, payload json.RawMessage) (Message, error) { + var m Message + if err := jsonv2.Unmarshal(payload, &m); err != nil { + return m, fmt.Errorf("the wrapped MCP message cannot be read: %w", err) + } + if m.JSONRPC != "2.0" { + return m, errors.New(`the wrapped MCP message must have jsonrpc "2.0"`) + } + if len(m.ID) > 0 && m.ID.Kind() != '"' && m.ID.Kind() != '0' { + return m, errors.New("the wrapped MCP message's id must be a string or a number") + } + if !m.IsResponse() { + if m.Method != method { + return m, fmt.Errorf("the wrapped MCP message is %s, and the envelope wraps %s", m.Method, method) + } + if len(m.Result) > 0 || len(m.Error) > 0 { + return m, errors.New("a wrapped MCP request carries neither result nor error") + } + if len(m.Params) > 0 && !isObject(m.Params) { + return m, errors.New("the wrapped MCP message's params must be an object") + } + return m, nil + } + if len(m.ID) == 0 { + return m, errors.New("a wrapped MCP response must carry the id of the request it answers") + } + switch { + case len(m.Result) > 0 && len(m.Error) > 0, len(m.Result) == 0 && len(m.Error) == 0: + return m, errors.New("a wrapped MCP response carries exactly one of result and error") + case len(m.Result) > 0 && !isObject(m.Result): + return m, errors.New("a wrapped MCP response's result must be an object") + case len(m.Error) > 0: + var e struct { + Code *int64 `json:"code"` + Message *string `json:"message"` + } + if err := jsonv2.Unmarshal(m.Error, &e); err != nil || e.Code == nil || e.Message == nil { + return m, errors.New("a wrapped MCP response's error must be an object with an integer code and a string message") + } + } + return m, nil +} + +// Check implements method.WrappedHandler. +func (Handler) Check(method string, payload json.RawMessage) error { + _, err := Read(method, payload) + return err +} + +// ParameterOverridePointer implements method.WrappedHandler. +func (Handler) ParameterOverridePointer(argument string) string { + escaped := strings.NewReplacer("~", "~0", "/", "~1").Replace(argument) + return "/params/arguments/" + escaped +} + +func isObject(b json.RawMessage) bool { + b = bytes.TrimSpace(b) + return len(b) > 0 && b[0] == '{' +} diff --git a/reference-implementations/agt-go/internal/mcp/mcp_test.go b/reference-implementations/agt-go/internal/mcp/mcp_test.go new file mode 100644 index 00000000..4270b465 --- /dev/null +++ b/reference-implementations/agt-go/internal/mcp/mcp_test.go @@ -0,0 +1,60 @@ +package mcp + +import ( + "encoding/json" + "testing" +) + +func TestSupports(t *testing.T) { + for m, want := range map[string]bool{ + "initialize": true, + "tools/call": true, + "notifications/resources/list_changed": true, + "server/discover": true, + "": false, + "tools/": false, + "/tools": false, + "tools//call": false, + "tools/call?x": false, + } { + if got := (Handler{}).Supports(m); got != want { + t.Errorf("Supports(%q) = %v, want %v", m, got, want) + } + } +} + +// The examples are extend_mcp.md's own: a tools/call request and its +// response, both wrapped under protocols/MCP/tools/call. +func TestCheck(t *testing.T) { + tests := []struct { + name string + method string + payload string + ok bool + }{ + {"request", "tools/call", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Barcelona"}}}`, true}, + {"notification", "notifications/initialized", `{"jsonrpc":"2.0","method":"notifications/initialized"}`, true}, + {"result", "tools/call", `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"sunny"}]}}`, true}, + {"error", "tools/call", `{"jsonrpc":"2.0","id":"a","error":{"code":-32602,"message":"unknown tool"}}`, true}, + {"other_method", "tools/call", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, false}, + {"version", "tools/call", `{"jsonrpc":"1.0","id":1,"method":"tools/call"}`, false}, + {"params_array", "tools/call", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":[1]}`, false}, + {"id_object", "tools/call", `{"jsonrpc":"2.0","id":{},"method":"tools/call"}`, false}, + {"request_with_result", "tools/call", `{"jsonrpc":"2.0","id":1,"method":"tools/call","result":{}}`, false}, + {"response_without_id", "tools/call", `{"jsonrpc":"2.0","result":{}}`, false}, + {"result_and_error", "tools/call", `{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":1,"message":"m"}}`, false}, + {"neither", "tools/call", `{"jsonrpc":"2.0","id":1}`, false}, + {"result_not_object", "tools/call", `{"jsonrpc":"2.0","id":1,"result":"x"}`, false}, + {"error_without_code", "tools/call", `{"jsonrpc":"2.0","id":1,"error":{"message":"m"}}`, false}, + {"not_json_rpc", "tools/call", `{"text":"hi"}`, false}, + {"duplicate_member", "tools/call", `{"jsonrpc":"2.0","jsonrpc":"2.0","method":"tools/call"}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Handler{}.Check(tt.method, json.RawMessage(tt.payload)) + if (err == nil) != tt.ok { + t.Fatalf("Check = %v, want ok=%v", err, tt.ok) + } + }) + } +} diff --git a/reference-implementations/agt-go/internal/method/method.go b/reference-implementations/agt-go/internal/method/method.go new file mode 100644 index 00000000..84db0891 --- /dev/null +++ b/reference-implementations/agt-go/internal/method/method.go @@ -0,0 +1,203 @@ +// Package method is the Guardian's method table: every method the standard +// defines, how the Guardian routes it, and which dispositions each hook +// permits (hooks.md, "Decision"). Wrapped protocols route through a registry +// of handlers, one per protocol name. +package method + +import ( + "encoding/json" + "slices" + "strings" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// Kind is how the Guardian routes a method. +type Kind int + +// The routes of the method table. +const ( + // Undefined is a method the standard does not define: MethodNotFound. + Undefined Kind = iota + // Handshake is handshake/hello. + Handshake + // Ping is system/ping. + Ping + // Hook is a native steps/* hook. + Hook + // Wrapped is a protocols// call. + Wrapped + // Unsupported is a method the standard defines and this Guardian never + // negotiates, the Inspect profile's agbom/*: CapabilityNotNegotiated. + Unsupported +) + +// HookRule is the rule a native hook's decision follows. +type HookRule struct { + // Permitted lists the dispositions the hook allows (hooks.md). + Permitted []acs.Disposition +} + +// Permits reports whether d is allowed at the hook. +func (r HookRule) Permits(d acs.Disposition) bool { return slices.Contains(r.Permitted, d) } + +var ( + allowOnly = []acs.Disposition{acs.Allow} + allowDeny = []acs.Disposition{acs.Allow, acs.Deny} + allowDenyMod = []acs.Disposition{acs.Allow, acs.Deny, acs.Modify} + allowModify = []acs.Disposition{acs.Allow, acs.Modify} + allFive = []acs.Disposition{acs.Allow, acs.Deny, acs.Modify, acs.Ask, acs.Defer} + wrappedAllowed = allFive +) + +// hooks is hooks.md's "Decision" line for every native hook. Audit-only and +// not decision-eligible hooks permit only ALLOW. +var hooks = map[string]HookRule{ + acs.StepSessionStart: {allowDeny}, + acs.StepAgentTrigger: {allowDenyMod}, + acs.StepTurnStart: {allowDeny}, + acs.StepUserMessage: {allowDenyMod}, + acs.StepAgentResponse: {allowDenyMod}, + acs.StepKnowledgeRetrieval: {allowDenyMod}, + acs.StepMemoryContextRetrieval: {allowDenyMod}, + acs.StepMemoryStore: {allowDenyMod}, + acs.StepToolCallRequest: {allFive}, + acs.StepToolCallResult: {allowDenyMod}, + acs.StepPreCompact: {allowDeny}, + acs.StepPostCompact: {allowModify}, + acs.StepSubagentStart: {allowDeny}, + acs.StepSubagentStop: {allowOnly}, + acs.StepSkillRegister: {allowDeny}, + acs.StepSkillLoad: {allowDeny}, + acs.StepSkillUnload: {allowOnly}, + acs.StepTurnEnd: {allowOnly}, + acs.StepSessionEnd: {allowOnly}, +} + +// Hooks lists every native hook, sorted. +func Hooks() []string { + names := make([]string, 0, len(hooks)) + for name := range hooks { + names = append(names, name) + } + slices.Sort(names) + return names +} + +// WrappedHandler reads a wrapped protocol's message for the policy engine. +// It is the only protocol-specific part of a wrapped call: authentication, +// validation of the envelope, the SessionContext append, the decision and +// the signature are the Guardian's, shared with the native hooks. +type WrappedHandler interface { + // Protocol is the protocol name as it appears in + // protocols// and in the ClientHello's wrapped_protocols. + Protocol() string + // Supports reports whether the handler reads method, the part after + // protocols//; negotiation evaluates only these. + Supports(method string) bool + // Check validates the wrapped message of method and returns an error + // naming what is wrong. + Check(method string, payload json.RawMessage) error + // ParameterOverridePointer returns the JSON pointer an override of the + // named argument addresses in this protocol's request payload. + ParameterOverridePointer(argument string) string +} + +// Table routes methods. It is built once and read concurrently. +type Table struct { + wrapped map[string]WrappedHandler +} + +// NewTable returns the method table with the given wrapped-protocol +// handlers registered. +func NewTable(handlers ...WrappedHandler) *Table { + t := &Table{wrapped: map[string]WrappedHandler{}} + for _, h := range handlers { + t.wrapped[h.Protocol()] = h + } + return t +} + +// Route returns how the Guardian answers name. +func (t *Table) Route(name string) Kind { + switch { + case name == acs.MethodHandshakeHello: + return Handshake + case name == acs.MethodSystemPing: + return Ping + case name == acs.MethodAgBOMSnapshot, name == acs.MethodAgBOMChanged: + return Unsupported + case strings.HasPrefix(name, acs.ProtocolsPrefix), strings.HasPrefix(name, wrappedPrefix): + return Wrapped + } + if _, ok := hooks[name]; ok { + return Hook + } + return Undefined +} + +// HookRule returns the rule of a native hook or of a wrapped call. +func (t *Table) HookRule(name string) HookRule { + if r, ok := hooks[name]; ok { + return r + } + return HookRule{wrappedAllowed} +} + +// WrappedHandler returns the handler registered for a wrapped call's +// protocol and the method within it, and false when no handler is +// registered or it does not read the method. +func (t *Table) WrappedHandler(name string) (WrappedHandler, string, bool) { + protocol, _, method, ok := SplitWrapped(name) + if !ok { + return nil, "", false + } + for registered, h := range t.wrapped { + if strings.EqualFold(registered, protocol) && h.Supports(method) { + return h, method, true + } + } + return nil, "", false +} + +// Evaluates reports whether name is a wrapped call a registered handler +// reads. +func (t *Table) Evaluates(name string) bool { + _, _, ok := t.WrappedHandler(name) + return ok +} + +// Protocols lists the registered wrapped protocols. +func (t *Table) Protocols() []string { + names := make([]string, 0, len(t.wrapped)) + for name := range t.wrapped { + names = append(names, name) + } + slices.Sort(names) + return names +} + +// wrappedPrefix starts the explicit-version wrapped form, +// wrapped:-/ (§3). +const wrappedPrefix = "wrapped:" + +// SplitWrapped splits protocols// and the explicit-version +// form wrapped:-/. The protocol name is returned as +// written; version is empty for the protocols/ form, which pins none. +func SplitWrapped(name string) (protocol, version, method string, ok bool) { + rest, explicit := strings.CutPrefix(name, wrappedPrefix) + if !explicit { + if rest, ok = strings.CutPrefix(name, acs.ProtocolsPrefix); !ok { + return "", "", "", false + } + } + protocol, method, ok = strings.Cut(rest, "/") + if explicit { + protocol, version, _ = strings.Cut(protocol, "-") + ok = ok && version != "" + } + if !ok || protocol == "" || method == "" { + return "", "", "", false + } + return protocol, version, method, true +} diff --git a/reference-implementations/agt-go/internal/method/method_test.go b/reference-implementations/agt-go/internal/method/method_test.go new file mode 100644 index 00000000..04954528 --- /dev/null +++ b/reference-implementations/agt-go/internal/method/method_test.go @@ -0,0 +1,142 @@ +package method + +import ( + "encoding/json" + "maps" + "os" + "slices" + "strings" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +type stub struct{} + +func (stub) Protocol() string { return "MCP" } +func (stub) Supports(m string) bool { return m == "tools/call" } +func (stub) Check(string, json.RawMessage) error { return nil } +func (stub) ParameterOverridePointer(argument string) string { + return "/params/arguments/" + argument +} + +func TestRoute(t *testing.T) { + table := NewTable(stub{}) + tests := map[string]Kind{ + acs.MethodHandshakeHello: Handshake, + acs.MethodSystemPing: Ping, + acs.StepToolCallRequest: Hook, + acs.StepSessionEnd: Hook, + "protocols/MCP/tools/call": Wrapped, + "protocols/A2A/x": Wrapped, + acs.MethodAgBOMSnapshot: Unsupported, + "wrapped:mcp-1/tools/call": Wrapped, + "steps/unknown": Undefined, + "system/deferredDecision": Undefined, + "trace/x": Undefined, + } + for name, want := range tests { + if got := table.Route(name); got != want { + t.Errorf("%s routes to %d, want %d", name, got, want) + } + } + if h, m, ok := table.WrappedHandler("protocols/MCP/tools/call"); !ok || h.Protocol() != "MCP" || m != "tools/call" { + t.Fatal("the registered handler was not found") + } + if h, m, ok := table.WrappedHandler("wrapped:mcp-2025-06-18/tools/call"); !ok || h.Protocol() != "MCP" || m != "tools/call" { + t.Fatal("the explicit-version form did not find the handler") + } + for _, name := range []string{"protocols/A2A/x", "protocols/MCP", "protocols//x", "protocols/MCP/tools/list", "wrapped:mcp/tools/call", "wrapped:a2a-0.2/message/send", acs.StepToolCallRequest} { + if _, _, ok := table.WrappedHandler(name); ok { + t.Errorf("%s found a handler", name) + } + } + if len(Hooks()) != 19 { + t.Fatalf("%d hooks, want the 19 of §5", len(Hooks())) + } +} + +func TestHookRules(t *testing.T) { + table := NewTable() + for name, allowed := range map[string][]acs.Disposition{ + acs.StepToolCallRequest: {acs.Allow, acs.Deny, acs.Modify, acs.Ask, acs.Defer}, + acs.StepPostCompact: {acs.Allow, acs.Modify}, + acs.StepSessionEnd: {acs.Allow}, + acs.StepSessionStart: {acs.Allow, acs.Deny}, + } { + rule := table.HookRule(name) + for _, d := range []acs.Disposition{acs.Allow, acs.Deny, acs.Modify, acs.Ask, acs.Defer} { + want := false + for _, a := range allowed { + want = want || a == d + } + if rule.Permits(d) != want { + t.Errorf("%s permits %s: %v", name, d, !want) + } + } + } +} + +// TestDenyFollowsTheHooksDecisionLines: whether a hook permits DENY is what +// a host's refusal at that hook rests on, so it is read from each hook's +// Decision line in hooks.md. Each line must start with one of the forms in +// decisionForms; a new form fails the test until it is classified. +func TestDenyFollowsTheHooksDecisionLines(t *testing.T) { + raw, err := os.ReadFile("../../../../docs/spec/instrument/hooks.md") + if err != nil { + t.Fatal(err) + } + documented := map[string]bool{} + hook := "" + for _, line := range strings.Split(string(raw), "\n") { + if name, ok := strings.CutPrefix(line, "## "); ok { + hook = "" + if !strings.Contains(name, "/") && name != "Overview" && name != "Common envelope" { + hook = acs.StepsPrefix + name + } + continue + } + if decision, ok := strings.CutPrefix(line, "**Decision:** "); ok && hook != "" { + permits, known := permitsDeny(decision) + if !known { + t.Fatalf("%s: unclassified Decision line %q; add its form to decisionForms", hook, decision) + } + documented[hook] = permits + } + } + if !slices.Equal(slices.Sorted(maps.Keys(documented)), Hooks()) { + t.Fatalf("hooks.md documents %v, and the method table holds %v", slices.Sorted(maps.Keys(documented)), Hooks()) + } + table := NewTable() + for hook, permitsDeny := range documented { + if table.HookRule(hook).Permits(acs.Deny) != permitsDeny { + t.Errorf("%s permits DENY: hooks.md says %v, the method table says %v", hook, permitsDeny, !permitsDeny) + } + } +} + +// decisionForms are the openings of the Decision lines in hooks.md, and +// whether each permits DENY. +var decisionForms = []struct { + prefix, states string + deny bool +}{ + {"ALLOW / DENY", "", true}, + {"Decision-eligible", "MAY deny", true}, + {"Decision-eligible", "MAY DENY", true}, + {"Decision-eligible", "MAY return DENY", true}, + {"Normally ALLOW. Guardian MAY DENY", "", true}, + {"Not decision-eligible", "", false}, + {"Audit only", "", false}, +} + +// permitsDeny classifies a Decision line by its opening and, where the +// opening alone does not say, by the sentence that grants DENY. +func permitsDeny(decision string) (permits, known bool) { + for _, f := range decisionForms { + if strings.HasPrefix(decision, f.prefix) && strings.Contains(decision, f.states) { + return f.deny, true + } + } + return false, false +} diff --git a/reference-implementations/agt-go/internal/observedagent/observedagent.go b/reference-implementations/agt-go/internal/observedagent/observedagent.go new file mode 100644 index 00000000..07970b70 --- /dev/null +++ b/reference-implementations/agt-go/internal/observedagent/observedagent.go @@ -0,0 +1,482 @@ +// Package observedagent is a minimal Observed Agent shared by tests and +// bundled host commands. It signs requests, sends them, checks each answer's +// signature, and applies a decision to the payload it governs (§6.3, §6.4, +// §6.5). +package observedagent + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + jsonv2 "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/disposition" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/envelope" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/jcs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/schema" +) + +// Transport carries one request body and returns the response body. +type Transport func(ctx context.Context, body []byte) ([]byte, error) + +// KeyedSigner signs and verifies as the Observed Agent's key holder. +type KeyedSigner interface { + Verify(ctx context.Context, sessionID string, input []byte, sig acs.Signature) error + Sign(ctx context.Context, sessionID, keyID string, input []byte) (acs.Signature, error) +} + +// Client is one Observed Agent session. +type Client struct { + Transport Transport + Signer KeyedSigner + // KeyID is the key the client signs with; empty lets the Signer choose. + KeyID string + AgentID string + SessionID string + // Now is the clock the request timestamps come from; nil is time.Now. + Now func() time.Time +} + +// Outcome is one answer as received. +type Outcome struct { + Raw []byte + Response acs.Response + // Result is set for a decision, Hello for a handshake, Error for a + // refusal. + Result *acs.Result + Hello *acs.ServerHello + Error *acs.Error + // Verified reports whether the answer's signature verified. + Verified bool + // InvalidModification reports that Result is the DENY replacing a signed + // MODIFY of invalid shape (§6.3); the caller must record it. + InvalidModification bool +} + +// Request describes one request to send; zero fields take fresh values. +type Request struct { + Method string + Payload any + RequestID string + Timestamp time.Time + Nonce string + // ChainHash, when set, is sent as metadata.session_state.chain_hash. + ChainHash string + // TurnID, when set, is sent as metadata.turn_id. + TurnID string + // Unsigned sends the request without a signature. + Unsigned bool +} + +var ( + responseSchemas *schema.Registry + responseSchemasErr error + responseSchemasOnce sync.Once +) + +// ErrMalformedResponse marks an answer that is not a well-formed response for +// the request: it does not parse, fails its schema, or answers another id. +var ErrMalformedResponse = errors.New("malformed response") + +func malformed(err error) error { return fmt.Errorf("%w: %w", ErrMalformedResponse, err) } + +// NewUUID returns a random version 4 UUID. +func NewUUID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// Envelope builds and signs the request body r describes. +func (c *Client) Envelope(ctx context.Context, r Request) ([]byte, error) { + if r.RequestID == "" { + r.RequestID = NewUUID() + } + if r.Timestamp.IsZero() { + r.Timestamp = c.now() + } + payload, err := jsonv2.Marshal(r.Payload) + if err != nil { + return nil, err + } + params := acs.Params{ + ACSVersion: acs.Version, + RequestID: r.RequestID, + Timestamp: r.Timestamp.UTC().Format(time.RFC3339Nano), + Metadata: acs.Metadata{AgentID: c.AgentID, SessionID: c.SessionID}, + Payload: payload, + } + if r.Nonce != "" { + params.Nonce = &r.Nonce + } + if r.TurnID != "" { + params.Metadata.TurnID = &r.TurnID + } + if r.ChainHash != "" { + params.Metadata.SessionState = &acs.SessionState{ChainHash: &r.ChainHash} + } + id, _ := jsonv2.Marshal(r.RequestID) + req := acs.Request{JSONRPC: acs.JSONRPCVersion, Method: r.Method, ID: id, Params: params} + body, err := jsonv2.Marshal(req) + if err != nil || r.Unsigned { + return body, err + } + input, err := jcs.Canonicalize(body) + if err != nil { + return nil, err + } + sig, err := c.Signer.Sign(ctx, c.SessionID, c.KeyID, input) + if err != nil { + return nil, err + } + req.Params.Signature = &sig + return jsonv2.Marshal(req) +} + +// Send builds, signs and sends r, and reads the answer. +func (c *Client) Send(ctx context.Context, r Request) (Outcome, error) { + if r.RequestID == "" { + r.RequestID = NewUUID() + } + body, err := c.Envelope(ctx, r) + if err != nil { + return Outcome{}, err + } + raw, err := c.Transport(ctx, body) + if err != nil { + return Outcome{}, err + } + o, err := c.read(ctx, raw, r.Method) + if err != nil { + return o, err + } + if string(o.Response.ID) == "null" { + if o.Error == nil { + return Outcome{}, malformed(fmt.Errorf("successful response has null id for request_id %q", r.RequestID)) + } + } else { + var responseID string + if err := json.Unmarshal(o.Response.ID, &responseID); err != nil || responseID != r.RequestID { + return Outcome{}, malformed(fmt.Errorf("response id does not match request_id %q", r.RequestID)) + } + } + if o.Result != nil && o.Result.RequestID != r.RequestID { + return Outcome{}, malformed(fmt.Errorf("result request_id does not match request_id %q", r.RequestID)) + } + return o, nil +} + +// SendBody sends a body as it is and reads the answer. +func (c *Client) SendBody(ctx context.Context, body []byte) (Outcome, error) { + raw, err := c.Transport(ctx, body) + if err != nil { + return Outcome{}, err + } + return c.Read(ctx, raw) +} + +// Read parses an answer and verifies its signature. Callers that know the +// request method should use Send, which also checks the response's shape for +// that method. +func (c *Client) Read(ctx context.Context, raw []byte) (Outcome, error) { + return c.read(ctx, raw, "") +} + +func (c *Client) read(ctx context.Context, raw []byte, requestMethod string) (Outcome, error) { + o := Outcome{Raw: raw} + if err := jsonv2.Unmarshal(raw, &o.Response); err != nil { + return o, malformed(fmt.Errorf("answer is not a response envelope: %w", err)) + } + if err := validateResponse(raw); err != nil { + if denial, ok := c.invalidModifyDenial(ctx, raw, o.Response); ok { + return denial, nil + } + return o, malformed(fmt.Errorf("answer fails response-envelope.json: %w", err)) + } + o.Error = o.Response.Error + if o.Error != nil { + if o.Error.Signature != nil { + input, _, err := envelope.ResponseSigningInput(raw) + if err != nil { + return o, malformed(err) + } + o.Verified = c.Signer.Verify(ctx, c.SessionID, input, *o.Error.Signature) == nil + } + return o, nil + } + if o.Response.Result == nil { + return o, nil + } + var sig *acs.Signature + isHandshake := requestMethod == acs.MethodHandshakeHello + if requestMethod == "" { + var members map[string]json.RawMessage + if err := json.Unmarshal(o.Response.Result, &members); err != nil { + return o, malformed(err) + } + _, hasType := members["type"] + _, hasNegotiatedVersion := members["negotiated_version"] + isHandshake = !hasType && hasNegotiatedVersion + } + if isHandshake { + o.Hello = &acs.ServerHello{} + if err := jsonv2.Unmarshal(o.Response.Result, o.Hello); err != nil { + return o, malformed(err) + } + if err := validateServerHello(o.Response.Result); err != nil { + return o, malformed(fmt.Errorf("handshake result fails handshake.json: %w", err)) + } + sig = o.Hello.Signature + } else { + o.Result = &acs.Result{} + if err := jsonv2.Unmarshal(o.Response.Result, o.Result); err != nil { + return o, malformed(err) + } + if o.Result.Type != acs.ResultTypeFinal { + return o, malformed(fmt.Errorf("result type is %q, want %q", o.Result.Type, acs.ResultTypeFinal)) + } + sig = o.Result.Signature + } + if sig != nil { + input, _, err := envelope.ResponseSigningInput(raw) + if err != nil { + return o, malformed(err) + } + o.Verified = c.Signer.Verify(ctx, c.SessionID, input, *sig) == nil + } + return o, nil +} + +// invalidModifyDenial implements §6.3's receiver rule for the one invalid +// response shape that has a mandatory outcome: a signed MODIFY combining a +// wholesale replacement with structured edits is DENY, not a failure posture. +func (c *Client) invalidModifyDenial(ctx context.Context, raw []byte, response acs.Response) (Outcome, bool) { + var result struct { + Type string `json:"type"` + ACSVersion string `json:"acs_version"` + RequestID string `json:"request_id"` + Decision acs.Disposition `json:"decision"` + Reasoning string `json:"reasoning"` + Signature *acs.Signature `json:"signature"` + Modifications map[string]json.RawMessage `json:"modifications"` + } + if json.Unmarshal(response.Result, &result) != nil || + result.Type != acs.ResultTypeFinal || + result.Decision != acs.Modify || + result.Signature == nil { + return Outcome{}, false + } + _, hasReplacement := result.Modifications["modified_content"] + _, hasRedactions := result.Modifications["redactions"] + _, hasOverrides := result.Modifications["parameter_overrides"] + if !hasReplacement || (!hasRedactions && !hasOverrides) { + return Outcome{}, false + } + input, _, err := envelope.ResponseSigningInput(raw) + if err != nil || c.Signer.Verify(ctx, c.SessionID, input, *result.Signature) != nil { + return Outcome{}, false + } + reason := result.Reasoning + if reason == "" { + reason = "the Guardian returned an invalid modification" + } + return Outcome{ + Raw: raw, + Response: response, + Result: &acs.Result{ + Type: result.Type, + ACSVersion: result.ACSVersion, + RequestID: result.RequestID, + Decision: acs.Decision{ + Disposition: acs.Deny, + Reasoning: reason, + }, + }, + Verified: true, + InvalidModification: true, + }, true +} + +func responseSchemaRegistry() (*schema.Registry, error) { + responseSchemasOnce.Do(func() { responseSchemas, responseSchemasErr = schema.Load() }) + return responseSchemas, responseSchemasErr +} + +func validateResponse(raw []byte) error { + registry, err := responseSchemaRegistry() + if err != nil { + return err + } + return registry.ValidateJSON(schema.ResponseEnvelope, raw) +} + +func validateServerHello(raw []byte) error { + registry, err := responseSchemaRegistry() + if err != nil { + return err + } + return registry.ValidateJSON(schema.ServerHello, raw) +} + +// Handshake negotiates the session. +func (c *Client) Handshake(ctx context.Context, hello acs.ClientHello) (Outcome, error) { + return c.Send(ctx, Request{Method: acs.MethodHandshakeHello, Payload: hello}) +} + +// DefaultHello declares every native hook and the ACS-Core profile. +func DefaultHello(methods ...string) acs.ClientHello { + return acs.ClientHello{ + ACSVersionsSupported: []string{acs.Version}, + MethodsImplemented: methods, + TransportsSupported: []string{acs.TransportHTTP}, + ProvenanceProducer: acs.ProvenanceNone, + ProfilesSupported: []string{acs.ProfileCore}, + } +} + +func (c *Client) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// ErrBlocked reports a step the decision does not let proceed. +var ErrBlocked = errors.New("the decision blocks the step") + +// Honour returns the decision the Observed Agent applies to a step it sent +// (§6.4): the Guardian's, when a result arrived and its signature verified; +// DENY, whatever the posture, when the Guardian answered but the answer +// refuses the request or cannot be trusted (Refused); otherwise, on a +// transport failure, a timeout or another error answer, the negotiated +// failure posture, proceed as ALLOW and deny as DENY. verified reports the +// first case; a caller records every other one as a decision failure (§6.5). +func Honour(o Outcome, err error, posture acs.FailurePosture) (d acs.Decision, verified bool) { + if err == nil && o.Result != nil && o.Verified { + return o.Result.Decision, true + } + if Refused(o, err) { + return acs.Decision{Disposition: acs.Deny, Reasoning: "the Guardian refused the request or its answer did not verify, which fails closed under any posture"}, false + } + if posture == acs.FailureDeny { + return acs.Decision{Disposition: acs.Deny, Reasoning: "no verified decision arrived, and the failure posture is deny"}, false + } + return acs.Decision{Disposition: acs.Allow, Reasoning: "no verified decision arrived, and the failure posture is proceed"}, false +} + +// Refused reports an answer that must not fall to a proceed posture: a +// result or error whose signature does not verify, an answer that is not a +// well-formed response, or a signed error other than -32603, which refuses +// the request itself. The failure posture is kept for a Guardian that did +// not decide: no answer, or a signed internal error (#32, open). +func Refused(o Outcome, err error) bool { + if err != nil { + return errors.Is(err, ErrMalformedResponse) + } + if !o.Verified { + return o.Result != nil || o.Error != nil + } + return o.Error != nil && o.Error.Code != acs.InternalError +} + +// Apply honours a decision for a payload (§6.4): ALLOW proceeds unchanged, +// MODIFY proceeds with the modification applied (§6.3), and DENY, ASK and +// DEFER block. A modification that breaks §6.3 or addresses nothing is +// treated as DENY (§6.3, §6.5). +func Apply(d acs.Decision, payload json.RawMessage) (json.RawMessage, error) { + if err := disposition.Check(d); err != nil { + return nil, err + } + switch d.Disposition { + case acs.Allow: + return payload, nil + case acs.Modify: + if d.Modifications == nil { + return nil, fmt.Errorf("%w: modify without modifications", ErrBlocked) + } + return modify(*d.Modifications, payload) + default: + return nil, fmt.Errorf("%w: %s", ErrBlocked, d.Disposition) + } +} + +func modify(m acs.Modifications, payload json.RawMessage) (json.RawMessage, error) { + if m.ModifiedContent != nil { + if m.Redactions != nil || m.ParameterOverrides != nil { + return nil, fmt.Errorf("%w: modified_content combined with structured edits", ErrBlocked) + } + return jsonv2.Marshal(*m.ModifiedContent) + } + var doc any + if err := jsonv2.Unmarshal(payload, &doc); err != nil { + return nil, err + } + for _, r := range m.Redactions { + replacement := "[REDACTED]" + if r.Replacement != nil { + replacement = *r.Replacement + } + if err := set(doc, r.Path, replacement); err != nil { + return nil, fmt.Errorf("%w: redaction %s: %v", ErrBlocked, r.Path, err) + } + } + for name, value := range m.ParameterOverrides { + var v any + if err := jsonv2.Unmarshal(value, &v); err != nil { + return nil, err + } + if err := set(doc, "/arguments/"+escape(name)+"/value", v); err != nil { + return nil, fmt.Errorf("%w: override %s: %v", ErrBlocked, name, err) + } + } + return jsonv2.Marshal(doc) +} + +// set replaces the existing value a JSON pointer (RFC 6901) addresses. +func set(doc any, pointer string, value any) error { + if pointer == "" || !strings.HasPrefix(pointer, "/") { + return errors.New("not a pointer to a member") + } + tokens := strings.Split(pointer[1:], "/") + node := doc + for i, raw := range tokens { + token := strings.NewReplacer("~1", "/", "~0", "~").Replace(raw) + last := i == len(tokens)-1 + switch n := node.(type) { + case map[string]any: + if _, ok := n[token]; !ok { + return fmt.Errorf("%q is absent", token) + } + if last { + n[token] = value + return nil + } + node = n[token] + case []any: + idx, err := strconv.Atoi(token) + if err != nil || idx < 0 || idx >= len(n) { + return fmt.Errorf("index %q is out of range", token) + } + if last { + n[idx] = value + return nil + } + node = n[idx] + default: + return fmt.Errorf("%q addresses inside a scalar", token) + } + } + return nil +} + +func escape(s string) string { return strings.NewReplacer("~", "~0", "/", "~1").Replace(s) } diff --git a/reference-implementations/agt-go/internal/observedagent/observedagent_test.go b/reference-implementations/agt-go/internal/observedagent/observedagent_test.go new file mode 100644 index 00000000..744d5f85 --- /dev/null +++ b/reference-implementations/agt-go/internal/observedagent/observedagent_test.go @@ -0,0 +1,209 @@ +package observedagent + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +type acceptingSigner struct{} + +func (acceptingSigner) Verify(context.Context, string, []byte, acs.Signature) error { return nil } + +func (acceptingSigner) Sign(context.Context, string, string, []byte) (acs.Signature, error) { + return acs.Signature{Algorithm: acs.SignatureAlgorithmHMACSHA256, KeyID: "test", Value: "test"}, nil +} + +// §6.4: a verified decision is applied as it is; a refusal or an answer that +// cannot be trusted denies under any posture (#32); anything else falls to +// the failure posture, and only proceed fails open. +func TestHonour(t *testing.T) { + deny := &acs.Result{Decision: acs.Decision{Disposition: acs.Deny, Reasoning: "no"}} + tests := []struct { + name string + outcome Outcome + err error + }{ + {"transport_failure", Outcome{}, errors.New("connection refused")}, + {"timeout", Outcome{}, context.DeadlineExceeded}, + {"signed_internal_error", Outcome{Error: &acs.Error{Code: acs.InternalError}, Verified: true}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if d, verified := Honour(tt.outcome, tt.err, acs.FailureProceed); d.Disposition != acs.Allow || verified { + t.Fatalf("proceed: %s, verified %v", d.Disposition, verified) + } + if d, verified := Honour(tt.outcome, tt.err, acs.FailureDeny); d.Disposition != acs.Deny || verified { + t.Fatalf("deny: %s, verified %v", d.Disposition, verified) + } + }) + } + refused := []struct { + name string + outcome Outcome + err error + }{ + {"unverified_result", Outcome{Result: &acs.Result{Decision: acs.Decision{Disposition: acs.Allow}}}, nil}, + {"malformed_answer", Outcome{}, malformed(errors.New("not JSON"))}, + {"unsigned_body_too_large", Outcome{Error: &acs.Error{Code: acs.InvalidRequest}}, nil}, + {"signed_replay", Outcome{Error: &acs.Error{Code: acs.ReplayDetected}, Verified: true}, nil}, + {"signed_invalid_params", Outcome{Error: &acs.Error{Code: acs.InvalidParams}, Verified: true}, nil}, + {"signed_chain_mismatch", Outcome{Error: &acs.Error{Code: acs.ChainMismatch}, Verified: true}, nil}, + {"signature_invalid", Outcome{Error: &acs.Error{Code: acs.SignatureInvalid}}, nil}, + {"unsigned_internal_error", Outcome{Error: &acs.Error{Code: acs.InternalError}}, nil}, + } + for _, tt := range refused { + t.Run(tt.name, func(t *testing.T) { + if d, verified := Honour(tt.outcome, tt.err, acs.FailureProceed); d.Disposition != acs.Deny || verified { + t.Fatalf("proceed: %s, verified %v", d.Disposition, verified) + } + }) + } + if d, verified := Honour(Outcome{Result: deny, Verified: true}, nil, acs.FailureProceed); d.Disposition != acs.Deny || !verified { + t.Fatalf("a verified decision became %s", d.Disposition) + } +} + +func TestSendRejectsAResponseForAnotherRequest(t *testing.T) { + client := &Client{ + Signer: acceptingSigner{}, + AgentID: "agent", + SessionID: "session", + Transport: func(context.Context, []byte) ([]byte, error) { + return []byte(`{"jsonrpc":"2.0","id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","result":{"type":"final","acs_version":"0.1.0","request_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","decision":"allow","signature":{"algorithm":"HMAC-SHA256","key_id":"test","value":"test"}}}`), nil + }, + } + _, err := client.Send(context.Background(), Request{ + Method: acs.StepToolCallRequest, + Payload: map[string]any{}, + RequestID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }) + if err == nil { + t.Fatal("accepted a verified response for another request") + } +} + +func TestSendAcceptsAnUncorrelatedError(t *testing.T) { + client := &Client{ + Signer: acceptingSigner{}, + AgentID: "agent", + SessionID: "session", + Transport: func(context.Context, []byte) ([]byte, error) { + return []byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"shutting down"}}`), nil + }, + } + outcome, err := client.Send(context.Background(), Request{ + Method: acs.StepToolCallRequest, + Payload: map[string]any{}, + RequestID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }) + if err != nil || outcome.Error == nil { + t.Fatalf("outcome = %+v, error = %v", outcome, err) + } +} + +func TestSendIgnoresUnknownResultFields(t *testing.T) { + client := &Client{ + Signer: acceptingSigner{}, + AgentID: "agent", + SessionID: "session", + Transport: func(context.Context, []byte) ([]byte, error) { + return []byte(`{"jsonrpc":"2.0","id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","result":{"type":"final","acs_version":"0.1.0","request_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","decision":"deny","reasoning":"policy denied","negotiated_version":42,"signature":{"algorithm":"HMAC-SHA256","key_id":"test","value":"test"}}}`), nil + }, + } + outcome, err := client.Send(context.Background(), Request{ + Method: acs.StepToolCallRequest, + Payload: map[string]any{}, + RequestID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }) + if err != nil || outcome.Result == nil || outcome.Result.Disposition != acs.Deny { + t.Fatalf("outcome = %+v, error = %v", outcome, err) + } + if decision, verified := Honour(outcome, nil, acs.FailureProceed); decision.Disposition != acs.Deny || !verified { + t.Fatalf("honoured decision = %+v, verified = %v", decision, verified) + } +} + +func TestSendRejectsMalformedHandshakeResult(t *testing.T) { + client := &Client{ + Signer: acceptingSigner{}, + AgentID: "agent", + SessionID: "session", + Transport: func(context.Context, []byte) ([]byte, error) { + return []byte(`{"jsonrpc":"2.0","id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","result":{"negotiated_version":"0.1.0","signature":{"algorithm":"HMAC-SHA256","key_id":"test","value":"test"}}}`), nil + }, + } + _, err := client.Handshake(context.Background(), DefaultHello(acs.StepToolCallRequest)) + if err == nil { + t.Fatal("accepted an incomplete ServerHello") + } +} + +func TestHandshakeIgnoresUnknownResultFields(t *testing.T) { + client := &Client{ + Signer: acceptingSigner{}, + AgentID: "agent", + SessionID: "session", + Transport: func(context.Context, []byte) ([]byte, error) { + return []byte(`{"jsonrpc":"2.0","id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","result":{"negotiated_version":"0.1.0","methods_evaluated":["steps/toolCallRequest"],"selected_transport":"http","timeout_config":{"default_ms":1000},"type":42,"signature":{"algorithm":"HMAC-SHA256","key_id":"test","value":"test"}}}`), nil + }, + } + outcome, err := client.Send(context.Background(), Request{ + Method: acs.MethodHandshakeHello, + Payload: DefaultHello(acs.StepToolCallRequest), + RequestID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }) + if err != nil || outcome.Hello == nil || outcome.Hello.NegotiatedVersion != acs.Version { + t.Fatalf("outcome = %+v, error = %v", outcome, err) + } +} + +func TestSendFailsClosedForMixedModifyShapes(t *testing.T) { + for name, modifications := range map[string]string{ + "redactions": `{"modified_content":"{}","redactions":[]}`, + "overrides": `{"modified_content":"{}","parameter_overrides":{}}`, + "both": `{"modified_content":"{}","redactions":[],"parameter_overrides":{}}`, + } { + t.Run(name, func(t *testing.T) { + client := &Client{ + Signer: acceptingSigner{}, + AgentID: "agent", + SessionID: "session", + Transport: func(context.Context, []byte) ([]byte, error) { + return []byte(`{"jsonrpc":"2.0","id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","result":{"type":"final","acs_version":"0.1.0","request_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","decision":"modify","reasoning":"rewrite command","modifications":` + modifications + `,"signature":{"algorithm":"HMAC-SHA256","key_id":"test","value":"test"}}}`), nil + }, + } + outcome, err := client.Send(context.Background(), Request{ + Method: acs.StepToolCallRequest, + Payload: map[string]any{}, + RequestID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }) + if err != nil || outcome.Result == nil || outcome.Result.Disposition != acs.Deny || !outcome.Verified || !outcome.InvalidModification { + t.Fatalf("outcome = %+v, error = %v", outcome, err) + } + if decision, verified := Honour(outcome, nil, acs.FailureProceed); decision.Disposition != acs.Deny || !verified { + t.Fatalf("honoured decision = %+v, verified = %v", decision, verified) + } + }) + } +} + +func TestApplyRejectsOverlappingStructuredEdits(t *testing.T) { + decision := acs.Decision{ + Disposition: acs.Modify, + Reasoning: "rewrite the command", + Modifications: &acs.Modifications{ + Redactions: []acs.Redaction{{Path: "/arguments/command"}}, + ParameterOverrides: map[string]json.RawMessage{ + "command": json.RawMessage(`"printf rewritten"`), + }, + }, + } + _, err := Apply(decision, json.RawMessage(`{"arguments":{"command":{"value":"printf original"}}}`)) + if err == nil { + t.Fatal("accepted overlapping structured edits") + } +} diff --git a/reference-implementations/agt-go/internal/schema/gen.go b/reference-implementations/agt-go/internal/schema/gen.go new file mode 100644 index 00000000..0b897e34 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/gen.go @@ -0,0 +1,45 @@ +//go:build ignore + +// gen.go refreshes specification/v0.1.0/ in this package from the +// repository's specification/v0.1.0/, file for file. Run it through +// go generate ./internal/schema. +package main + +import ( + "io/fs" + "log" + "os" + "path/filepath" +) + +const ( + source = "../../../../specification/v0.1.0" + target = "specification/v0.1.0" +) + +func main() { + if err := os.RemoveAll(target); err != nil { + log.Fatal(err) + } + err := filepath.WalkDir(source, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + dst := filepath.Join(target, rel) + if d.IsDir() { + return os.MkdirAll(dst, 0o755) + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dst, b, 0o644) + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/reference-implementations/agt-go/internal/schema/schema.go b/reference-implementations/agt-go/internal/schema/schema.go new file mode 100644 index 00000000..6c0df39d --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/schema.go @@ -0,0 +1,214 @@ +// Package schema validates ACS messages against the specification's own JSON +// Schemas. +// +// specification/v0.1.0/ in this package is a byte-identical copy of the +// repository's specification/v0.1.0/, embedded so the Guardian carries its +// schemas wherever the module is built. TestSchemasMatchSpecification fails +// when the two differ; go generate ./internal/schema refreshes the copy. +package schema + +//go:generate go run gen.go + +import ( + "bytes" + "embed" + "errors" + "fmt" + "io/fs" + "strings" + + "github.com/go-json-experiment/json" + "github.com/santhosh-tekuri/jsonschema/v6" + "golang.org/x/text/language" + "golang.org/x/text/message" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +//go:embed specification/v0.1.0 +var files embed.FS + +const root = "specification/v0.1.0" + +// Base is the namespace every schema $id in the package sits under. +const Base = "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/" + +// References of the schemas the Guardian validates against, relative to +// Base. +const ( + RequestEnvelope = "request-envelope.json" + ResponseEnvelope = "response-envelope.json" + ContextEntry = "context-entry.json" + ServerHello = "handshake.json#/$defs/ServerHello" +) + +// payloadSchemas maps each method to the schema its params.payload is +// validated against. ACS-Core validates the base hook schemas; the +// *.acs-provenance.json variants belong to the ACS-Provenance profile. +var payloadSchemas = map[string]string{ + acs.MethodHandshakeHello: "handshake.json#/$defs/ClientHello", + acs.MethodSystemPing: "hooks/system-ping.json", + acs.MethodAgBOMSnapshot: "hooks/agbom-snapshot.json", + acs.MethodAgBOMChanged: "hooks/agbom-changed.json", + acs.StepSessionStart: "hooks/session-start.json", + acs.StepAgentTrigger: "hooks/agent-trigger.json", + acs.StepTurnStart: "hooks/turn-start.json", + acs.StepUserMessage: "hooks/user-message.json", + acs.StepAgentResponse: "hooks/agent-response.json", + acs.StepKnowledgeRetrieval: "hooks/knowledge-retrieval.json", + acs.StepMemoryContextRetrieval: "hooks/memory-context-retrieval.json", + acs.StepMemoryStore: "hooks/memory-store.json", + acs.StepToolCallRequest: "hooks/tool-call-request.json", + acs.StepToolCallResult: "hooks/tool-call-result.json", + acs.StepPreCompact: "hooks/pre-compact.json", + acs.StepPostCompact: "hooks/post-compact.json", + acs.StepSubagentStart: "hooks/subagent-start.json", + acs.StepSubagentStop: "hooks/subagent-stop.json", + acs.StepSkillRegister: "hooks/skill-register.json", + acs.StepSkillLoad: "hooks/skill-load.json", + acs.StepSkillUnload: "hooks/skill-unload.json", + acs.StepTurnEnd: "hooks/turn-end.json", + acs.StepSessionEnd: "hooks/session-end.json", +} + +// Registry holds every schema the Guardian validates against, compiled once. +type Registry struct { + byRef map[string]*jsonschema.Schema + payloads map[string]*jsonschema.Schema +} + +// Error reports the first place an instance fails its schema. +type Error struct { + // Pointer is the JSON pointer of the failing value within the instance. + Pointer string + Message string +} + +func (e *Error) Error() string { + if e.Pointer == "" { + return "schema validation failed at the root: " + e.Message + } + return fmt.Sprintf("schema validation failed at %s: %s", e.Pointer, e.Message) +} + +// ErrNoPayloadSchema is returned for a method the specification defines no +// payload schema for. +var ErrNoPayloadSchema = errors.New("no payload schema for method") + +// Load compiles every schema of the embedded specification. +func Load() (*Registry, error) { + c := jsonschema.NewCompiler() + c.AssertFormat() + c.UseLoader(jsonschema.SchemeURLLoader{}) + err := fs.WalkDir(files, root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := files.ReadFile(p) + if err != nil { + return err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + return fmt.Errorf("%s: %w", p, err) + } + want := Base + strings.TrimPrefix(p, root+"/") + obj, _ := doc.(map[string]any) + if id, _ := obj["$id"].(string); id != want { + return fmt.Errorf("%s: $id is %q, want %q", p, id, want) + } + return c.AddResource(want, doc) + }) + if err != nil { + return nil, fmt.Errorf("load schemas: %w", err) + } + r := &Registry{byRef: map[string]*jsonschema.Schema{}, payloads: map[string]*jsonschema.Schema{}} + compile := func(ref string) (*jsonschema.Schema, error) { + if s, ok := r.byRef[ref]; ok { + return s, nil + } + s, err := c.Compile(Base + ref) + if err != nil { + return nil, fmt.Errorf("compile %s: %w", ref, err) + } + r.byRef[ref] = s + return s, nil + } + for _, ref := range []string{RequestEnvelope, ResponseEnvelope, ContextEntry, ServerHello} { + if _, err := compile(ref); err != nil { + return nil, err + } + } + for method, ref := range payloadSchemas { + s, err := compile(ref) + if err != nil { + return nil, err + } + r.payloads[method] = s + } + return r, nil +} + +// Validate checks a decoded JSON instance against the schema ref names, one +// of the references declared in this package. +func (r *Registry) Validate(ref string, instance any) error { + s, ok := r.byRef[ref] + if !ok { + return fmt.Errorf("schema %s is not registered", ref) + } + return check(s, instance) +} + +// ValidatePayload checks a decoded params.payload against the payload schema +// of method. +func (r *Registry) ValidatePayload(method string, payload any) error { + s, ok := r.payloads[method] + if !ok { + return fmt.Errorf("%w %s", ErrNoPayloadSchema, method) + } + return check(s, payload) +} + +// ValidateJSON decodes b and checks it against the schema ref names. +func (r *Registry) ValidateJSON(ref string, b []byte) error { + var instance any + if err := json.Unmarshal(b, &instance); err != nil { + return err + } + return r.Validate(ref, instance) +} + +func check(s *jsonschema.Schema, instance any) error { + err := s.Validate(instance) + if err == nil { + return nil + } + var ve *jsonschema.ValidationError + if !errors.As(err, &ve) { + return err + } + leaf := firstLeaf(ve) + return &Error{Pointer: pointer(leaf.InstanceLocation), Message: leaf.ErrorKind.LocalizedString(printer)} +} + +var printer = message.NewPrinter(language.English) + +// firstLeaf descends to the first innermost cause, which names the specific +// keyword that failed rather than the enclosing schema. +func firstLeaf(e *jsonschema.ValidationError) *jsonschema.ValidationError { + for len(e.Causes) > 0 { + e = e.Causes[0] + } + return e +} + +// pointer renders an instance location as an RFC 6901 JSON pointer; the +// root is the empty pointer. +func pointer(location []string) string { + var b strings.Builder + for _, token := range location { + b.WriteByte('/') + b.WriteString(strings.NewReplacer("~", "~0", "/", "~1").Replace(token)) + } + return b.String() +} diff --git a/reference-implementations/agt-go/internal/schema/schema_test.go b/reference-implementations/agt-go/internal/schema/schema_test.go new file mode 100644 index 00000000..eaec735e --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/schema_test.go @@ -0,0 +1,308 @@ +package schema + +import ( + "bytes" + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/go-json-experiment/json" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// specificationDir is the repository's own specification, relative to this +// package. +const specificationDir = "../../../../specification/v0.1.0" + +func TestSchemasMatchSpecification(t *testing.T) { + want := readTree(t, os.DirFS(specificationDir), ".") + got := readTree(t, files, root) + for name, b := range want { + c, ok := got[name] + switch { + case !ok: + t.Errorf("%s is in specification/v0.1.0/ but not in the embedded copy", name) + case !bytes.Equal(b, c): + t.Errorf("%s differs from specification/v0.1.0/%s", name, name) + } + } + for name := range got { + if _, ok := want[name]; !ok { + t.Errorf("%s is in the embedded copy but not in specification/v0.1.0/", name) + } + } + if t.Failed() { + t.Log("run go generate ./internal/schema to refresh the copy") + } +} + +func readTree(t *testing.T, fsys fs.FS, dir string) map[string][]byte { + t.Helper() + tree := map[string][]byte{} + err := fs.WalkDir(fsys, dir, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := fs.ReadFile(fsys, p) + if err != nil { + return err + } + rel, err := filepath.Rel(dir, p) + tree[filepath.ToSlash(rel)] = b + return err + }) + if err != nil { + t.Fatal(err) + } + if len(tree) == 0 { + t.Fatalf("no files under %s", dir) + } + return tree +} + +func TestEveryHookSchemaHasAMethod(t *testing.T) { + entries, err := fs.ReadDir(files, root+"/hooks") + if err != nil { + t.Fatal(err) + } + mapped := map[string]bool{} + for _, ref := range payloadSchemas { + mapped[ref] = true + } + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".acs-provenance.json") { + continue + } + if !mapped["hooks/"+name] { + t.Errorf("hooks/%s is not the payload schema of any method", name) + } + } +} + +func TestLoad(t *testing.T) { + r := mustLoad(t) + if got := len(r.payloads); got != len(payloadSchemas) { + t.Fatalf("compiled %d payload schemas, want %d", got, len(payloadSchemas)) + } +} + +func mustLoad(t *testing.T) *Registry { + t.Helper() + r, err := Load() + if err != nil { + t.Fatal(err) + } + return r +} + +const validRequest = `{ + "jsonrpc": "2.0", + "method": "steps/toolCallRequest", + "id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "params": { + "acs_version": "0.1.0", + "request_id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "timestamp": "2026-09-19T10:00:00Z", + "metadata": {"agent_id": "agent", "session_id": "5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30"}, + "payload": {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}} + } +}` + +func TestValidateRequest(t *testing.T) { + r := mustLoad(t) + tests := []struct { + name string + mutate func(map[string]any) + pointer string + }{ + {"valid", func(map[string]any) {}, ""}, + {"missing_request_id", func(m map[string]any) { delete(params(m), "request_id") }, "/params"}, + {"request_id_not_uuid", func(m map[string]any) { params(m)["request_id"] = "not-a-uuid" }, "/params/request_id"}, + {"timestamp_not_date_time", func(m map[string]any) { params(m)["timestamp"] = "yesterday" }, "/params/timestamp"}, + {"session_id_not_uuid", func(m map[string]any) { + params(m)["metadata"].(map[string]any)["session_id"] = "demo" + }, "/params/metadata/session_id"}, + {"unknown_top_level_member", func(m map[string]any) { m["Method"] = "x" }, ""}, + {"method_outside_namespaces", func(m map[string]any) { m["method"] = "tools/call" }, "/method"}, + {"nonce_too_short", func(m map[string]any) { params(m)["nonce"] = "short" }, "/params/nonce"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var m map[string]any + if err := json.Unmarshal([]byte(validRequest), &m); err != nil { + t.Fatal(err) + } + tt.mutate(m) + err := r.Validate(RequestEnvelope, m) + if tt.name == "valid" { + if err != nil { + t.Fatal(err) + } + return + } + var se *Error + if !errors.As(err, &se) { + t.Fatalf("got %v, want a schema error", err) + } + if se.Pointer != tt.pointer { + t.Fatalf("pointer %q, want %q (%s)", se.Pointer, tt.pointer, se.Message) + } + }) + } +} + +func params(m map[string]any) map[string]any { return m["params"].(map[string]any) } + +func TestValidatePayload(t *testing.T) { + r := mustLoad(t) + var ok, missing any + if err := json.Unmarshal([]byte(`{"tool":{"name":"Bash"},"arguments":{}}`), &ok); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(`{"tool":{"name":"Bash"}}`), &missing); err != nil { + t.Fatal(err) + } + if err := r.ValidatePayload(acs.StepToolCallRequest, ok); err != nil { + t.Fatal(err) + } + var se *Error + if err := r.ValidatePayload(acs.StepToolCallRequest, missing); !errors.As(err, &se) { + t.Fatalf("got %v, want a schema error", err) + } + if err := r.ValidatePayload("steps/undefined", ok); !errors.Is(err, ErrNoPayloadSchema) { + t.Fatalf("got %v, want ErrNoPayloadSchema", err) + } +} + +// TestWireTypesMatchSchemas checks every JSON member name of the acs types +// against the properties the schema declares for that object, and every +// required property against the type, so a mistyped tag cannot pass as an +// unknown member the schema tolerates. +func TestWireTypesMatchSchemas(t *testing.T) { + tests := []struct { + value any + ref string + extra []string // members the type carries beyond the schema, each declared in the type's doc + missing []string // schema properties the type leaves to raw JSON or omits on purpose + }{ + {acs.Request{}, "request-envelope.json#", nil, nil}, + {acs.Params{}, "request-envelope.json#/$defs/AcsParams", nil, nil}, + {acs.Metadata{}, "request-envelope.json#/$defs/Metadata", nil, nil}, + {acs.SessionState{}, "request-envelope.json#/$defs/Metadata/properties/session_state", nil, nil}, + {acs.UserContext{}, "request-envelope.json#/$defs/Metadata/properties/user_context", nil, nil}, + {acs.Signature{}, "request-envelope.json#/$defs/Signature", nil, nil}, + {acs.Response{}, "response-envelope.json#", nil, nil}, + {acs.Result{}, "response-envelope.json#/$defs/AcsResult", nil, nil}, + {acs.Error{}, "response-envelope.json#/$defs/JsonRpcError", []string{"signature"}, nil}, + {acs.PolicyReference{}, "response-envelope.json#/$defs/AcsResult/properties/policy_references/items", nil, nil}, + {acs.EvaluationMetadata{}, "response-envelope.json#/$defs/AcsResult/properties/metadata", nil, nil}, + {acs.Modifications{}, "modifications.json#", nil, nil}, + {acs.Redaction{}, "modifications.json#/properties/redactions/items", nil, nil}, + {acs.AskDetails{}, "ask-details.json#", nil, nil}, + {acs.Approver{}, "ask-details.json#/properties/approver", nil, nil}, + {acs.ApproverAuth{}, "ask-details.json#/properties/approver/properties/auth", nil, nil}, + {acs.IntentExtension{}, "ask-details.json#/properties/intent_extension", nil, nil}, + {acs.Capability{}, "ask-details.json#/properties/intent_extension/properties/capabilities/items", nil, nil}, + {acs.DeferDetails{}, "defer-details.json#", nil, nil}, + {acs.ClientHello{}, "handshake.json#/$defs/ClientHello", nil, nil}, + {acs.WrappedProtocol{}, "handshake.json#/$defs/ClientHello/properties/wrapped_protocols/items", nil, nil}, + {acs.ServerHello{}, "handshake.json#/$defs/ServerHello", []string{"signature"}, []string{"agbom_serializations_supported", "trace_emission"}}, + {acs.TimeoutConfig{}, "handshake.json#/$defs/ServerHello/properties/timeout_config", nil, nil}, + {acs.ContextEntry{}, "context-entry.json#", []string{"turn_id", "approver", "intent_extension"}, nil}, + {acs.ProvenanceSummary{}, "provenance-summary.json#", nil, nil}, + {acs.Provenance{}, "provenance.json#", nil, nil}, + {acs.Intent{}, "hooks/session-start.json#/properties/intent", nil, nil}, + {acs.SessionStartPayload{}, "hooks/session-start.json#", nil, nil}, + {acs.AgentTriggerPayload{}, "hooks/agent-trigger.json#", nil, nil}, + {acs.SessionEndPayload{}, "hooks/session-end.json#", nil, nil}, + {acs.SystemPingPayload{}, "hooks/system-ping.json#", nil, nil}, + {acs.ToolCallRequestPayload{}, "hooks/tool-call-request.json#", nil, nil}, + {acs.Tool{}, "hooks/tool-call-request.json#/properties/tool", nil, nil}, + {acs.ToolArgument{}, "hooks/tool-call-request.json#/properties/arguments/additionalProperties", nil, nil}, + {acs.ToolCallIntent{}, "hooks/tool-call-request.json#/properties/intent", nil, nil}, + {acs.ToolCallResultPayload{}, "hooks/tool-call-result.json#", nil, nil}, + {acs.ToolOutput{}, "hooks/tool-call-result.json#/properties/outputs/items", nil, nil}, + {acs.SkillRegisterPayload{}, "hooks/skill-register.json#", nil, []string{"skill", "composition", "registration_provenance"}}, + {acs.SkillDefinition{}, "hooks/skill-register.json#/properties/definition", nil, []string{"ref", "body"}}, + {acs.Digest{}, "hooks/skill-load.json#/properties/digest", nil, nil}, + {acs.SkillLoadPayload{}, "hooks/skill-load.json#", nil, []string{"registration_ref", "parent_step_id", "digest_verified", "declared_capabilities"}}, + {acs.PostCompactPayload{}, "hooks/post-compact.json#", nil, nil}, + {acs.CompactSummary{}, "hooks/post-compact.json#/properties/summary", nil, nil}, + } + for _, tt := range tests { + name := reflect.TypeOf(tt.value).Name() + t.Run(name, func(t *testing.T) { + node := resolve(t, tt.ref) + props, _ := node["properties"].(map[string]any) + fields := jsonNames(reflect.TypeOf(tt.value)) + for _, f := range fields { + if _, ok := props[f]; !ok && !slices.Contains(tt.extra, f) { + t.Errorf("member %q is not a property of %s", f, tt.ref) + } + } + for p := range props { + if !slices.Contains(fields, p) && !slices.Contains(tt.missing, p) { + t.Errorf("property %q of %s has no member in %s", p, tt.ref, name) + } + } + required, _ := node["required"].([]any) + for _, r := range required { + if !slices.Contains(fields, r.(string)) { + t.Errorf("required property %q of %s has no member in %s", r, tt.ref, name) + } + } + }) + } +} + +// resolve returns the schema object ref names, following a JSON pointer +// fragment within one file. +func resolve(t *testing.T, ref string) map[string]any { + t.Helper() + file, fragment, _ := strings.Cut(ref, "#") + b, err := files.ReadFile(root + "/" + file) + if err != nil { + t.Fatal(err) + } + var node any + if err := json.Unmarshal(b, &node); err != nil { + t.Fatal(err) + } + for _, token := range strings.Split(strings.TrimPrefix(fragment, "/"), "/") { + if token == "" { + continue + } + m, ok := node.(map[string]any) + if !ok { + t.Fatalf("%s: %q does not address an object", ref, token) + } + node = m[token] + } + m, ok := node.(map[string]any) + if !ok { + t.Fatalf("%s does not address a schema object", ref) + } + return m +} + +// jsonNames lists the JSON member names of a struct type, embedded structs +// inlined. +func jsonNames(typ reflect.Type) []string { + var names []string + for f := range typ.Fields() { + if f.Anonymous { + names = append(names, jsonNames(f.Type)...) + continue + } + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + names = append(names, name) + } + return names +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/acs_schema.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/acs_schema.json new file mode 100644 index 00000000..47dee84d --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/acs_schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/acs_schema.json", + "title": "ACS Schema (v0.1.0 aggregator)", + "description": "Aggregator manifest for the modular ACS v0.1.0 JSON Schema package. Each subschema lives as its own file under specification/v0.1.0/ and carries an $id under https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/...; this file lists the package contents and cross-references them via relative $ref so a single document can be loaded as the entry point. Validators that need a self-contained bundle should resolve $ref by loading the referenced files; validators that follow $id can ignore this aggregator and load the modular files directly.", + "version": "0.1.0", + + "$defs": { + "RequestEnvelope": { "$ref": "../v0.1.0/request-envelope.json" }, + "ResponseEnvelope": { "$ref": "../v0.1.0/response-envelope.json" }, + "Handshake": { "$ref": "../v0.1.0/handshake.json" }, + "Provenance": { "$ref": "../v0.1.0/provenance.json" }, + "ProvenanceSummary": { "$ref": "../v0.1.0/provenance-summary.json" }, + "ContextEntry": { "$ref": "../v0.1.0/context-entry.json" }, + "AskDetails": { "$ref": "../v0.1.0/ask-details.json" }, + "DeferDetails": { "$ref": "../v0.1.0/defer-details.json" }, + "Modifications": { "$ref": "../v0.1.0/modifications.json" }, + + "Hook_SessionStart": { "$ref": "../v0.1.0/hooks/session-start.json" }, + "Hook_SessionEnd": { "$ref": "../v0.1.0/hooks/session-end.json" }, + "Hook_AgentTrigger": { "$ref": "../v0.1.0/hooks/agent-trigger.json" }, + "Hook_TurnStart": { "$ref": "../v0.1.0/hooks/turn-start.json" }, + "Hook_TurnEnd": { "$ref": "../v0.1.0/hooks/turn-end.json" }, + "Hook_UserMessage": { "$ref": "../v0.1.0/hooks/user-message.json" }, + "Hook_AgentResponse": { "$ref": "../v0.1.0/hooks/agent-response.json" }, + "Hook_KnowledgeRetrieval": { "$ref": "../v0.1.0/hooks/knowledge-retrieval.json" }, + "Hook_MemoryContextRetrieval": { "$ref": "../v0.1.0/hooks/memory-context-retrieval.json" }, + "Hook_MemoryStore": { "$ref": "../v0.1.0/hooks/memory-store.json" }, + "Hook_ToolCallRequest": { "$ref": "../v0.1.0/hooks/tool-call-request.json" }, + "Hook_ToolCallResult": { "$ref": "../v0.1.0/hooks/tool-call-result.json" }, + "Hook_PreCompact": { "$ref": "../v0.1.0/hooks/pre-compact.json" }, + "Hook_PostCompact": { "$ref": "../v0.1.0/hooks/post-compact.json" }, + "Hook_SubagentStart": { "$ref": "../v0.1.0/hooks/subagent-start.json" }, + "Hook_SubagentStop": { "$ref": "../v0.1.0/hooks/subagent-stop.json" }, + "Hook_SkillRegister": { "$ref": "../v0.1.0/hooks/skill-register.json" }, + "Hook_SkillLoad": { "$ref": "../v0.1.0/hooks/skill-load.json" }, + "Hook_SkillUnload": { "$ref": "../v0.1.0/hooks/skill-unload.json" }, + "Hook_SystemPing": { "$ref": "../v0.1.0/hooks/system-ping.json" }, + "Hook_AgbomSnapshot": { "$ref": "../v0.1.0/hooks/agbom-snapshot.json" }, + "Hook_AgbomChanged": { "$ref": "../v0.1.0/hooks/agbom-changed.json" }, + + "AgBOMComponent": { "$ref": "../v0.1.0/agbom/component.json" }, + "AgBOMDocument": { "$ref": "../v0.1.0/agbom/document.json" }, + + "InspectFormatMapping": { "$ref": "../v0.1.0/inspect/format-mapping.json" }, + "TraceOtelMapping": { "$ref": "../v0.1.0/trace/otel-mapping.json" }, + "TraceOcsfMapping": { "$ref": "../v0.1.0/trace/ocsf-mapping.json" } + }, + + "oneOf": [ + { "$ref": "#/$defs/RequestEnvelope" }, + { "$ref": "#/$defs/ResponseEnvelope" } + ] +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/agbom/component.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/agbom/component.json new file mode 100644 index 00000000..9f75d196 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/agbom/component.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/agbom/component.json", + "title": "ACS AgBOM Component", + "description": "Single component in an Agent Bill of Materials. Discriminated by 'type'. Every component carries provenance of its registration so AgBOM mutations are traceable in the same lineage system as data flow.", + "type": "object", + "required": ["id", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable identifier for this component within the AgBOM. SHOULD remain stable across version upgrades of the same logical component, so 'changed' diffs reference an unchanging id." + }, + "type": { + "type": "string", + "enum": ["model", "mcp_server", "a2a_peer", "tool", "knowledge_source", "memory_store", "agent_capability", "skill"], + "description": "Component type. Drives which type-specific fields are required." + }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "provider": { "type": "string" }, + "endpoint": { + "type": "string", + "format": "uri", + "description": "URI per Section 3.11 (file:///, posix:///, https://, etc.)." + }, + "registration_provenance": { + "$ref": "../provenance.json", + "description": "Provenance of how this component entered the AgBOM. origin SHOULD be 'system' for framework/configuration registrations and 'user_input' / 'tool_output' for runtime-discovered or user-installed components." + }, + "model_fields": { + "type": "object", + "description": "Required when type='model'.", + "required": ["context_window"], + "properties": { + "context_window": { "type": "integer", "minimum": 0 }, + "args": { + "type": "object", + "additionalProperties": true, + "description": "Snapshot of model configuration (temperature, top_p, system prompt hash, etc.) at registration time." + } + } + }, + "mcp_server_fields": { + "type": "object", + "description": "Required when type='mcp_server'.", + "required": ["tools"], + "properties": { + "tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Component ids of tool components exposed by this MCP server." + } + } + }, + "a2a_peer_fields": { + "type": "object", + "description": "Required when type='a2a_peer'.", + "required": ["protocol_version"], + "properties": { + "protocol_version": { "type": "string" }, + "agent_card_ref": { "type": "string", "format": "uri" } + } + }, + "tool_fields": { + "type": "object", + "description": "Required when type='tool'.", + "required": ["capability"], + "properties": { + "capability": { + "type": "string", + "description": "Abstract capability per Section 3.11 (e.g., filesystem.delete, network.egress, process.execute)." + } + } + }, + "knowledge_source_fields": { + "type": "object", + "description": "Required when type='knowledge_source'.", + "required": ["source_type"], + "properties": { + "source_type": { + "type": "string", + "enum": ["vector_db", "search_index", "knowledge_base", "web_search", "other"] + }, + "schema_ref": { "type": "string", "format": "uri" } + } + }, + "memory_store_fields": { + "type": "object", + "description": "Required when type='memory_store'.", + "required": ["scope", "store_type"], + "properties": { + "scope": { "type": "string", "enum": ["session", "user", "tenant", "global"] }, + "store_type": { "type": "string" }, + "path": { "type": "string", "format": "uri" }, + "window_size": { "type": "integer", "minimum": 0 } + } + }, + "agent_capability_fields": { + "type": "object", + "description": "Required when type='agent_capability'. References to other components in the same AgBOM by id.", + "required": ["description"], + "properties": { + "description": { "type": "string" }, + "tools": { "type": "array", "items": { "type": "string" } }, + "mcp_servers": { "type": "array", "items": { "type": "string" } }, + "a2a_peers": { "type": "array", "items": { "type": "string" } } + } + }, + "skill_fields": { + "type": "object", + "description": "Required when type='skill'. A skill is loadable executable content that composes lower-level components into a higher-level ability. Composition references reuse the agent_capability shape; definition and declared_capabilities are what a skill adds. A skill differs from an agent_capability in carrying a definition artifact, the surface attackers poison. The spec persists only its reference and integrity digest, never its body (see steps/skillRegister for where the body is inspected).", + "required": ["description", "definition"], + "properties": { + "description": { "type": "string" }, + "definition": { + "type": "object", + "description": "Reference and integrity digest for the skill's COMPLETE loadable artifact, including any bundled or fetched model files, adapters, or other binary assets, not only text. The body itself MUST NOT be required on the wire. Only the reference and digest persist in the AgBOM; the full body is available for inspection in the transient steps/skillRegister payload. A model-bearing skill, whose backdoor can live in opaque weights rather than readable instructions, is governed here through the digest and registration_provenance rather than source inspection.", + "required": ["ref", "digest"], + "properties": { + "ref": { "type": "string", "format": "uri", "description": "URI of the complete loadable skill artifact per Section 3.11 (file:///, https://, etc.), including any bundled model files or adapters." }, + "digest": { + "type": "object", + "description": "Integrity digest over the complete loadable artifact, not only its text. Lets a Guardian detect tampering between registration and load, and bind a steps/skillLoad to the exact artifact vetted at steps/skillRegister via the (skill_id, digest) pair.", + "required": ["algorithm", "value"], + "properties": { + "algorithm": { "type": "string", "enum": ["sha-256", "sha-512"] }, + "value": { "type": "string" } + } + } + } + }, + "declared_capabilities": { + "type": "array", + "items": { "type": "string" }, + "description": "Abstract capabilities the skill declares it needs (e.g., filesystem.write, network.egress): the skill's least-privilege manifest. A Guardian SHOULD compare this against the union of capabilities exposed by the composed tools and MAY deny a skill that declares more than it composes." + }, + "tools": { "type": "array", "items": { "type": "string" }, "description": "Component ids of tools this skill composes." }, + "mcp_servers": { "type": "array", "items": { "type": "string" }, "description": "Component ids of MCP servers this skill composes." }, + "a2a_peers": { "type": "array", "items": { "type": "string" }, "description": "Component ids of A2A peers this skill composes." }, + "models": { + "type": "array", + "items": { "type": "string" }, + "description": "Component ids of model components this skill bundles. A model-bearing skill, one shipping learned weights where a backdoor can live in opaque parameters rather than any readable instruction, references its bundled model here so the AgBOM inventories it as a first-class 'model' component carrying its own registration_provenance. The whole-artifact definition.digest already binds those weights; listing the model here makes it visible to inventory and load-time policy instead of hidden inside the opaque definition body, which is the provenance-sensitive handling a model-bearing skill needs. A bundled artifact that does not fit the 'model' component's contract (no served endpoint, no language-model context window) is still covered by definition.digest and registration_provenance over the whole skill." + }, + "composed_skills": { + "type": "array", + "items": { "type": "string" }, + "description": "Component ids of other skills this skill is permitted to load. A skill that loads a skill outside this declared set at runtime SHOULD be denied at steps/skillLoad. This is the containment boundary for inter-skill cascades (A loads B loads C)." + } + } + } + }, + "allOf": [ + { "if": { "properties": { "type": { "const": "model" } }, "required": ["type"] }, "then": { "required": ["name", "version", "provider", "endpoint", "model_fields"] } }, + { "if": { "properties": { "type": { "const": "mcp_server" } }, "required": ["type"] }, "then": { "required": ["name", "version", "endpoint", "mcp_server_fields"] } }, + { "if": { "properties": { "type": { "const": "a2a_peer" } }, "required": ["type"] }, "then": { "required": ["endpoint", "a2a_peer_fields"] } }, + { "if": { "properties": { "type": { "const": "tool" } }, "required": ["type"] }, "then": { "required": ["name", "version", "provider", "tool_fields"] } }, + { "if": { "properties": { "type": { "const": "knowledge_source" } }, "required": ["type"] }, "then": { "required": ["name", "knowledge_source_fields"] } }, + { "if": { "properties": { "type": { "const": "memory_store" } }, "required": ["type"] }, "then": { "required": ["name", "memory_store_fields"] } }, + { "if": { "properties": { "type": { "const": "agent_capability" } }, "required": ["type"] }, "then": { "required": ["name", "agent_capability_fields"] } }, + { "if": { "properties": { "type": { "const": "skill" } }, "required": ["type"] }, "then": { "required": ["name", "skill_fields"] } } + ] +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/agbom/document.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/agbom/document.json new file mode 100644 index 00000000..1d6ad4da --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/agbom/document.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/agbom/document.json", + "title": "ACS AgBOM Document", + "description": "Canonical AgBOM document. Wire-shape for the Inspect pillar. Serializations to CycloneDX 1.6, SPDX 3.0, and SWID are deterministic derivations from this canonical form (see inspect/format-mapping.json). The Observed Agent always emits this canonical form on the wire; the Guardian renders serializations on request from downstream consumers.", + "type": "object", + "required": ["agbom_version", "agent", "components"], + "properties": { + "agbom_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Version of the AgBOM schema (independent of the ACS spec version, so AgBOM can evolve faster). v0.1 of this schema is '0.1.0'." + }, + "agent": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string" }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "vendor": { "type": "string" }, + "description": { "type": "string" } + } + }, + "components": { + "type": "array", + "items": { "$ref": "component.json" }, + "description": "Flat list of components. Inter-component references (an agent_capability listing its tools, an mcp_server listing its tools) are by component id, not by nesting, so the document remains diffable." + }, + "generated_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of AgBOM construction." + }, + "agbom_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Lowercase-hex SHA-256 of the RFC 8785 (JCS) canonicalization of this object with the agbom_hash field REMOVED. Lets Guardians and downstream consumers detect AgBOM mutations even when re-emitted snapshots are byte-identical to what they had before. RECOMMENDED." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/ask-details.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/ask-details.json new file mode 100644 index 00000000..6fbbddd5 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/ask-details.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/ask-details.json", + "title": "ACS ASK Details", + "description": "Approver descriptor and prompt details. Required when decision is 'ask'.", + "type": "object", + "required": ["approver", "question", "timeout_seconds"], + "properties": { + "approver": { + "type": "object", + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["human", "agent", "service"] + }, + "id": { "type": "string" }, + "endpoint": { + "type": "string", + "format": "uri", + "description": "URI for agent or service approvers" + }, + "auth": { + "type": "object", + "description": "Authentication descriptor for non-human approvers", + "properties": { + "method": { "type": "string", "enum": ["bearer", "mtls"] } + } + } + } + }, + "question": { "type": "string" }, + "context": { "type": "string" }, + "options": { + "type": "array", + "items": { "type": "string" } + }, + "timeout_seconds": { "type": "integer", "minimum": 1 }, + "timeout_disposition": { + "type": "string", + "enum": ["allow", "deny"], + "default": "deny" + }, + "intent_extension": { + "type": "object", + "description": "Optional. Present when the ASK is being raised because the requested action is outside the current Intent.parsed and the approver's grant should extend the Intent. The approver's response (in approval-request semantics) carries the same shape; on grant, the Guardian appends these capabilities to Intent.parsed and writes a ContextEntry with step_type='intent_extension'. Required for IBAC dynamic operation: without it, ASK can only approve a single decision and Intent cannot grow within a session, breaking the IBAC escalation flow.", + "required": ["capabilities", "scope"], + "properties": { + "capabilities": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "tool": { "type": "string" }, + "operation": { "type": "string" }, + "resource": { "type": "string" } + }, + "description": "Capability tuples to add to Intent.parsed. Same shape as Intent.parsed entries in agent-trigger.json." + } + }, + "scope": { + "type": "string", + "enum": ["this_request", "session"], + "description": "'this_request' = capabilities apply only to the in-flight request, not added to Intent.parsed. 'session' = capabilities are added to Intent.parsed for the remainder of the session." + }, + "provenance": { + "$ref": "provenance.json", + "description": "Provenance attached to the extension. origin SHOULD identify the approver (typically 'user_input' for human approvers, 'agent_generated' for agent approvers, 'external' for service approvers). source_id SHOULD identify the specific approver. Lets auditors distinguish parser-derived capabilities from approver-extended ones." + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/context-entry.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/context-entry.json new file mode 100644 index 00000000..e7411985 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/context-entry.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/context-entry.json", + "title": "ACS ContextEntry", + "description": "Append-only entry in a SessionContext's audit chain. Maintained server-side by the Guardian; not transmitted in full on the wire (the wire-visible commitment is chain_hash inside the request envelope's metadata.session_state). Defined here so that hash-chain verification is portable across implementations: any conformant Guardian MUST produce the same entry_hash for the same step content under the rules below, otherwise cross-implementation audit comparison and chain-mismatch detection break. Storage representation is implementation-defined (in-memory, append-only log, SQL row, etc.); this schema constrains the canonical form used for hashing and audit export, not how Guardians store the data internally.", + "type": "object", + "required": ["entry_id", "step_id", "step_type", "entry_hash"], + "properties": { + "entry_id": { + "type": "string", + "description": "Identifier for this entry, unique within the session." + }, + "step_id": { + "type": "string", + "description": "Identifier of the step that produced this entry. SHOULD equal the request_id of the originating envelope." + }, + "step_type": { + "type": "string", + "description": "The method name from the originating request envelope (e.g., 'steps/toolCallRequest', 'steps/toolCallResult')." + }, + "request_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Lowercase-hex SHA-256 of the JCS-canonicalized (RFC 8785) request envelope's params object. RECOMMENDED. Without request_hash, the chain commits only to step metadata (step_id, step_type) and not to the actual request content; retroactive request-content tampering would be undetectable from the chain alone. RECOMMENDED for any deployment that relies on the chain for tamper-evidence." + }, + "provenance_summary": { + "$ref": "provenance-summary.json", + "description": "Optional condensed view of provenance facts at this step. Included in the entry's content (and therefore committed to by entry_hash) when present." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the entry was written. RECOMMENDED for audit reconstruction. Included in the entry's content when present." + }, + "previous_hash": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{64}$", + "description": "Lowercase-hex SHA-256 entry_hash of the previous ContextEntry in the chain. NULL (or absent) for the first entry in a session. MUST be present and non-null for every entry except the first." + }, + "entry_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Lowercase-hex SHA-256 commitment to this entry plus the prior chain. Computed deterministically as SHA-256(content_bytes || prev_hash_bytes), where: (1) content_bytes is the UTF-8 encoding of the RFC 8785 (JCS) canonicalization of this object with the entry_hash and previous_hash fields REMOVED; (2) prev_hash_bytes is the raw 32-byte decoding of previous_hash, or the empty byte string if previous_hash is null/absent (first entry); (3) || denotes byte concatenation. Conformant Guardians MUST follow this rule exactly for chains to be cross-implementation verifiable. Alternative canonicalization schemes are NOT permitted within v0.1; future versions MAY negotiate alternatives via the handshake." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/defer-details.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/defer-details.json new file mode 100644 index 00000000..63c2644d --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/defer-details.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/defer-details.json", + "title": "ACS DEFER Details", + "description": "Deferral metadata when the Guardian could not reach a verdict. Required when decision is 'defer'.", + "type": "object", + "required": ["reason", "resolution_method", "resolution_timeout_ms"], + "properties": { + "reason": { + "type": "string", + "enum": [ + "insufficient_context", + "conflicting_policies", + "low_confidence", + "pending_dependency" + ] + }, + "resolution_method": { + "type": "string", + "enum": ["additional_context", "human_approval", "timeout"] + }, + "resolution_timeout_ms": { "type": "integer", "minimum": 0 }, + "timeout_decision": { + "type": "string", + "enum": ["deny", "ask"], + "default": "deny" + }, + "required_context": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of inputs whose presence would resolve the deferral" + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/handshake.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/handshake.json new file mode 100644 index 00000000..819a1e53 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/handshake.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/handshake.json", + "title": "ACS Handshake", + "description": "Capability negotiation messages exchanged at session start, before any hook traffic. The Observed Agent sends a ClientHello; the Guardian Agent responds with a ServerHello. Version mismatch terminates with an UNSUPPORTED_VERSION error in the JSON-RPC error envelope. Unknown fields MUST be ignored at every level. This file defines both message shapes via $defs; the wire encoding wraps them in the standard request/response envelope using method 'handshake/hello'.", + "type": "object", + "$defs": { + "ClientHello": { + "type": "object", + "required": [ + "acs_versions_supported", + "methods_implemented", + "transports_supported", + "provenance_producer" + ], + "properties": { + "acs_versions_supported": { + "type": "array", + "items": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + "minItems": 1, + "description": "Spec versions the Observed Agent can speak (semver). Guardian selects one in ServerHello.negotiated_version." + }, + "methods_implemented": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(steps/|protocols/|agbom/|trace/|system/|handshake/|wrapped:).+" + }, + "description": "Hook and wrapped-protocol method names the Observed Agent will emit during this session. Reserved namespaces: steps/* (Instrument hooks), protocols/* (wrapped MCP/A2A), agbom/* (Inspect AgBOM methods — required when ACS-Inspect profile is claimed; agbom/changed additionally required under ACS-Inspect-Dynamic), handshake/* (capability negotiation), trace/* (reserved for future ACS-native trace methods), system/* (low-level transport/control; system/ping for liveness — clients SHOULD include it), wrapped: (explicit-version wrapped form). Used by the Guardian to know which hooks to expect and which it can safely treat as not-emitted." + }, + "transports_supported": { + "type": "array", + "items": { "type": "string", "enum": ["http", "https", "stdio"] }, + "minItems": 1 + }, + "max_payload_size_bytes": { + "type": "integer", + "minimum": 1, + "description": "Maximum payload size the Observed Agent is willing to send in a single request." + }, + "provenance_producer": { + "type": "string", + "enum": ["deterministic", "none"], + "description": "Declares whether Provenance objects are populated by deterministic code outside the LLM output path. 'deterministic' is REQUIRED for information-flow paradigms and for deployments claiming ACS-Provenance. 'none' = no Provenance is emitted; Guardians whose policies require Provenance MUST refuse the session at handshake time rather than silently degrading enforcement. LLM-authored Provenance is not a conformant producer mode." + }, + "wrapped_protocols": { + "type": "array", + "items": { + "type": "object", + "required": ["protocol", "version"], + "properties": { + "protocol": { "type": "string", "enum": ["MCP", "A2A"] }, + "version": { "type": "string", "description": "Version of the wrapped protocol (e.g., '2025-06-18')." } + } + }, + "description": "Wrapped sub-protocols this Observed Agent will tunnel over ACS. Each entry pins a specific version so the Guardian can validate wrapped payloads. In v0.1, only MCP wrapping is specified (protocols/MCP/*); A2A is reserved for v0.2 and MAY be advertised for forward-compatibility but has no normative wrapping semantics in v0.1." + }, + "profiles_supported": { + "type": "array", + "items": { + "type": "string", + "enum": ["acs-core", "acs-trace", "acs-inspect", "acs-inspect-dynamic", "acs-provenance", "acs-crypto", "acs-audit"] + }, + "description": "Conformance profiles this Observed Agent implements. 'acs-core' is the mandatory baseline and SHOULD always be included. Other profiles are optional and independently claimable. See Section 4 (Conformance Profiles) for definitions." + } + } + }, + "ServerHello": { + "type": "object", + "required": ["negotiated_version", "methods_evaluated", "selected_transport", "timeout_config"], + "properties": { + "negotiated_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Version selected by the Guardian from the client's acs_versions_supported. MUST match the client's major version." + }, + "methods_evaluated": { + "type": "array", + "items": { "type": "string" }, + "description": "Subset of the client's methods_implemented that this Guardian will actually evaluate. Methods listed by the client but absent here are NOT evaluated; the Guardian's enforcement does not cover them. Clients MAY still emit them for audit but MUST treat them as ALLOW-by-default." + }, + "selected_transport": { + "type": "string", + "enum": ["http", "https", "stdio"] + }, + "signature_algorithms_supported": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "HMAC-SHA256", + "ECDSA-P256", + "RSA-PSS-SHA256", + "ML-DSA-65", + "ML-DSA-44", + "ML-DSA-87", + "SLH-DSA-128s", + "SLH-DSA-128f", + "ML-DSA-65+ECDSA-P256", + "ML-DSA-65+RSA-PSS-SHA256" + ] + }, + "description": "Signature algorithms the Guardian can verify on incoming requests. Empty array = signatures not supported / not required. 'HMAC-SHA256' is the v0.1 RECOMMENDED default (simplest deployment path). Classical asymmetric ('ECDSA-P256', 'RSA-PSS-SHA256') and PQC ('ML-DSA-*', 'SLH-DSA-*') are OPTIONAL. A future ACS version is expected to promote PQC to RECOMMENDED once ecosystem support matures." + }, + "timeout_config": { + "type": "object", + "required": ["default_ms"], + "properties": { + "default_ms": { + "type": "integer", + "minimum": 1, + "description": "Default timeout for any method not listed in per_method_ms. Every millisecond of timeout is worst-case latency added to the step while the Observed Agent waits, so deployments SHOULD budget per-method timeouts against each step's latency tolerance rather than rely on one generous default." + }, + "per_method_ms": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 1 }, + "description": "Per-method timeout overrides. Keys are method names (e.g., 'steps/toolCallRequest')." + } + } + }, + "skew_window_ms": { + "type": "integer", + "minimum": 0, + "default": 300000, + "description": "Maximum clock skew, in milliseconds, the Guardian tolerates between a request's 'timestamp' and its own clock, in either direction. Bounds timestamp freshness for replay protection (Specification 10.3); a request whose 'timestamp' is further than this from the Guardian's clock is rejected with TIMESTAMP_OUT_OF_WINDOW (-32006, Specification 17.1). When omitted the RECOMMENDED default is 300000 (5 minutes); deployments with well-synchronized clocks SHOULD tighten it. The Observed Agent uses the value for clock-drift diagnostics and MAY surface it on Trace events." + }, + "on_decision_failure": { + "type": "string", + "enum": ["proceed", "deny"], + "default": "proceed", + "description": "Posture the Observed Agent applies on a decision failure: no usable decision within the negotiated timeout, whether the Guardian is silent, the transport fails, or an error response carries no decision (Specification 6.4). 'proceed' (default) is fail-open, so a slow, erroring, or unreachable Guardian does not halt production; 'deny' is fail-closed. Every fail-open proceed MUST be recorded as an audit event (Specification 6.4). A decision that arrives within the timeout MUST be honored regardless of this posture." + }, + "approver_types_supported": { + "type": "array", + "items": { "type": "string", "enum": ["human", "agent", "service"] }, + "description": "Approver types this Guardian can route ASK decisions to." + }, + "policy_requires_provenance": { + "type": "boolean", + "description": "If true, this Guardian's policy requires Provenance on data-bearing fields; clients with provenance_producer='none' MUST be rejected before session starts." + }, + "agbom_serializations_supported": { + "type": "array", + "items": { + "type": "string", + "enum": ["canonical", "cyclonedx-1.6", "spdx-3.0", "swid"] + }, + "description": "Inspect-pillar serializations this Guardian can render the canonical AgBOM into on request. Deployments claiming ACS-Inspect MUST include at least one of {cyclonedx-1.6, spdx-3.0, swid} in addition to 'canonical' (the wire-shape). Guardians not claiming ACS-Inspect MAY omit this field. The Observed Agent always emits the canonical form on the wire; serializations are derivations the Guardian produces for downstream consumers." + }, + "trace_emission": { + "type": "object", + "description": "Trace-pillar negotiation. Deployments claiming ACS-Trace MUST emit Trace events under at least one of OTel or OCSF for every supported ACS step. Deployments not claiming ACS-Trace SHOULD still emit Trace events where feasible.", + "properties": { + "otel_enabled": { "type": "boolean", "description": "Guardian (or its co-deployed Trace sink) emits OTel spans per the Section 3.15 mapping." }, + "ocsf_enabled": { "type": "boolean", "description": "Guardian (or its co-deployed Trace sink) emits OCSF events per the Section 3.15 mapping." }, + "otel_collector_endpoint": { "type": "string", "format": "uri", "description": "Optional OTLP endpoint the Observed Agent MAY send spans to directly (when the deployment splits trace emission across both sides)." } + } + }, + "profiles_accepted": { + "type": "array", + "items": { + "type": "string", + "enum": ["acs-core", "acs-trace", "acs-inspect", "acs-inspect-dynamic", "acs-provenance", "acs-crypto", "acs-audit"] + }, + "description": "Conformance profiles accepted for this session. The Guardian selects from the client's profiles_supported based on its own capabilities and policy requirements. A Guardian MAY refuse a session if the client does not declare a profile the Guardian's policy requires." + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agbom-changed.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agbom-changed.json new file mode 100644 index 00000000..e3a384b6 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agbom-changed.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/agbom-changed.json", + "title": "agbom/changed payload", + "description": "AgBOM mutation notification. Emitted by the Observed Agent whenever a component is added, removed, or version-changed mid-session. MAY be denied by the Guardian to block a hot-swap (e.g., refuse a model upgrade mid-session); otherwise audited. MUST be written into the SessionContext audit chain alongside the originating mutation.", + "type": "object", + "oneOf": [ + { "required": ["diff"] }, + { "required": ["full_snapshot"] } + ], + "properties": { + "diff": { + "type": "object", + "description": "Incremental change set. Preferred for small mutations; the resulting AgBOM after applying the diff MUST equal the AgBOM the Observed Agent would emit as a fresh snapshot.", + "minProperties": 1, + "anyOf": [ + { "required": ["added"] }, + { "required": ["removed"] }, + { "required": ["changed"] } + ], + "properties": { + "added": { + "type": "array", + "items": { "$ref": "../agbom/component.json" } + }, + "removed": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "type": { "type": "string" } + }, + "description": "Component identifier and type for removal — full component record not needed." + } + }, + "changed": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "type", "before", "after"], + "properties": { + "id": { "type": "string" }, + "type": { "type": "string" }, + "before": { "$ref": "../agbom/component.json" }, + "after": { "$ref": "../agbom/component.json" } + } + } + } + } + }, + "full_snapshot": { + "$ref": "../agbom/document.json", + "description": "Full re-snapshot. Used when the change is large or when the Observed Agent cannot construct a minimal diff." + }, + "reason": { + "type": "string", + "enum": ["component_added", "component_removed", "component_upgraded", "configuration_changed", "discovery", "user_action", "policy_action"], + "description": "Why the AgBOM changed. Lets policies key on cause (e.g., deny user-initiated tool installs but allow framework-driven upgrades)." + }, + "trigger_step_id": { + "type": "string", + "description": "Optional step_id of the originating action that caused the AgBOM mutation (e.g., the toolCallRequest that installed a new MCP server)." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agbom-snapshot.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agbom-snapshot.json new file mode 100644 index 00000000..b7a7527c --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agbom-snapshot.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/agbom-snapshot.json", + "title": "agbom/snapshot payload", + "description": "Full Agent Bill of Materials snapshot. Emitted by the Observed Agent once per session, after sessionStart and before any content-bearing hook fires; also re-emitted after any handshake renegotiation. Disposition is normally 'allow'; Guardians MAY return 'deny' to refuse a session whose component graph contains a banned model, tool, peer, or knowledge source. agbom/snapshot MUST be written into the SessionContext audit chain (the AgBOM is part of the session's security-relevant history).", + "type": "object", + "required": ["agbom"], + "properties": { + "agbom": { + "$ref": "../agbom/document.json", + "description": "The canonical AgBOM document. Serializations (CycloneDX, SPDX, SWID) are derivations the Guardian produces for downstream consumers." + }, + "trigger": { + "type": "string", + "enum": ["session_start", "renegotiation", "policy_request"], + "description": "Why this snapshot is being emitted. 'session_start' is the normal first-snapshot case; 'renegotiation' follows handshake renegotiation; 'policy_request' is a Guardian-initiated re-snapshot if the spec ever adds Guardian-to-Observed query (v0.2)." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-response.acs-provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-response.acs-provenance.json new file mode 100644 index 00000000..d0da00bc --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-response.acs-provenance.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/agent-response.acs-provenance.json", + "title": "steps/agentResponse payload (ACS-Provenance profile)", + "description": "Strict variant of agent-response.json for deployments running provenance_producer: deterministic / claiming ACS-Provenance. Every content item MUST carry provenance. ACS-Core deployments validate against the base schema instead.", + "allOf": [{ "$ref": "./agent-response.json" }], + "properties": { + "content": { + "type": "array", + "items": { "required": ["type", "value", "provenance"] } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-response.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-response.json new file mode 100644 index 00000000..d232d582 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-response.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/agent-response.json", + "title": "steps/agentResponse payload", + "description": "Payload for the agentResponse hook. Fires when the agent's response is ready, before reaching the user.", + "type": "object", + "required": ["content"], + "properties": { + "content": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["type", "value"], + "properties": { + "type": { "type": "string", "enum": ["text", "image", "audio", "file"] }, + "value": {}, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED on every content item under provenance_producer: deterministic (ACS-Provenance). Validate against agent-response.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + } + }, + "reasoning": { + "type": "string", + "description": "Optional natural-language rationale produced by the agent" + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-trigger.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-trigger.json new file mode 100644 index 00000000..5573915f --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/agent-trigger.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/agent-trigger.json", + "title": "steps/agentTrigger payload", + "description": "Payload for the agentTrigger hook. Fires when an agent is activated.", + "type": "object", + "required": ["trigger_type"], + "properties": { + "trigger_type": { + "type": "string", + "enum": ["user_message", "scheduled", "external_event", "a2a_inbound", "system"] + }, + "trigger_source": { + "type": "object", + "description": "Source descriptor; shape depends on trigger_type" + }, + "intent": { + "type": "object", + "description": "Optional Intent registered at session start (IBAC adopters populate)", + "properties": { + "raw": { "type": "string" }, + "parsed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool": { "type": "string" }, + "operation": { "type": "string" }, + "resource": { "type": "string" } + } + } + }, + "parser_provenance": { "$ref": "../provenance.json" }, + "scope_mode": { + "type": "string", + "enum": ["strict", "moderate", "permissive"] + } + }, + "if": { "required": ["parsed"] }, + "then": { "required": ["parser_provenance"] } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/knowledge-retrieval.acs-provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/knowledge-retrieval.acs-provenance.json new file mode 100644 index 00000000..54cef52d --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/knowledge-retrieval.acs-provenance.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/knowledge-retrieval.acs-provenance.json", + "title": "steps/knowledgeRetrieval payload (ACS-Provenance profile)", + "description": "Strict variant of knowledge-retrieval.json for deployments running provenance_producer: deterministic / claiming ACS-Provenance. The query and every result MUST carry provenance. ACS-Core deployments validate against the base schema instead.", + "allOf": [{ "$ref": "./knowledge-retrieval.json" }], + "properties": { + "query": { "required": ["value", "provenance"] }, + "results": { + "type": "array", + "items": { "required": ["value", "provenance"] } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/knowledge-retrieval.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/knowledge-retrieval.json new file mode 100644 index 00000000..f3becbfe --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/knowledge-retrieval.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/knowledge-retrieval.json", + "title": "steps/knowledgeRetrieval payload", + "description": "Payload for the knowledgeRetrieval hook. Fires when the agent retrieves knowledge (RAG, vector DB, etc.).", + "type": "object", + "required": ["query", "results"], + "properties": { + "query": { + "type": "object", + "required": ["value"], + "properties": { + "value": { "type": "string" }, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED under provenance_producer: deterministic (ACS-Provenance). Validate against knowledge-retrieval.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + }, + "source": { + "type": "object", + "description": "Knowledge source descriptor", + "properties": { + "name": { "type": "string" }, + "type": { "type": "string", "enum": ["vector_db", "search_index", "knowledge_base", "web_search", "other"] }, + "version": { "type": "string" } + } + }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["value"], + "properties": { + "value": {}, + "source_id": { "type": "string" }, + "score": { "type": "number" }, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED on every result under provenance_producer: deterministic (ACS-Provenance). Validate against knowledge-retrieval.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-context-retrieval.acs-provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-context-retrieval.acs-provenance.json new file mode 100644 index 00000000..759c4692 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-context-retrieval.acs-provenance.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/memory-context-retrieval.acs-provenance.json", + "title": "steps/memoryContextRetrieval payload (ACS-Provenance profile)", + "description": "Strict variant of memory-context-retrieval.json for deployments running provenance_producer: deterministic / claiming ACS-Provenance. Every result MUST carry provenance. ACS-Core deployments validate against the base schema instead.", + "allOf": [{ "$ref": "./memory-context-retrieval.json" }], + "properties": { + "results": { + "type": "array", + "items": { "required": ["value", "provenance"] } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-context-retrieval.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-context-retrieval.json new file mode 100644 index 00000000..df8598b8 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-context-retrieval.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/memory-context-retrieval.json", + "title": "steps/memoryContextRetrieval payload", + "description": "Payload for the memoryContextRetrieval hook. Fires when the agent reads from persistent memory.", + "type": "object", + "required": ["results"], + "properties": { + "query": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "provenance": { "$ref": "../provenance.json" } + } + }, + "memory_store": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "scope": { "type": "string", "enum": ["session", "user", "tenant", "global"] } + } + }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["value"], + "properties": { + "memory_id": { "type": "string" }, + "value": {}, + "stored_at": { "type": "string", "format": "date-time" }, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED on every result under provenance_producer: deterministic (ACS-Provenance). Validate against memory-context-retrieval.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-store.acs-provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-store.acs-provenance.json new file mode 100644 index 00000000..f59d965f --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-store.acs-provenance.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/memory-store.acs-provenance.json", + "title": "steps/memoryStore payload (ACS-Provenance profile)", + "description": "Strict variant of memory-store.json for deployments running provenance_producer: deterministic / claiming ACS-Provenance. The written content MUST carry provenance. ACS-Core deployments validate against the base schema instead.", + "allOf": [{ "$ref": "./memory-store.json" }], + "properties": { + "content": { "required": ["value", "provenance"] } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-store.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-store.json new file mode 100644 index 00000000..c5f92533 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/memory-store.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/memory-store.json", + "title": "steps/memoryStore payload", + "description": "Payload for the memoryStore hook. Fires before the agent writes to persistent memory.", + "type": "object", + "required": ["content"], + "properties": { + "memory_id": { + "type": "string", + "description": "Identifier for the memory entry being written or updated" + }, + "memory_store": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "scope": { "type": "string", "enum": ["session", "user", "tenant", "global"] } + } + }, + "operation": { + "type": "string", + "enum": ["create", "update", "delete"] + }, + "content": { + "type": "object", + "required": ["value"], + "properties": { + "value": {}, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED under provenance_producer: deterministic (ACS-Provenance). Validate against memory-store.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/post-compact.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/post-compact.json new file mode 100644 index 00000000..544eaef7 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/post-compact.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/post-compact.json", + "title": "steps/postCompact payload", + "description": "Fires after compaction has occurred. The hook is audit + provenance-binding: the Guardian records the new summary's content and lineage, and MAY return 'modify' to rewrite or further redact the summary, but MUST NOT return 'deny' — compaction has already happened and the audit chain MUST record the post-compact state. Provenance on the summary is normative on origin and derived_from: origin MUST be 'agent_generated', and derived_from MUST equal the union of provenance_ids of every entry in entries_compacted. The framework — not the LLM — populates derived_from. The Guardian projects this lineage onto its own trust classification in policy (v0.1 does not carry a trust field on the wire — see §7.1); the integrity property the Guardian MUST enforce is the standard monotonicity rule, namely that the summary's effective trust cannot exceed the minimum effective trust of its derived_from entries. No amount of LLM processing launders untrusted-classified data into trusted-classified data.", + "type": "object", + "required": ["summary", "entries_compacted", "pre_compact_chain_hash", "post_compact_chain_hash"], + "properties": { + "summary": { + "type": "object", + "required": ["value", "provenance"], + "properties": { + "value": { + "type": "string", + "description": "The post-compaction summary text." + }, + "provenance": { + "$ref": "../provenance.json", + "description": "MUST have origin='agent_generated' and derived_from = union of provenance_ids of every entry in entries_compacted. Conformant frameworks MUST compute this deterministically; the LLM MUST NOT be given the opportunity to forge or relabel it. Trust classification is computed by the Guardian against local policy and is not carried in this v0.1 wire object (see §7.1); the Guardian MUST enforce the monotonicity property that the summary's effective trust does not exceed the minimum effective trust of its derived_from lineage." + } + } + }, + "entries_compacted": { + "type": "array", + "minItems": 1, + "items": { "type": "string" }, + "description": "The step_ids that were summarized. SHOULD equal the entries_to_compact from the matching steps/preCompact (modulo a Guardian-modified set if the preCompact returned 'modify' to scope the compaction)." + }, + "pre_compact_chain_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "The chain_hash at the moment compaction began (i.e., the chain_hash of the most recent ContextEntry written before this hook). Required for chain-replay verification: a verifier can check that the entries between this hash and post_compact_chain_hash correspond to the entries_compacted list." + }, + "post_compact_chain_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "The chain_hash after this postCompact ContextEntry is written. The next per-step ContextEntry will have previous_hash equal to this value." + }, + "lineage_depth_after": { + "type": "integer", + "minimum": 0, + "description": "Optional. The summary's max lineage depth, computed as max(depth) + 1 over the depths of entries in derived_from. Lets policies bound 'how many compactions deep is this summary' without recomputing the chain." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/pre-compact.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/pre-compact.json new file mode 100644 index 00000000..8299cafb --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/pre-compact.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/pre-compact.json", + "title": "steps/preCompact payload", + "description": "Fires before the runtime LLM compresses its context window into a summary. Decision-eligible — Guardians MAY return 'deny' to block compaction (e.g., because deployment policy forbids compacting after data the Guardian classifies as untrusted has entered until a re-grounding from a policy-trusted origin occurs). Compaction is the chokepoint where provenance can be laundered: the summary that comes out is new agent_generated content whose lineage spans every entry in the pre-compaction context. Without this hook, AARM cumulative-context tracking breaks across compaction, FIDES's monotonicity claim is unverifiable, and 'don't compact across a trust boundary' policies have nowhere to attach. (Trust classification is computed by the Guardian against local policy in v0.1 — see §7.1.)", + "type": "object", + "required": ["entries_to_compact", "triggered_by"], + "properties": { + "entries_to_compact": { + "type": "array", + "minItems": 1, + "items": { "type": "string" }, + "description": "Array of step_ids that the framework intends to summarize. Lets the Guardian see exactly which prior steps will be folded into the summary, which is what its provenance lineage will reference after compaction." + }, + "pre_compact_provenance_summary": { + "$ref": "../provenance-summary.json", + "description": "Optional condensed view of the provenance facts in the to-be-compacted region (origins observed, entry counts, max lineage depth, earliest step per origin, etc.). RECOMMENDED — without it the Guardian must derive these facts itself by walking the listed entries. Lets a policy decide quickly whether the compaction is safe; the Guardian projects the origin facts onto its own trust classification in policy." + }, + "triggered_by": { + "type": "string", + "enum": ["size_threshold", "manual", "agent_initiated", "framework_initiated"], + "description": "What caused the framework to compact now. 'size_threshold' = the runtime hit a configured token/step limit. 'manual' = the user requested it. 'agent_initiated' = the runtime LLM emitted a compaction tool call (NOT RECOMMENDED but observed in some agents). 'framework_initiated' = scheduled or policy-driven by the framework itself." + }, + "estimated_input_tokens": { + "type": "integer", + "minimum": 0, + "description": "Optional. Rough token count of the region about to be compacted. Useful for policies that key on compaction magnitude." + }, + "target_summary_tokens": { + "type": "integer", + "minimum": 0, + "description": "Optional. Target size of the resulting summary. Lets the Guardian reason about how aggressive the compression is." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/session-end.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/session-end.json new file mode 100644 index 00000000..c1b1a873 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/session-end.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/session-end.json", + "title": "steps/sessionEnd payload", + "description": "Payload for the sessionEnd hook. Fires when the session terminates. Asynchronous; does not block agent.", + "type": "object", + "required": ["reason"], + "properties": { + "reason": { + "type": "string", + "enum": ["completed", "cancelled", "error", "timeout", "abandoned"] + }, + "summary": { + "type": "object", + "description": "Optional summary written by the agent's framework", + "properties": { + "outcome": { "type": "string" }, + "step_count": { "type": "integer", "minimum": 0 }, + "errors": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "final_chain_hash": { + "type": "string", + "description": "Final SessionContext chain hash from the agent's perspective. Guardian SHOULD verify against its own chain." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/session-start.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/session-start.json new file mode 100644 index 00000000..8d1e8cba --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/session-start.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/session-start.json", + "title": "steps/sessionStart payload", + "description": "Fires once at session initiation, before any other steps/* hook for the session_id. Establishes the session boundary, opens the receipt chain (the first ContextEntry has previous_hash=null), and lets Guardians attach session-level state (policy bundle selection, tenant routing, identity binding) before content-bearing hooks fire. All payload fields are OPTIONAL — the simplest valid invocation is an empty payload that just marks the session boundary. Required by paradigms that need an explicit session boundary marker (AARM cumulative-context, IBAC intent lock at session start) and recommended for any deployment that wants to attach session-level identity or policy state out-of-band from agentTrigger.", + "type": "object", + "properties": { + "user_identity": { + "type": "object", + "description": "Authenticated principal initiating the session. Shape is platform-defined; typical fields shown.", + "properties": { + "user_id": { "type": "string" }, + "roles": { "type": "array", "items": { "type": "string" } }, + "authentication_method": { "type": "string" } + } + }, + "policy_mode": { + "type": "string", + "enum": ["strict", "moderate", "permissive"], + "description": "Declared scope_mode for this session. If Intent is also established here (or later via agentTrigger), Intent.scope_mode MUST equal this value." + }, + "intent": { + "type": "object", + "description": "Optional Intent established at session start. Same shape as Intent inside agentTrigger. When present, the Guardian MUST treat this as the canonical session Intent: any later agentTrigger that attempts to establish a different Intent MUST be rejected, and Intent.parsed MUST NOT be mutable except via the intent_extension mechanism in ask-details.json.", + "properties": { + "raw": { "type": "string" }, + "parsed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool": { "type": "string" }, + "operation": { "type": "string" }, + "resource": { "type": "string" } + } + } + }, + "parser_provenance": { "$ref": "../provenance.json" }, + "scope_mode": { + "type": "string", + "enum": ["strict", "moderate", "permissive"] + } + }, + "if": { "required": ["parsed"] }, + "then": { "required": ["parser_provenance"] } + }, + "platform_context": { + "type": "object", + "description": "Optional platform-specific context for session initialization (e.g., browser session id, IDE workspace path, request origin URL).", + "additionalProperties": true + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-load.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-load.json new file mode 100644 index 00000000..59608874 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-load.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/skill-load.json", + "title": "steps/skillLoad payload", + "description": "Fires when a registered skill activates into a session, before its actions run. Where steps/skillRegister vets the artifact statically and once, steps/skillLoad governs each activation in live context. It carries the load path so a Guardian can see and contain inter-skill cascades (skill A loads B loads C, each clean alone), and it carries the digest of the artifact being loaded so the Guardian can bind this activation to an approved prior registration. A skillLoad MUST be correlatable to a prior approved steps/skillRegister for the same skill_id and digest. A load the Guardian cannot tie to an approved registration, or whose digest differs from the approved one, is unverifiable and SHOULD be denied. Without this binding the lifecycle gate is bypassable: a framework could emit skillLoad for an artifact that was never registered or whose registration was denied. The Guardian also SHOULD deny when load_path shows a skill loading another skill outside its declared composed_skills.", + "type": "object", + "required": ["skill_id", "load_trigger", "load_path", "digest"], + "properties": { + "skill_id": { + "type": "string", + "description": "Id of the skill being loaded. References the 'skill' component registered via steps/skillRegister." + }, + "digest": { + "type": "object", + "description": "Integrity digest of the artifact actually being loaded, over the COMPLETE loadable artifact (the same scope as definition.digest at registration: text plus any bundled or fetched model files, adapters, or other binary assets). The Guardian compares it against the digest it approved at steps/skillRegister; a mismatch means the artifact changed between registration and load (tamper or swap). Carrying the digest here, rather than only a digest_verified boolean, lets the Guardian verify the binding itself instead of trusting the framework. The durable correlation key for an approved load is the pair (skill_id, digest), which a Guardian persists across sessions.", + "required": ["algorithm", "value"], + "properties": { + "algorithm": { "type": "string", "enum": ["sha-256", "sha-512"] }, + "value": { "type": "string" } + } + }, + "registration_ref": { + "type": "string", + "description": "step_id of the steps/skillRegister whose approval authorizes this load, when that registration occurred in a session the Guardian can correlate. An in-session pointer for audit replay; the durable correlation key remains (skill_id, digest). A load with neither a resolvable registration_ref nor a matching persisted (skill_id, digest) approval is unverifiable." + }, + "load_trigger": { + "type": "string", + "enum": ["user", "agent_decision", "skill_composition", "system"], + "description": "What caused the load. 'skill_composition' means another skill loaded this one, the cascade case; load_path MUST then have more than one element. 'user' and 'agent_decision' are direct loads. 'system' is framework-initiated." + }, + "load_path": { + "type": "array", + "description": "Ordered chain of skills that led to this load, root first, this skill last. A direct load has a single element. A cascade A->B->C produces [A, B, C] when C loads. Lets the Guardian apply depth limits and containment policy on the composition surface, not just the single skill.", + "minItems": 1, + "items": { + "type": "object", + "required": ["skill_id"], + "properties": { + "skill_id": { "type": "string" }, + "step_id": { "type": "string", "description": "step_id of the loading skill's hook that triggered the next load. Mirrors subagentStart.parent_step_id for audit replay." } + } + } + }, + "parent_step_id": { + "type": "string", + "description": "step_id that triggered this load (a steps/toolCallRequest for a load tool, a steps/agentResponse the framework read as a load directive, or the prior steps/skillLoad in a cascade). Anchors the load in the session audit chain." + }, + "digest_verified": { + "type": "boolean", + "description": "Optional framework-asserted hint that the loaded digest matches the registered one. A Guardian SHOULD NOT rely on it: it verifies the binding itself by comparing the 'digest' field against its record of the approved registration, since a compromised framework could assert digest_verified=true falsely." + }, + "declared_capabilities": { + "type": "array", + "items": { "type": "string" }, + "description": "The skill's declared capability manifest, echoed from registration so the Guardian can apply load-time policy without re-fetching the component." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-register.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-register.json new file mode 100644 index 00000000..ff4c2b75 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-register.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/skill-register.json", + "title": "steps/skillRegister payload", + "description": "Fires when a skill enters the agent's available set, before it is eligible to load or run. This is the static vetting gate: the only point where a Guardian inspects the whole skill artifact and fixes its approved integrity digest before any of its actions execute. Per-action hooks like steps/toolCallRequest cannot catch a payload split across a skill's actions, because each action is benign in isolation. The malice exists only in the composition. The approval recorded here is what steps/skillLoad is later checked against, by the (skill_id, digest) pair. A Guardian MAY deny registration; a denied skill MUST NOT become eligible to load. The full definition body travels in this payload for inspection but is NOT persisted to the AgBOM. Only definition.ref and definition.digest persist (Spec Review Principle 3).", + "type": "object", + "required": ["skill_id", "definition", "declared_capabilities"], + "properties": { + "skill_id": { + "type": "string", + "description": "Stable id the skill will carry as a 'skill' component in the AgBOM. steps/skillLoad and steps/skillUnload reference this id." + }, + "skill": { + "type": "object", + "description": "Descriptor of the skill being registered. SHOULD be populated when the deployment can identify the skill's source and publisher.", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "provider": { "type": "string" } + } + }, + "definition": { + "type": "object", + "description": "The skill's loadable artifact. 'ref' and 'digest' commit to the COMPLETE loadable artifact, including any bundled or fetched model files, adapters, or other binary assets, not only text. This scope matters: a skill can bundle a learned model whose backdoor lives in opaque weights rather than in any readable instruction, and source-code inspection has limited visibility into such a payload. For that class the control is the digest plus registration_provenance (integrity and origin of the whole artifact), not body reading. 'body' is the human-readable portion the Guardian can inspect inline. 'digest' is what steps/skillLoad is later bound against. The body is transient inspection material; it MUST NOT be required to persist in the AgBOM.", + "required": ["digest"], + "properties": { + "ref": { "type": "string", "format": "uri", "description": "URI of the complete loadable skill artifact per Section 3.11 (the package, including any bundled model files or adapters)." }, + "digest": { + "type": "object", + "description": "Integrity digest over the COMPLETE loadable artifact, not only its text. Binds the exact bytes that load and run, so a model-bearing skill's weights are covered, and ties this registration to later steps/skillLoad events via the (skill_id, digest) pair.", + "required": ["algorithm", "value"], + "properties": { + "algorithm": { "type": "string", "enum": ["sha-256", "sha-512"] }, + "value": { "type": "string" } + } + }, + "body": { + "type": "string", + "description": "Human-readable portion of the artifact (instructions, helper scripts, action manifest) for inline inspection. Does NOT include opaque binary assets such as model weights or adapters, which are covered by digest and registration_provenance rather than body inspection. Present for the Guardian to analyze; not persisted downstream." + } + } + }, + "declared_capabilities": { + "type": "array", + "items": { "type": "string" }, + "description": "Abstract capabilities the skill declares it needs, its least-privilege manifest. The Guardian SHOULD compare against the union of capabilities exposed by the composed tools and MAY deny over-broad declarations." + }, + "composition": { + "type": "object", + "description": "What the skill composes, by AgBOM component id. composed_skills declares which other skills this skill is permitted to load, the containment set enforced at steps/skillLoad.", + "properties": { + "tools": { "type": "array", "items": { "type": "string" } }, + "mcp_servers": { "type": "array", "items": { "type": "string" } }, + "a2a_peers": { "type": "array", "items": { "type": "string" } }, + "models": { "type": "array", "items": { "type": "string" }, "description": "Component ids of model components the skill bundles. A model-bearing skill references its bundled model here so it is inventoried as a 'model' component with its own provenance, rather than left opaque inside the definition body. The whole-artifact digest binds the weights regardless; this surfaces the model to the Guardian's load-time and inventory policy." }, + "composed_skills": { "type": "array", "items": { "type": "string" } } + } + }, + "registration_provenance": { + "$ref": "../provenance.json", + "description": "How the skill entered the available set. origin distinguishes a framework-bundled skill ('system') from a marketplace install ('user_input') or a runtime-fetched one ('tool_output'). source_id SHOULD carry the publisher or marketplace identity. A Guardian MAY require a signed provenance and deny unsigned or untrusted-origin skills." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-unload.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-unload.json new file mode 100644 index 00000000..73e25315 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/skill-unload.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/skill-unload.json", + "title": "steps/skillUnload payload", + "description": "Fires when a skill leaves the active set. Its enforcement value is low: by unload time the skill's actions have already run, so a Guardian typically observes rather than denies. It earns a place for two reasons: it keeps the AgBOM's active inventory accurate, and repeated load/unload churn of the same skill is itself an evasion signal worth tracing. Deployments MAY fold this into agbom/changed instead of emitting a distinct hook; the field shape below is what agbom/changed would carry for a skill removal.", + "type": "object", + "required": ["skill_id", "reason"], + "properties": { + "skill_id": { + "type": "string", + "description": "Id of the skill being unloaded. References the 'skill' component." + }, + "reason": { + "type": "string", + "enum": ["session_end", "explicit_unload", "replaced", "error"], + "description": "Why the skill left the active set. 'replaced' means a different version or skill took its place. Pair with the replacing skill_id in load events to trace swaps." + }, + "replaced_by": { + "type": "string", + "description": "When reason='replaced', the skill_id that took this skill's place." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/subagent-start.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/subagent-start.json new file mode 100644 index 00000000..59fea328 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/subagent-start.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/subagent-start.json", + "title": "steps/subagentStart payload", + "description": "Fires when a parent agent spawns a subagent in-process — i.e., delegation that does NOT cross an A2A boundary. A2A-mediated delegation flows through steps/agentTrigger with trigger_type='a2a_inbound' on the subagent's side; subagentStart is for the same-runtime case. The audit chain must record whether the subagent inherits the parent's Intent.parsed, gets a derived intent, or starts fresh — and a Guardian MAY deny the spawn if intent_derivation would grant capabilities the parent's Intent.parsed does not authorize. Sessions are per-subagent: each subagent gets its own session_id and SessionContext; the parent–child relation is captured here.", + "type": "object", + "required": ["subagent_session_id", "parent_session_id", "parent_step_id", "intent_derivation"], + "properties": { + "subagent_session_id": { + "type": "string", + "format": "uuid", + "description": "Fresh session identifier for the spawned subagent. Distinct from the envelope's metadata.session_id, which is the parent's session. The subagent's own steps/* hooks will carry this id in their envelope metadata.session_id." + }, + "parent_session_id": { + "type": "string", + "format": "uuid", + "description": "Session id of the parent. Lets cross-session policies and audit replay reconstruct the spawn relationship." + }, + "parent_step_id": { + "type": "string", + "description": "step_id of the parent's hook that triggered the spawn (typically a steps/toolCallRequest for a delegation tool, or a steps/agentResponse that the framework interpreted as a spawn directive)." + }, + "intent_derivation": { + "type": "string", + "enum": ["inherit_full", "inherit_subset", "derived_from_parent", "fresh"], + "description": "How the subagent's Intent.parsed relates to the parent's. 'inherit_full' = identical capability set (subagent can do anything the parent can). 'inherit_subset' = strict subset (subagent_intent.parsed MUST be a subset of parent's parsed; the Guardian SHOULD verify). 'derived_from_parent' = the parent emitted a parsed delegation directive that the framework's intent parser turned into the subagent's intent (parser_provenance MUST cite the parent step_id). 'fresh' = the subagent has its own user-derived Intent (typically only valid when the spawn was triggered by user-routed input)." + }, + "subagent_intent": { + "type": "object", + "description": "The Intent that will govern the subagent. Same shape as the Intent in agent-trigger.json. parser_provenance is REQUIRED — for 'derived_from_parent', parser_provenance.derived_from MUST include the parent_step_id. The Guardian MAY deny the spawn if subagent_intent grants capabilities outside the parent's Intent.parsed when intent_derivation='inherit_subset'.", + "properties": { + "raw": { "type": "string" }, + "parsed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool": { "type": "string" }, + "operation": { "type": "string" }, + "resource": { "type": "string" } + } + } + }, + "parser_provenance": { "$ref": "../provenance.json" }, + "scope_mode": { + "type": "string", + "enum": ["strict", "moderate", "permissive"] + } + }, + "if": { "required": ["parsed"] }, + "then": { "required": ["parser_provenance"] } + }, + "subagent_descriptor": { + "type": "object", + "description": "Optional descriptor of the subagent itself — its agent_id, model, framework version, etc. SHOULD be populated when the subagent is a distinct named component the deployment can identify (e.g., a research subagent, a coding subagent).", + "properties": { + "agent_id": { "type": "string" }, + "agent_name": { "type": "string" }, + "model_id": { "type": "string" } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/subagent-stop.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/subagent-stop.json new file mode 100644 index 00000000..47044beb --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/subagent-stop.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/subagent-stop.json", + "title": "steps/subagentStop payload", + "description": "Fires when an in-process subagent terminates. The envelope's metadata.session_id is the PARENT's session (the parent receives the subagent-stop notification on its own audit chain); the subagent's own session has by this point already emitted its sessionEnd with its own final_chain_hash. This hook lets the parent's audit chain reference the subagent's terminal state without merging the two chains. Not decision-eligible — the subagent has already terminated.", + "type": "object", + "required": ["subagent_session_id", "outcome"], + "properties": { + "subagent_session_id": { + "type": "string", + "format": "uuid", + "description": "session_id of the terminated subagent. Matches the subagent_session_id from the originating subagentStart." + }, + "outcome": { + "type": "string", + "enum": ["completed", "failed", "cancelled", "timeout", "denied_at_spawn"], + "description": "Terminal status. 'completed' = subagent returned normally. 'failed' = subagent errored. 'cancelled' = parent or external signal cancelled it. 'timeout' = subagent exceeded its timeout. 'denied_at_spawn' = Guardian denied the original subagentStart; this stop is recorded for audit symmetry even though no subagent ran." + }, + "final_chain_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "OPTIONAL. The subagent's final SessionContext chain_hash at sessionEnd. When present, lets parent-side audit replay verify the subagent's chain integrity without the parent maintaining a copy of it. Frameworks that maintain no session-chain (e.g., shell-hook integrations without an internal audit chain) omit this field rather than fabricate a value; Guardian handling of the omission is specified in \u00a78.6 of the specification." + }, + "summary": { + "type": "object", + "description": "Optional value the subagent returned to the parent. Provenance: origin='agent_generated' and derived_from = the subagent's response lineage. The summary's content becomes data the parent can act on; its effective trust classification is computed by the parent-side Guardian against local policy from the lineage's origins, and MUST not exceed the minimum effective trust of the lineage (monotonicity). v0.1 does not carry a trust field on the wire — see §7.1.", + "required": ["value", "provenance"], + "properties": { + "value": {}, + "provenance": { "$ref": "../provenance.json" } + } + }, + "subagent_step_count": { + "type": "integer", + "minimum": 0, + "description": "Optional. Total steps the subagent executed. Useful for resource-accounting policies." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/system-ping.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/system-ping.json new file mode 100644 index 00000000..719055a2 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/system-ping.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/system-ping.json", + "title": "system/ping payload", + "description": "Liveness probe. Carries no enforcement semantics. Guardians MUST always return decision='allow' for system/ping regardless of policy, signature, or session state. system/ping MUST NOT be written into the SessionContext audit chain (no ContextEntry, no chain_hash advance) and MUST NOT require a signature even if the session otherwise mandates them, so liveness probing remains possible during signature-rotation or key-resolution failures. Connection failure or response timeout for system/ping is a transport-level signal — the Observed Agent MAY use it to renegotiate transport, re-handshake, or fail over.", + "type": "object", + "properties": { + "echo": { + "type": "string", + "maxLength": 256, + "description": "Optional opaque string the Guardian echoes back in its response payload. Useful for round-trip-time measurement and for distinguishing concurrent probes." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-request.acs-provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-request.acs-provenance.json new file mode 100644 index 00000000..1e477977 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-request.acs-provenance.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/tool-call-request.acs-provenance.json", + "title": "steps/toolCallRequest payload (ACS-Provenance profile)", + "description": "Strict variant of tool-call-request.json for deployments running provenance_producer: deterministic / claiming ACS-Provenance. Every argument value MUST carry its own provenance. ACS-Core deployments validate against the base schema instead.", + "allOf": [{ "$ref": "./tool-call-request.json" }], + "properties": { + "arguments": { + "type": "object", + "additionalProperties": { "required": ["value", "provenance"] } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-request.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-request.json new file mode 100644 index 00000000..82448c87 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-request.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/tool-call-request.json", + "title": "steps/toolCallRequest payload", + "description": "Payload for the toolCallRequest hook. Fires before tool execution. Primary enforcement gate.", + "type": "object", + "required": ["tool", "arguments"], + "properties": { + "tool": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "provider": { "type": "string" } + } + }, + "operation": { + "type": "string", + "description": "Optional sub-operation when the tool exposes multiple verbs" + }, + "capability": { + "type": "string", + "description": "Abstract capability identifier (e.g., filesystem.delete, network.egress, process.execute)" + }, + "arguments": { + "type": "object", + "description": "Tool arguments. Under provenance_producer: deterministic, each argument value carries its own provenance (ACS-Provenance).", + "additionalProperties": { + "type": "object", + "required": ["value"], + "properties": { + "value": {}, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED on every argument under provenance_producer: deterministic (ACS-Provenance). Validate against tool-call-request.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + } + }, + "raw_command": { + "type": "string", + "description": "Optional shell-style command string for tools that accept one" + }, + "intent": { + "type": "object", + "description": "Optional natural-language framing of why the tool is being called", + "properties": { + "description": { "type": "string" }, + "goal": { "type": "string" } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-result.acs-provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-result.acs-provenance.json new file mode 100644 index 00000000..8e3b9310 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-result.acs-provenance.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/tool-call-result.acs-provenance.json", + "title": "steps/toolCallResult payload (ACS-Provenance profile)", + "description": "Strict variant of tool-call-result.json for deployments running provenance_producer: deterministic / claiming ACS-Provenance. Every output item MUST carry provenance. ACS-Core deployments validate against the base schema instead.", + "allOf": [{ "$ref": "./tool-call-result.json" }], + "properties": { + "outputs": { + "type": "array", + "items": { "required": ["value", "provenance"] } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-result.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-result.json new file mode 100644 index 00000000..6f4bfcd3 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/tool-call-result.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/tool-call-result.json", + "title": "steps/toolCallResult payload", + "description": "Payload for the toolCallResult hook. Fires after tool execution, before the result reaches the agent. Output redaction gate.", + "type": "object", + "required": ["tool", "exit_status", "outputs"], + "properties": { + "tool": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "provider": { "type": "string" } + } + }, + "operation": { "type": "string" }, + "request_id_ref": { + "type": "string", + "format": "uuid", + "description": "Links to the originating toolCallRequest" + }, + "exit_status": { + "type": "string", + "enum": ["success", "failure", "timeout", "blocked"] + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "required": ["value"], + "properties": { + "value": {}, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED on every output item under provenance_producer: deterministic (ACS-Provenance). Validate against tool-call-result.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + } + }, + "duration_ms": { "type": "integer", "minimum": 0 } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/turn-end.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/turn-end.json new file mode 100644 index 00000000..f5c2fc25 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/turn-end.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/turn-end.json", + "title": "steps/turnEnd payload", + "description": "Fires at the end of an agent turn. Audit-only — the turn has already happened, so the Guardian writes a ContextEntry but cannot return 'deny' or 'modify'. The matching turnStart's turn_id is referenced via metadata.turn_id on the envelope (and SHOULD also appear in this payload for self-containment). Closes the per-turn audit window so policies that key on per-turn state (cumulative-taint reset, tool-call counts, etc.) have a clean boundary to compute against.", + "type": "object", + "required": ["turn_id", "outcome"], + "properties": { + "turn_id": { + "type": "string", + "description": "Identifier of the turn that just ended. MUST equal the turn_id from the matching turnStart and from metadata.turn_id on this envelope." + }, + "outcome": { + "type": "string", + "enum": ["completed", "deferred", "error", "interrupted", "denied_at_start"], + "description": "Terminal status of the turn. 'completed' = turn finished normally. 'deferred' = a step in this turn returned 'defer' and the turn is paused awaiting resolution; the next turn (or this same turn after resolution) MAY have triggered_by='auto_continuation'. 'error' = the turn errored. 'interrupted' = an external signal (user cancellation, parent-agent recall) ended it early. 'denied_at_start' = the matching turnStart returned 'deny'; this turnEnd is recorded for audit symmetry." + }, + "step_count": { + "type": "integer", + "minimum": 0, + "description": "Number of per-step ContextEntries written during this turn (between turnStart and this turnEnd, exclusive of both). Useful for per-turn rate-limit policies and for resource accounting." + }, + "summary": { + "type": "object", + "description": "Optional summary of what happened in the turn. Provenance: origin='agent_generated' and derived_from = the union of the steps' provenance_ids. (Trust on the resulting summary is computed by the Guardian against local policy from the lineage's origins; v0.1 does not carry a trust field on the wire — see §7.1.) Lets the Guardian record a turn-level summary distinct from any agentResponse content.", + "properties": { + "value": { "type": "string" }, + "provenance": { "$ref": "../provenance.json" } + }, + "required": ["value", "provenance"] + }, + "tool_call_count": { + "type": "integer", + "minimum": 0, + "description": "Optional. Number of toolCallRequest steps this turn. Convenience field for the common 'limit tool calls per turn' policy; also derivable by a Guardian that walks ContextEntries between turnStart and turnEnd." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/turn-start.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/turn-start.json new file mode 100644 index 00000000..082f24c5 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/turn-start.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/turn-start.json", + "title": "steps/turnStart payload", + "description": "Fires at the beginning of an agent turn. Lightweight — most policies will return 'allow' and use the hook for state transitions in policy. Decision-eligible (a Guardian MAY deny to block the turn from starting, e.g., to enforce a per-turn rate limit or to refuse auto-continuation under certain conditions). The framework MUST set metadata.turn_id on the envelope and propagate that turn_id onto every subsequent per-step ContextEntry until the matching turnEnd. Without an explicit turn boundary, AARM-style 'no consequential action in N turns after taint' has to infer turn breaks from userMessage/agentResponse pairing — an inference that breaks under auto-continuation, planning loops, and multi-step ReAct cycles.", + "type": "object", + "required": ["turn_id", "triggered_by"], + "properties": { + "turn_id": { + "type": "string", + "description": "Identifier for this turn. SHOULD equal the request_id of this turnStart envelope. Carried forward on metadata.turn_id of every step until turnEnd." + }, + "triggered_by": { + "type": "string", + "enum": ["user_message", "auto_continuation", "agent_loop", "subagent_return", "scheduled", "external_event"], + "description": "What opened this turn. 'user_message' = a userMessage was just received and the agent will respond. 'auto_continuation' = a Stop hook elsewhere returned a continuation directive. 'agent_loop' = the agent decided on its own to start another turn (planning loop, ReAct, etc.). 'subagent_return' = a subagent returned and the parent is resuming. 'scheduled' / 'external_event' = a non-user trigger fired." + }, + "parent_turn_id": { + "type": "string", + "description": "Optional. Identifier of the enclosing turn when turns nest. Typical case: a parent agent is in the middle of a turn that spawns a subagent (which has its own session and its own turn) and is awaiting the subagent's return; the subagent's first turn carries parent_turn_id pointing at the parent's open turn." + }, + "expected_inputs": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional. Hint about what the framework expects to happen during this turn (e.g., ['userMessage','toolCallRequest','agentResponse']). Lets policies prepare expectations and detect unexpected sequences." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/user-message.acs-provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/user-message.acs-provenance.json new file mode 100644 index 00000000..92456294 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/user-message.acs-provenance.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/user-message.acs-provenance.json", + "title": "steps/userMessage payload (ACS-Provenance profile)", + "description": "Strict variant of user-message.json for deployments running provenance_producer: deterministic / claiming ACS-Provenance. Every content item MUST carry provenance. ACS-Core deployments validate against the base schema instead.", + "allOf": [{ "$ref": "./user-message.json" }], + "properties": { + "content": { + "type": "array", + "items": { "required": ["type", "value", "provenance"] } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/user-message.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/user-message.json new file mode 100644 index 00000000..98a23c76 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/hooks/user-message.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/user-message.json", + "title": "steps/userMessage payload", + "description": "Payload for the userMessage hook. Fires when user input arrives, before LLM ingestion.", + "type": "object", + "required": ["content"], + "properties": { + "content": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["type", "value"], + "properties": { + "type": { "type": "string", "enum": ["text", "image", "audio", "file"] }, + "value": {}, + "provenance": { + "$ref": "../provenance.json", + "description": "OPTIONAL in the base schema; REQUIRED on every content item under provenance_producer: deterministic (ACS-Provenance). Validate against user-message.acs-provenance.json when the profile is claimed. See Specification section 7." + } + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/inspect/format-mapping.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/inspect/format-mapping.json new file mode 100644 index 00000000..a7674fd0 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/inspect/format-mapping.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/inspect/format-mapping.json", + "title": "ACS AgBOM Serialization Format Mapping", + "description": "Normative mapping from the canonical AgBOM (agbom/document.json) into the three v0.1.0 serialization targets: CycloneDX 1.6, SPDX 3.0, and SWID (ISO/IEC 19770-2). This is a documentation-shaped JSON Schema — its body describes the transformation rules; conformant Guardians MUST be able to render at least one of these on request from a downstream consumer.", + "type": "object", + "properties": { + "cyclonedx_1_6": { + "type": "object", + "description": "CycloneDX 1.6 JSON. Each canonical component → one entry in components[]. The CycloneDX bom-ref equals the canonical component id. Type mapping: model → 'machine-learning-model' (CycloneDX bom-types extension); mcp_server, a2a_peer, knowledge_source → 'service'; tool → 'application'; memory_store → 'data' (with type='database' subtype where applicable); agent_capability → represented in CycloneDX 'composition' aggregating its referenced components; skill → 'application' carrying its definition digest in component 'hashes' and a CycloneDX 'composition' aggregating its referenced components. The CycloneDX 'metadata.tools' SHOULD include the Observed Agent's framework as the BOM generator. Provenance attached to a component MUST be carried into CycloneDX 'evidence' or 'properties' (acs:provenance:origin, and acs:provenance:source_id when present). v0.1 carries factual provenance only; trust classification is computed by the Guardian against local policy and is not exported in CycloneDX (see §7.1)." + }, + "spdx_3_0": { + "type": "object", + "description": "SPDX 3.0 JSON-LD. Each canonical component → one Element node. Type mapping: model → spdx:AIPackage (SPDX 3.0 AI profile); tool → spdx:SoftwarePackage; mcp_server, a2a_peer, knowledge_source → spdx:Service; memory_store → spdx:DatasetPackage; agent_capability → spdx:Bundle aggregating its referenced components via 'contains' relationships; skill → spdx:SoftwarePackage with a verified checksum derived from its definition digest, aggregating its referenced components via 'contains' relationships. The graph is wired with SPDX relationships: agent_capability--CONTAINS-->{tool,mcp_server,a2a_peer}; skill--CONTAINS-->{tool,mcp_server,a2a_peer,skill}; mcp_server--EXPOSES-->tool. AgBOM-level metadata (agent.id, generated_at, agbom_hash) maps to spdx:CreationInfo and spdx:Element identifiers." + }, + "swid": { + "type": "object", + "description": "SWID (ISO/IEC 19770-2) XML. Each canonical component → one SoftwareIdentity tag with @tagId = component id, @name = component name, @version = component version. Provider maps to . agent_capability components emit a SoftwareIdentity with @corpus='true' and use to reference their constituent components. skill components emit a SoftwareIdentity carrying the definition digest in a element and use to reference their constituent components and composed skills. Endpoint URIs map to . The AgBOM-level agent and timestamps populate the root wrapping the per-component tags." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/modifications.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/modifications.json new file mode 100644 index 00000000..53aa1e88 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/modifications.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/modifications.json", + "title": "ACS Modifications", + "description": "Modifications applied when decision is 'modify'. Two mutually exclusive shapes: wholesale replacement via 'modified_content', or structured edits via 'redactions' and/or 'parameter_overrides'. The two shapes never combine, and structured edits MUST target disjoint paths so the effective payload is order-independent (Specification 6.3). The provenance of values introduced by 'parameter_overrides' is not defined in v0.1.", + "type": "object", + "properties": { + "modified_content": { + "type": "string", + "description": "Replacement content for the entire payload. Exclusive: MUST NOT be combined with 'redactions' or 'parameter_overrides', which have no path to address inside an opaque replacement." + }, + "redactions": { + "type": "array", + "description": "Targeted redactions applied to specific fields. Each path MUST be disjoint from every 'parameter_overrides' target (Specification 6.3).", + "items": { + "type": "object", + "required": ["path"], + "properties": { + "path": { "type": "string", "description": "JSON pointer to the field" }, + "replacement": { "type": "string", "description": "Replacement text (default '[REDACTED]')" } + } + } + }, + "parameter_overrides": { + "type": "object", + "description": "Replacement values for tool call arguments, keyed by argument name. Each target MUST be disjoint from every 'redactions' path (Specification 6.3).", + "additionalProperties": true + } + }, + "oneOf": [ + { + "title": "Wholesale replacement: modified_content only.", + "required": ["modified_content"], + "not": { "anyOf": [{ "required": ["redactions"] }, { "required": ["parameter_overrides"] }] } + }, + { + "title": "Structured edits: redactions and/or parameter_overrides on disjoint targets, never modified_content.", + "not": { "required": ["modified_content"] }, + "anyOf": [{ "required": ["redactions"] }, { "required": ["parameter_overrides"] }] + } + ] +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/provenance-summary.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/provenance-summary.json new file mode 100644 index 00000000..4a427aad --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/provenance-summary.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/provenance-summary.json", + "title": "ACS Provenance Summary", + "description": "Condensed view of provenance facts for cumulative or session-level policy decisions. Computed by Guardian framework code from the Provenance objects observed at a step (entry-level summary) or across the session to date (session-level summary). All fields are OPTIONAL — Guardians populate the fields their policies actually consume. Used by paradigms that reason over accumulated context (AARM cumulative-context, FIDES session-level integrity); paradigms that don't (e.g., pure IBAC) can omit the entire object. v0.1 summaries carry origin-derived aggregates only; trust-derived aggregates (e.g., lowest_trust, earliest_untrusted_step_id) are Guardian-internal in v0.1 because trust classification is computed in policy from origin/source_id, not carried on the wire (§7.1).", + "type": "object", + "properties": { + "origins_seen": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "user_input", + "system", + "tool_output", + "retrieved", + "agent_generated", + "a2a_inbound", + "external" + ] + }, + "description": "Distinct origins observed in the scope of this summary. The primary v0.1 signal for cumulative-context and session-integrity policies — Guardians map origins (and source_ids) to trust internally per local policy." + }, + "entry_count": { + "type": "integer", + "minimum": 0, + "description": "Total number of Provenance objects (entry-level) or ContextEntries (session-level) summarized." + }, + "entry_count_by_origin": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 }, + "description": "Map of origin => count of Provenance objects with that origin in scope. Keys MUST be drawn from the origin enum. Optional; used by policies that distinguish, e.g., a single tool_output from many." + }, + "earliest_step_id_by_origin": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Map of origin => step_id of the earliest step at which data of that origin entered the scope. Optional; used by policies expressed as 'deny if entered within the last N turns' to compute lookback distance from the current step. Replaces the trust-derived earliest_untrusted_step_id from earlier drafts: v0.1 carries origin facts, the Guardian projects them onto its trust classification in policy." + }, + "max_lineage_depth": { + "type": "integer", + "minimum": 0, + "description": "Longest derived_from chain depth observed. Optional; used by policies that constrain lineage depth (e.g., 'deny if data is more than K hops from a policy-trusted source')." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/provenance.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/provenance.json new file mode 100644 index 00000000..73b335bd --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/provenance.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/provenance.json", + "title": "ACS Provenance", + "description": "Factual lineage label attached to data-bearing fields. Populated by deterministic framework code at channel boundaries, never by the LLM. Provenance attachment is OPTIONAL at the field level; when a Provenance object is emitted, all required fields below MUST be populated. v0.1 carries factual provenance (origin, source_id, derived_from) on the wire; trust classification is performed by the Guardian against local policy keyed on origin and source_id, and is not a v0.1 schema field. The spec (§7.1) reserves an OPTIONAL `trust` enum for vendor implementations that elect to carry classification on the wire; such implementations extend this schema rather than rely on v0.1 to validate the field.", + "type": "object", + "required": ["provenance_id", "origin"], + "properties": { + "provenance_id": { + "type": "string", + "description": "Unique identifier for this provenance node within the session" + }, + "origin": { + "type": "string", + "enum": [ + "user_input", + "system", + "tool_output", + "retrieved", + "agent_generated", + "a2a_inbound", + "external" + ], + "description": "Where this data entered the system" + }, + "source_id": { + "type": "string", + "minLength": 1, + "description": "Identifier within the origin (tool name, URL, file path, retrieval source). Combined with origin, this is what Guardians key trust classification on in v0.1." + }, + "derived_from": { + "type": "array", + "items": { "type": "string" }, + "description": "Lineage edges. Array of provenance_ids this data was derived from." + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/request-envelope.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/request-envelope.json new file mode 100644 index 00000000..61218a26 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/request-envelope.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/request-envelope.json", + "title": "ACS Request Envelope", + "description": "JSON-RPC 2.0 envelope with ACS extensions. Sent by the Observed Agent to the Guardian Agent.", + "type": "object", + "required": ["jsonrpc", "method", "id", "params"], + "additionalProperties": false, + "properties": { + "jsonrpc": { "const": "2.0" }, + "method": { + "type": "string", + "description": "Hook or wrapped-protocol method name. Reserved namespaces: steps/* (Instrument hooks), protocols/* (wrapped sub-protocols MCP and A2A), agbom/* (Inspect pillar AgBOM methods), trace/* (reserved for future ACS-native trace methods; v0.1 does not define any), system/* (low-level transport/control methods, including system/ping for liveness), handshake/* (capability negotiation, including handshake/hello), wrapped: (explicit-version wrapped form, e.g. wrapped:a2a-0.2/message/send).", + "pattern": "^(steps/|protocols/|agbom/|trace/|system/|handshake/|wrapped:).+" + }, + "id": { + "oneOf": [{ "type": "string" }, { "type": "number" }], + "description": "JSON-RPC correlation id" + }, + "params": { "$ref": "#/$defs/AcsParams" } + }, + "$defs": { + "AcsParams": { + "type": "object", + "required": ["acs_version", "request_id", "timestamp", "metadata", "payload"], + "properties": { + "acs_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "ACS specification version (semver)" + }, + "request_id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this hook invocation" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the hook fired" + }, + "nonce": { + "type": "string", + "minLength": 16, + "maxLength": 64, + "description": "Cryptographic nonce for replay prevention" + }, + "tenant_id": { + "type": "string", + "description": "Tenant identifier (multi-tenancy v0.1: reserved field, no isolation rules)" + }, + "metadata": { "$ref": "#/$defs/Metadata" }, + "payload": { + "type": "object", + "description": "Hook-specific payload. Schema depends on method." + }, + "signature": { "$ref": "#/$defs/Signature" } + } + }, + "Metadata": { + "type": "object", + "required": ["agent_id", "session_id"], + "properties": { + "agent_id": { "type": "string" }, + "agent_name": { "type": "string" }, + "session_id": { "type": "string", "format": "uuid" }, + "turn_id": { + "type": "string", + "description": "Identifier of the agent turn this step belongs to. Set by the framework when the turn opens (steps/turnStart) and propagated onto every per-step ContextEntry until steps/turnEnd. Lets policies key on per-turn state ('limit tool calls per turn', 'no consequential action in N turns after taint') without inferring turn boundaries from userMessage/agentResponse pairing — the inference breaks under auto-continuation, planning loops, and multi-step ReAct cycles. Required on every step between a turnStart and the matching turnEnd; absent on session-level steps (sessionStart, sessionEnd, agentTrigger, agbom/* events outside a turn)." + }, + "parent_turn_id": { + "type": "string", + "description": "Optional. Identifier of the enclosing turn when turns nest (e.g., a subagent turn inside a parent turn that is awaiting subagentStop). The parent–child turn relation is also captured in turnStart's payload; this field is the wire-level shortcut so per-step audit entries don't need to walk the chain to find the enclosing turn." + }, + "session_state": { + "type": "object", + "description": "Optional session state hints from the Observed Agent. The Guardian holds the authoritative state.", + "properties": { + "chain_hash": { "type": "string", "description": "Latest known chain hash from the agent's perspective" } + } + }, + "environment": { + "type": "string", + "enum": ["development", "staging", "production"] + }, + "platform": { "type": "string" }, + "platform_version": { "type": "string" }, + "user_context": { + "type": "object", + "properties": { + "user_id": { "type": "string" }, + "roles": { "type": "array", "items": { "type": "string" } }, + "authentication_method": { "type": "string" } + } + } + } + }, + "Signature": { + "type": "object", + "required": ["algorithm", "value", "key_id"], + "description": "Cryptographic signature envelope. The registry is crypto-agile: HMAC-SHA256 is the v0.1 baseline RECOMMENDED default (sufficient for shared-secret deployments). Classical asymmetric (ECDSA-P256, RSA-PSS-SHA256) and PQC algorithms (ML-DSA-*, SLH-DSA-*, aligned to NIST FIPS 203-205) are available under the ACS-Crypto profile. ML-DSA-65 is the RECOMMENDED primary for ACS-Crypto deployments; SLH-DSA-128s is a SHOULD backup for algorithmic diversity. Hybrid composites combine PQC + classical for transitional deployments.", + "properties": { + "algorithm": { + "type": "string", + "enum": [ + "HMAC-SHA256", + "ECDSA-P256", + "RSA-PSS-SHA256", + "ML-DSA-65", + "ML-DSA-44", + "ML-DSA-87", + "SLH-DSA-128s", + "SLH-DSA-128f", + "ML-DSA-65+ECDSA-P256", + "ML-DSA-65+RSA-PSS-SHA256" + ], + "description": "Signature algorithm. HMAC-SHA256 is symmetric MAC for shared-secret deployments (v0.1 RECOMMENDED). ECDSA-P256 and RSA-PSS-SHA256 are classical asymmetric. ML-DSA-* and SLH-DSA-* are pure PQC. The '+' forms are hybrid composites: the value field is the base64 of len(pqc_sig)||pqc_sig||len(classical_sig)||classical_sig where each len is a 4-byte big-endian unsigned integer; verifiers MUST verify both component signatures over the canonical input defined in Specification 10 (the JCS canonicalization of the envelope with the signature field removed)." + }, + "value": { + "type": "string", + "description": "Base64-encoded signature. For hybrid algorithms, see the algorithm description for the composite encoding." + }, + "key_id": { + "type": "string", + "description": "Identifier resolving to the public key (or the hybrid key descriptor that pins both component public keys for hybrid algorithms)." + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/response-envelope.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/response-envelope.json new file mode 100644 index 00000000..56a16a22 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/response-envelope.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/response-envelope.json", + "title": "ACS Response Envelope", + "description": "JSON-RPC 2.0 response envelope with ACS extensions. Sent by the Guardian Agent to the Observed Agent.", + "type": "object", + "required": ["jsonrpc", "id"], + "additionalProperties": false, + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { + "type": ["string", "number", "null"], + "description": "Echoes the request id. Per JSON-RPC 2.0, MUST be null when the id cannot be determined (e.g. parse error or invalid request); otherwise matches the request id. A successful (result) response always carries the original non-null id." + }, + "result": { + "description": "Method-dependent. Hook, wrapped-protocol, agbom/* and system/* methods return an AcsResult. handshake/hello returns a ServerHello (handshake.json), which carries no decision. The two shapes share no required field, so a conformant response matches exactly one branch: a ServerHello has no 'decision' and an AcsResult has no 'negotiated_version'.", + "oneOf": [ + { "$ref": "#/$defs/AcsResult" }, + { "$ref": "handshake.json#/$defs/ServerHello" } + ] + }, + "error": { "$ref": "#/$defs/JsonRpcError" } + }, + "oneOf": [ + { "required": ["result"], "not": { "required": ["error"] }, "properties": { "id": { "type": ["string", "number"] } } }, + { "required": ["error"], "not": { "required": ["result"] } } + ], + "$defs": { + "AcsResult": { + "type": "object", + "required": ["type", "acs_version", "request_id", "decision"], + "properties": { + "type": { + "type": "string", + "const": "final", + "description": "Discriminator. v0.1 only emits 'final'. v0.2 adds 'progress' and 'interruption'." + }, + "acs_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + "request_id": { "type": "string", "format": "uuid" }, + "decision": { + "type": "string", + "enum": ["allow", "deny", "modify", "ask", "defer"] + }, + "reasoning": { + "type": "string", + "description": "Human-renderable explanation of the verdict. REQUIRED on 'deny', 'modify', 'ask', and 'defer' (these decisions affect or interrupt the agent's action and the operator/end-user needs an explanation). RECOMMENDED on 'allow' when the deployment expects user-visible audit trails. This is the single explanation field — it serves both end-user display and audit/agent-internal consumption; deployments that want different text for those audiences SHOULD compose them client-side from reasoning + policy_data + reason_codes rather than expecting separate fields here." + }, + "reason_codes": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "description": "Optional machine-readable categorization of why the decision was reached. Free vocabulary in v0.1 (a registry MAY be layered in a future version once patterns stabilize). Common categories that fall out of the v0.1 paradigms: 'fides_p_t_violation', 'fides_p_f_violation', 'untrusted_into_consequential', 'ibac_intent_mismatch', 'ibac_scope_mode_strict', 'aarm_cumulative_taint', 'camel_dependency_graph_violation', 'pii_detected', 'rate_limited', 'chain_mismatch' (the submitted chain_hash does not match the Guardian's computed head; see Specification 8). UIs and meta-policies SHOULD switch on these codes rather than parsing reasoning text or policy/rule ids (which are bundle-specific)." + }, + "policy_references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { + "type": "string", + "minLength": 1, + "description": "Optional deployment-chosen version identifier for the referenced policy bundle or document. Lets audit replay reconstruct the exact historical policy state without prescribing a ledger or versioning scheme." + }, + "policy_name": { "type": "string", "minLength": 1 }, + "rule_id": { "type": "string", "minLength": 1 } + } + }, + "description": "The policies/rules that fired. A single decision MAY cite multiple — when IBAC + FIDES + AARM all reject the same action, expect three entries here, one per paradigm. policy_version is optional and deployment-defined, but SHOULD be populated when the deployment supports replay or ledger-backed policy state. Audit replay walks this list to reconstruct which rules contributed." + }, + "policy_data": { + "type": "object", + "additionalProperties": true, + "description": "Paradigm-specific or policy-specific structured payload. Free-form escape hatch for facts the policy wants to surface beyond reasoning text — e.g., FIDES exposes the violating argument path and lineage; IBAC exposes the requested capability and closest Intent.parsed match; AARM exposes the earliest policy-untrusted step_id and lookback_distance computed by the Guardian (v0.1 carries factual provenance on the wire and the Guardian projects it onto its trust classification — see §7.1). When multiple paradigms fire on the same decision, the convention is to key by paradigm name: { 'ibac': {...}, 'fides': {...}, 'aarm': {...} }, so each payload is independently parseable. Unstructured 'data' from earlier ACS drafts maps here." + }, + "cited_provenance_ids": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "description": "Optional. The provenance_ids whose facts drove this decision. FIDES P-T denials cite the lineage edge that violated trust; CaMeL violations cite the dependency-graph node; AARM cites the earliest untrusted step's provenance; IBAC 'allow with possibly-tainted contents' decisions cite the lineage that justified the call. Lifting this to a standard top-level field gives audit tooling one place to look for 'which provenance objects drove this verdict' without parsing per-paradigm policy_data payloads, and lets a verifier walk derived_from chains from these ids to confirm the verdict is consistent with the chain." + }, + "modifications": { "$ref": "modifications.json" }, + "ask_details": { "$ref": "ask-details.json" }, + "defer_details": { "$ref": "defer-details.json" }, + "payload": { + "type": "object", + "description": "Method-specific response data. Used by system/* methods (e.g., system/ping returns { status, echo, server_timestamp }) and other methods that carry structured data alongside the decision. Not used by standard Instrument hooks — those communicate via modifications, ask_details, or defer_details." + }, + "chain_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Rolling SHA-256 audit-chain head (lowercase hex) after this step's ContextEntry was appended. The Guardian MUST include it on every response for a step where it wrote a ContextEntry, the content-bearing steps (Specification 8). Publishing the head on the wire lets an observer that records traffic detect a chain rewritten after the fact. It is covered by the response signature. Under the HMAC baseline this gives integrity and tamper-evidence to a key-holder and supports cross-Guardian comparison; non-repudiation against a compromised Guardian requires the asymmetric ACS-Crypto profile." + }, + "signature": { + "$ref": "request-envelope.json#/$defs/Signature", + "description": "Signature over this response envelope, computed on the canonical input defined in Specification 10 (the JCS canonicalization of the envelope with the signature field removed). Optional in the schema; mandatory under ACS-Core. Required for a published chain_hash to be verifiable." + }, + "metadata": { + "type": "object", + "description": "ACS-defined evaluator and observability metadata. NOT for policy-emitted facts (use policy_data for those) — keeping the split clean lets Trace consumers key on a stable shape.", + "properties": { + "evaluator": { + "type": "string", + "enum": ["deterministic", "agent", "composite"] + }, + "evaluator_version": { "type": "string" }, + "evaluation_duration_ms": { "type": "integer", "minimum": 0 }, + "model_id": { "type": "string", "description": "Required if evaluator is 'agent' or 'composite'" }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + }, + "allOf": [ + { "if": { "properties": { "decision": { "const": "deny" } }, "required": ["decision"] }, "then": { "required": ["reasoning"] } }, + { "if": { "properties": { "decision": { "const": "modify" } }, "required": ["decision"] }, "then": { "required": ["reasoning", "modifications"] } }, + { "if": { "properties": { "decision": { "const": "ask" } }, "required": ["decision"] }, "then": { "required": ["reasoning", "ask_details"] } }, + { "if": { "properties": { "decision": { "const": "defer" } }, "required": ["decision"] }, "then": { "required": ["reasoning", "defer_details"] } } + ] + }, + "JsonRpcError": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer", + "description": "JSON-RPC error code. ACS reserves -32000 to -32099 for application errors." + }, + "message": { "type": "string" }, + "data": {} + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/trace/ocsf-mapping.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/trace/ocsf-mapping.json new file mode 100644 index 00000000..879d4b29 --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/trace/ocsf-mapping.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/ocsf-mapping.json", + "title": "ACS Trace → OCSF event-class mapping", + "description": "Normative mapping from ACS hooks and decisions to OCSF (Open Cybersecurity Schema Framework) event classes. v0.1.0 conformance requires that a deployment emitting OCSF for the Trace pillar uses these class UIDs and severity rules verbatim. Class UIDs, class names and activity IDs are those of OCSF 1.9.0.", + "type": "object", + "properties": { + "step_to_class": { + "type": "object", + "description": "Map ACS step methods to OCSF class UIDs. Every step event carries the step's request_id at unmapped.acs.request_id and its session_id at unmapped.acs.session_id, the same values as the OTel attributes acs.request_id and acs.session.id. Every step event also sets metadata.correlation_uid to its own pair, formatted as :, because SIEM content reads native fields and not unmapped. A steps/toolCallResult event also carries request_id_ref at unmapped.acs.request_id_ref whenever the payload carries it.", + "additionalProperties": { + "type": "object", + "required": ["class_uid", "class_name"], + "properties": { + "class_uid": { "type": "integer" }, + "class_name": { "type": "string" }, + "activity_id": { "type": "integer", "description": "OCSF activity_id within the class (e.g., 1=Logon, 2=Logoff for Authentication)." }, + "_note": { "type": "string", "description": "Explanation for readers." } + } + }, + "default": { + "steps/sessionStart": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 1 }, + "steps/sessionEnd": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 2 }, + "steps/userMessage": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/agentResponse": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/agentTrigger": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/turnStart": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/turnEnd": { "class_uid": 6002, "class_name": "Application Lifecycle" }, + "steps/toolCallRequest": { "class_uid": 1007, "class_name": "Process Activity" }, + "steps/toolCallResult": { "class_uid": 1007, "class_name": "Process Activity" }, + "steps/knowledgeRetrieval": { "class_uid": 6005, "class_name": "Datastore Activity" }, + "steps/memoryStore": { "class_uid": 6005, "class_name": "Datastore Activity" }, + "steps/memoryContextRetrieval": { "class_uid": 6005, "class_name": "Datastore Activity" }, + "steps/preCompact": { "class_uid": 6005, "class_name": "Datastore Activity", "activity_id": 99, "_note": "Compaction is a derived-data write into the agent's working context — modeled as Datastore Activity with a custom activity_id for the compaction subtype." }, + "steps/postCompact": { "class_uid": 6005, "class_name": "Datastore Activity", "activity_id": 99 }, + "steps/subagentStart": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 1, "_note": "Subagent spawn opens a new session — modeled as Authentication/Logon for OCSF symmetry with sessionStart." }, + "steps/subagentStop": { "class_uid": 3002, "class_name": "Authentication", "activity_id": 2 }, + "steps/skillRegister": { "class_uid": 5001, "class_name": "Device Inventory Info", "activity_id": 2, "_note": "Registration admits a skill to the component set and persists its definition ref and digest to the AgBOM. It is modeled as Device Inventory Info, the class agbom/* uses, with activity_id 2 (Collect)." }, + "steps/skillLoad": { "class_uid": 1005, "class_name": "Module Activity", "activity_id": 1, "_note": "OCSF defines Module Activity as a process loading or unloading a module, and its required module object is the skill itself — not Process Activity, which needs a launched or terminated process that a skill load does not have; keeping loads off 1007 lets a reader tell a skill loading from the tool calls its actions raise." }, + "steps/skillUnload": { "class_uid": 1005, "class_name": "Module Activity", "activity_id": 2 }, + "agbom/snapshot": { "class_uid": 5001, "class_name": "Device Inventory Info", "activity_id": 2 }, + "agbom/changed": { "class_uid": 5001, "class_name": "Device Inventory Info", "activity_id": 2 } + } + }, + "decision_class": { + "type": "object", + "description": "Decisions (deny/modify/ask/defer) are emitted as Detection Finding events. 'allow' is normally emitted as Application Activity with informational severity rather than as a finding, unless the deployment elects to record allows as findings for full audit symmetry. action_id and disposition_id record the Guardian's verdict. Only the Observed Agent sees the enforced outcome (Specification §6.4), so an action_id of 2 (Denied) or a disposition_id of 2 (Blocked) never proves the action was blocked. When any acs.decision.failure event in either party's stream carries the same session_id and request_id, a reader MUST NOT count the decision event as an enforced outcome. A missing failure event does not prove the decision was enforced. A Guardian SHOULD NOT set verdict_id or the finding's status_id, because both belong to whoever triages the finding.", + "properties": { + "class_uid": { "type": "integer", "const": 2004 }, + "class_name": { "type": "string", "const": "Detection Finding" }, + "required_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Fields every decision event carries. action and disposition are required beside their ids because ask and defer share 99 in both.", + "default": ["finding_info", "finding_info.title", "action_id", "action", "disposition_id", "disposition", "evidences", "metadata.correlation_uid"] + }, + "profiles": { + "type": "array", + "items": { "type": "string" }, + "description": "OCSF profiles a decision event lists in metadata.profiles. The Security Control profile defines disposition_id, which Detection Finding does not define on its own.", + "default": ["security_control"] + }, + "disposition_mapping": { + "type": "object", + "description": "OCSF disposition_id for decision events, keyed by ACS disposition. 99 (Other) means OCSF has no matching value. Defer takes 99, not 14 (Delayed), because an ACS defer holds the step and denies by default when it times out, where Delayed reads as an action that ran later. The disposition attribute carries the OCSF caption for 1 and 2, and the ACS disposition name for 99. The allow entry applies when the deployment records allows as Detection Findings.", + "additionalProperties": { "type": "integer" }, + "default": { + "allow": 1, + "deny": 2, + "modify": 99, + "ask": 99, + "defer": 99 + } + }, + "action_mapping": { + "type": "object", + "description": "OCSF action_id for decision events, keyed by ACS disposition. modify takes 4 (Modified), which gives it a code disposition_id does not have. The action attribute carries the OCSF caption for 1 (Allowed), 2 (Denied) and 4 (Modified), and the ACS disposition name for 99.", + "additionalProperties": { "type": "integer" }, + "default": { + "allow": 1, + "deny": 2, + "modify": 4, + "ask": 99, + "defer": 99 + } + }, + "alert_mapping": { + "type": "object", + "description": "OCSF is_alert for an on-time decision event, keyed by ACS disposition. It is true exactly when severity_id is 3 or higher, so the two fields never disagree. ask and defer alert because an ASK can time out to allow and a DEFER can fall back to that ASK, and v0.1.0 has no Trace event for how either one ends, so this event is the only signal a SOC gets. modify does not alert, because the action ran in the form the policy required. A MODIFY the client cannot apply becomes a DENY (Specification §6.5), which alerts.", + "additionalProperties": { "type": "boolean" }, + "default": { + "allow": false, + "deny": true, + "modify": false, + "ask": true, + "defer": true + } + }, + "evidence_fields": { + "type": "object", + "description": "What every decision event carries in evidences, the only native place on a Detection Finding for the agent and the action. Detection Finding has no ai_operation, and the class rejects a top-level ai_agent. api.operation is set only when the gated step is steps/toolCallRequest or steps/toolCallResult, to the tool name. For every other step, evidences carries ai_agent and actor.session only.", + "additionalProperties": { "type": "string" }, + "default": { + "ai_agent.uid": "metadata.agent_id", + "actor.session.uid": "metadata.session_id", + "api.operation": "payload.tool.name" + } + }, + "policy_fields": { + "type": "object", + "description": "When the response carries a non-empty policy_references, the event carries authorizations, one entry per policy_references entry in the same order, each with a policy object built from these fields. An entry with neither policy_id nor policy_name MUST be an authorizations entry with no policy object, so positions still line up: OCSF requires a policy to carry uid or name. policy_references stays under unmapped.acs as the exact copy.", + "additionalProperties": { "type": "string" }, + "default": { + "policy.uid": "policy_id", + "policy.version": "policy_version", + "policy.name": "policy_name" + } + }, + "late_decision": { + "type": "object", + "description": "A Guardian that finishes a decision after the negotiated timeout still emits the decision event. A late deny under fail-open means the policy said no and the action ran, so suppressing the event would leave OCSF silent exactly when someone slows the Guardian down. action_id and disposition_id stay the verdict, as on an on-time decision. finding_info.types includes late_decision_fail_open or late_decision_fail_closed, from the on_decision_failure posture the Guardian sent in its ServerHello. unmapped.acs.failure carries the failure facts, named as the OTel acs.failure.* attributes are. is_alert follows alert_mapping, and is also true for any verdict other than allow under fail-open, because the action ran without what the policy required. severity_id stays the value severity_mapping gives the verdict, so a late decision is the one case where is_alert can be true below severity 3. A reader MUST NOT count a decision event whose finding_info.types includes late_decision_fail_open or late_decision_fail_closed as an enforced outcome. The Guardian measures its own processing time, not network time, so a decision can still arrive late and look on time.", + "properties": { + "finding_types": { + "type": "array", + "items": { "type": "string" }, + "default": ["late_decision_fail_open", "late_decision_fail_closed"] + }, + "failure_fields": { + "type": "array", + "items": { "type": "string" }, + "default": ["class", "posture", "timeout_ms", "elapsed_ms", "payload_bytes"] + } + } + }, + "join_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Fields every decision event carries under unmapped.acs to join it to the step event it gated. request_id comes from the response, and session_id from the gated request's metadata.session_id. metadata.correlation_uid carries the same pair as :.", + "default": ["request_id", "session_id"] + }, + "acs_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Decision facts carried under unmapped.acs with the response field's name whenever the response carries it, so the OCSF event holds the same facts as the OTel acs.decision event. finding_info.desc SHOULD carry reasoning, and unmapped.acs.reasoning is the exact copy. policy_references keeps its object form, so it needs no padding rule. An empty source array counts as absent. OCSF does not validate unmapped, so each field takes the type and pattern of its definition in response-envelope.json.", + "default": ["request_id", "decision", "reasoning", "reason_codes", "policy_references", "cited_provenance_ids", "chain_hash", "metadata"] + } + } + }, + "severity_mapping": { + "type": "object", + "description": "OCSF severity_id values for decision events, keyed by ACS disposition.", + "additionalProperties": { "type": "integer", "minimum": 0, "maximum": 6 }, + "default": { + "allow": 1, + "modify": 2, + "ask": 3, + "defer": 3, + "deny": 4 + } + }, + "provenance_metadata": { + "type": "object", + "description": "When Provenance is attached to the originating hook, the OCSF event MUST carry provenance facts in 'enrichments' (an OCSF-recognized extensibility field). Each enrichment carries name='acs_provenance_*' and value={origin, source_id, ...}. v0.1 carries factual provenance in enrichments; trust classification is computed by the Guardian against local policy and is not a v0.1 enrichment (see §7.1).", + "properties": { + "required_enrichments": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs_provenance_origin"] + }, + "optional_enrichments": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs_provenance_source_id"] + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/schema/specification/v0.1.0/trace/otel-mapping.json b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/trace/otel-mapping.json new file mode 100644 index 00000000..aa76ebed --- /dev/null +++ b/reference-implementations/agt-go/internal/schema/specification/v0.1.0/trace/otel-mapping.json @@ -0,0 +1,219 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/otel-mapping.json", + "title": "ACS Trace → OpenTelemetry semconv mapping", + "description": "Normative mapping from ACS hooks and decisions to OpenTelemetry spans and span events. v0.1.0 conformance requires that a deployment emitting OTel for the Trace pillar uses these span names and required attributes verbatim. Decisions are recorded as span events on the parent step span, not as separate spans, so that the enforcement verdict and the action it gates share a parent and an OTel context.", + "type": "object", + "properties": { + "step_to_span": { + "type": "object", + "description": "Map ACS step methods to OTel span names and attributes. A required attribute's source field is required on the wire. A conditional attribute's source field is optional on the wire.", + "additionalProperties": { + "type": "object", + "required": ["span_name", "required_attributes"], + "properties": { + "span_name": { "type": "string" }, + "required_attributes": { "type": "array", "items": { "type": "string" } }, + "conditional_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attributes populated whenever the hook payload carries the source field. An exporter MUST emit each one when its source field is present and MUST omit it when the source field is absent. An empty source array counts as absent." + }, + "optional_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attributes an exporter MAY emit when its source field is present." + }, + "_note": { "type": "string", "description": "Explanation for readers. The normative rules are in the lists and descriptions." } + } + }, + "default": { + "steps/sessionStart": { + "span_name": "acs.session", + "required_attributes": ["acs.session.id"], + "optional_attributes": ["acs.tenant_id", "acs.user.id", "acs.policy_mode"] + }, + "steps/agentTrigger": { + "span_name": "acs.agent.trigger", + "required_attributes": ["acs.agent.id", "acs.trigger.type"] + }, + "steps/userMessage": { + "span_name": "acs.message.user", + "required_attributes": ["acs.session.id", "acs.content.types"] + }, + "steps/agentResponse": { + "span_name": "acs.message.agent", + "required_attributes": ["acs.session.id", "acs.agent.id"] + }, + "steps/toolCallRequest": { + "span_name": "gen_ai.tool.call", + "required_attributes": ["gen_ai.tool.name"], + "conditional_attributes": ["acs.capability"], + "optional_attributes": ["acs.tool.provider", "acs.tool.version", "acs.operation"] + }, + "steps/toolCallResult": { + "span_name": "gen_ai.tool.result", + "required_attributes": ["gen_ai.tool.name", "acs.exit_status"], + "conditional_attributes": ["acs.request_id_ref"], + "optional_attributes": ["acs.duration_ms"] + }, + "steps/knowledgeRetrieval": { + "span_name": "acs.knowledge.retrieval", + "required_attributes": ["acs.results.count"], + "conditional_attributes": ["acs.source.type"] + }, + "steps/memoryStore": { + "span_name": "acs.memory.store", + "required_attributes": [], + "conditional_attributes": ["acs.memory_store.name", "acs.operation"] + }, + "steps/memoryContextRetrieval": { + "span_name": "acs.memory.retrieval", + "required_attributes": ["acs.results.count"], + "conditional_attributes": ["acs.memory_store.name"] + }, + "steps/sessionEnd": { + "span_name": "acs.session.end", + "required_attributes": ["acs.session.reason"] + }, + "steps/turnStart": { + "span_name": "acs.turn", + "required_attributes": ["acs.turn.id", "acs.turn.triggered_by"], + "optional_attributes": ["acs.turn.parent_id"] + }, + "steps/turnEnd": { + "span_name": "acs.turn.end", + "required_attributes": ["acs.turn.id", "acs.turn.outcome"], + "optional_attributes": ["acs.turn.step_count"] + }, + "steps/preCompact": { + "span_name": "acs.compact", + "required_attributes": ["acs.compact.entry_count", "acs.compact.triggered_by"], + "optional_attributes": ["acs.compact.estimated_input_tokens"] + }, + "steps/postCompact": { + "span_name": "acs.compact.complete", + "required_attributes": ["acs.compact.entry_count"], + "optional_attributes": ["acs.compact.lineage_depth_after"] + }, + "steps/subagentStart": { + "span_name": "acs.subagent", + "required_attributes": ["acs.subagent.session_id", "acs.subagent.parent_session_id", "acs.subagent.intent_derivation"] + }, + "steps/subagentStop": { + "span_name": "acs.subagent.end", + "required_attributes": ["acs.subagent.session_id", "acs.subagent.outcome"], + "conditional_attributes": ["acs.subagent.final_chain_hash"] + }, + "steps/skillRegister": { + "span_name": "acs.skill.register", + "required_attributes": ["acs.skill.id", "acs.skill.digest.algorithm", "acs.skill.digest.value", "acs.skill.declared_capabilities"] + }, + "steps/skillLoad": { + "span_name": "acs.skill.load", + "required_attributes": ["acs.skill.id", "acs.skill.digest.algorithm", "acs.skill.digest.value", "acs.skill.load_trigger", "acs.skill.load_path"], + "optional_attributes": ["acs.skill.digest_verified", "acs.skill.registration_ref"] + }, + "steps/skillUnload": { + "span_name": "acs.skill.unload", + "required_attributes": ["acs.skill.id", "acs.skill.reason"], + "optional_attributes": ["acs.skill.replaced_by"] + }, + "agbom/snapshot": { + "span_name": "acs.agbom", + "required_attributes": ["acs.agbom.format", "acs.agbom.component_count"], + "_note": "acs.agbom.format names the AgBOM form the span records. No AgBOM payload carries a format field, because the wire form is always canonical. An exporter recording the payload sets canonical. A Guardian recording a serialization it rendered sets that serialization's agbom_serializations_supported value from the handshake." + }, + "agbom/changed": { + "span_name": "acs.agbom", + "required_attributes": ["acs.agbom.format"], + "conditional_attributes": ["acs.agbom.change_reason"], + "_note": "acs.agbom.format is sourced as on agbom/snapshot." + } + } + }, + "step_span_attributes": { + "type": "object", + "description": "Attributes every step_to_span span carries in addition to its own entry's lists. acs.request_id is the step's own request_id and acs.session.id its session_id, both required on every request envelope. acs.request_id_ref on a steps/toolCallResult span joins to the steps/toolCallRequest span it reports on. The Observed Agent chooses request_id, and it is unique only within a session (Specification §10.3), so a join keys on acs.session.id and acs.request_id together. The Observed Agent and the Guardian may each emit a span for one request, and a reader joins the two. When the Observed Agent retries a step with a new request_id, each attempt is its own request: the Observed Agent emits a step span for each attempt that reached the Guardian, carrying that attempt's request_id. A Guardian event whose pair matches no Observed Agent step span is a discrepancy worth investigating.", + "properties": { + "required_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.request_id", "acs.session.id"] + } + } + }, + "decision_event": { + "type": "object", + "description": "Decisions are recorded as a span event named 'acs.decision' on the parent step span. The event MUST carry the disposition. It MUST carry each conditional attribute whenever the response carries the source field, and MUST omit it when the source field is absent. An empty source array counts as absent. The event records the Guardian's verdict. Only the Observed Agent knows the applied outcome. When any acs.decision.failure event in either party's stream carries the same acs.session.id and acs.request_id, a reader MUST NOT count the acs.decision event as an enforced outcome. acs.reason_codes, acs.cited_provenance_ids and acs.chain_hash come from the response fields of the same name. acs.reason_codes is a set: its order carries no meaning, and it does not line up with the policy arrays. acs.policy.ids, acs.policy.versions and acs.policy.rule_ids come from policy_references as three parallel string arrays. Their source field is policy_references itself, so the conditional rule tests its presence, not the presence of the member fields. All three are emitted whenever the response carries a non-empty policy_references, and none is emitted otherwise. Each has the same length and order as policy_references, with an empty string where an entry lacks the field, so a reader can line up a policy, its version and its rule by position. An empty string on the wire could not be told from an absent field, so response-envelope.json rejects one in a policy_references entry. acs.chain_hash is a copy of the response field and proves nothing on its own. The trace carries neither the response signature nor the ContextEntries a verifier needs (Specification §8.2, §8.6), and a Guardian's own trace is no independent witness of its chain head. When the Guardian's and the Observed Agent's streams show different acs.chain_hash values for the same acs.session.id and acs.request_id, that is an integrity event (Specification §8.6).", + "properties": { + "event_name": { "type": "string", "const": "acs.decision" }, + "required_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.decision"] + }, + "conditional_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": [ + "acs.evaluator", + "acs.reasoning", + "acs.confidence", + "acs.evaluator_version", + "acs.model_id", + "acs.reason_codes", + "acs.policy.ids", + "acs.policy.versions", + "acs.policy.rule_ids", + "acs.cited_provenance_ids", + "acs.chain_hash" + ] + } + } + }, + "decision_failure_event": { + "type": "object", + "description": "A decision failure is recorded as a span event named 'acs.decision.failure' on the step span, separate from acs.decision, or on the acs.session span for handshake_failure. It records a step whose decision did not arrive or was not usable, and the deployment's failure posture. The Observed Agent MUST record every decision failure, fail-open or fail-closed, in the deployment's own audit log, and SHOULD emit this event when it claims ACS-Trace. A Guardian claiming ACS-Trace SHOULD emit it for the failures it sees itself: an error it returned (guardian_error) and a decision it finished after the negotiated timeout (timeout). In v0.1 the Observed Agent reports this event about its own conduct, so a missing event does not show that no failure happened, and a count built from these events is a lower bound. acs.failure.reported_by, acs.failure.posture and acs.failure.payload_bytes are the emitter's own claims. Events from both parties for one request describe one failure, so a reader counts distinct acs.session.id and acs.request_id pairs. That counts failed attempts, not steps: a step the Observed Agent recovered by retrying still shows its failed attempts. A pair with a Guardian event and no Observed Agent event is still counted. A Guardian MUST NOT emit this event for a request it rejects as a replay (REPLAY_DETECTED), because that pair belongs to the earlier request. A reader cross-checking the two sides takes them from separately produced streams. acs.failure.detail is required when acs.failure.class is other. It MUST be text the emitter composes itself, and MUST NOT copy bytes from the request, the response, or an error's data, including parser messages that quote their input. acs.failure.payload_bytes SHOULD be emitted, because when reported honestly it helps separate an induced failure from an organic one.", + "properties": { + "event_name": { "type": "string", "const": "acs.decision.failure" }, + "required_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.failure.class", "acs.failure.posture", "acs.failure.reported_by"] + }, + "conditional_attributes": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.failure.detail", "acs.failure.error_code", "acs.failure.timeout_ms", "acs.failure.elapsed_ms", "acs.failure.payload_bytes"] + }, + "attribute_values": { + "type": "object", + "description": "The values each enumerated attribute takes. timeout: the Guardian was reached and no decision arrived in time. unreachable: the request never reached the Guardian. malformed_response: an answer arrived and was not usable, for any reason other than its signature. signature_invalid: an answer arrived whose signature failed verification (Specification §10), kept apart so a tampered response does not blend into organic failures. handshake_failure: the session started without a completed handshake (Specification §4.1), so no step in it was governed; it is recorded on the acs.session span and counted under the handshake's own request_id and session_id. guardian_error: an error came back instead of a decision. observed_agent_error: the Observed Agent's own side failed before a decision could arrive. other: a failure that fits none of these. acs.failure.posture is fail_open for on_decision_failure proceed and fail_closed for deny. A response whose modifications break Specification §6.3, whether or not schema validation also rejects it, is not a decision failure, because §6.3 requires the Observed Agent to apply DENY. A MODIFY the client cannot apply is not a decision failure either, because §6.5 defines its outcome: DENY, or at postCompact the original summary. malformed_response covers a response that does not parse, or that fails schema validation for any other reason.", + "additionalProperties": { "type": "array", "items": { "type": "string" } }, + "default": { + "acs.failure.class": ["timeout", "unreachable", "malformed_response", "guardian_error", "observed_agent_error", "signature_invalid", "handshake_failure", "other"], + "acs.failure.posture": ["fail_open", "fail_closed"], + "acs.failure.reported_by": ["observed_agent", "guardian"] + } + } + } + }, + "provenance_attributes": { + "type": "object", + "description": "When Provenance is attached to a hook payload, the resulting span MUST carry provenance facts as attributes. Lineage edges MAY be linked via OTel span links keyed by provenance_id. v0.1 emits the factual provenance fields (origin, source_id) on the wire and on the span. Both are string arrays, also for a single Provenance object. acs.provenance.source_id is emitted whenever at least one attached Provenance object has a source_id, and omitted otherwise. When emitted, it has the same length and order as acs.provenance.origin, with an empty string where an object has none. Trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see §7.1).", + "properties": { + "required": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.provenance.origin"] + }, + "optional": { + "type": "array", + "items": { "type": "string" }, + "default": ["acs.provenance.source_id"] + } + } + } + } +} diff --git a/reference-implementations/agt-go/internal/tscases/record/main.go b/reference-implementations/agt-go/internal/tscases/record/main.go new file mode 100644 index 00000000..edf57d41 --- /dev/null +++ b/reference-implementations/agt-go/internal/tscases/record/main.go @@ -0,0 +1,78 @@ +// Command record sends the parity corpus to a running TypeScript reference +// Guardian and writes its answers. Run it through scripts/record-ts-cases.sh, +// which starts that Guardian from the tree named by -ts-tree. +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/observedagent" + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/internal/tscases" +) + +func main() { + url := flag.String("url", "http://127.0.0.1:8787/acs", "the TypeScript Guardian's endpoint") + out := flag.String("out", "agtbridge/testdata/ts-cases.json", "where to write the recording") + tree := flag.String("ts-tree", "../agt", "the TypeScript tree the running Guardian was started from") + flag.Parse() + if err := run(*url, *out, *tree); err != nil { + fmt.Fprintln(os.Stderr, "record:", err) + os.Exit(1) + } +} + +func run(url, out, tree string) error { + if err := tscases.Uncommitted(tree); err != nil { + return err + } + trees, err := tscases.DecidingTrees(tree) + if err != nil { + return err + } + rec := tscases.Recording{TSTrees: trees, Cases: tscases.Corpus()} + for ci := range rec.Cases { + session := observedagent.NewUUID() + for si := range rec.Cases[ci].Steps { + step := &rec.Cases[ci].Steps[si] + id := observedagent.NewUUID() + rawID, _ := json.Marshal(id) + body, err := json.Marshal(acs.Request{JSONRPC: acs.JSONRPCVersion, Method: step.Method, ID: rawID, Params: acs.Params{ + ACSVersion: acs.Version, RequestID: id, Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Metadata: acs.Metadata{AgentID: "parity", SessionID: session}, Payload: step.Payload, + }}) + if err != nil { + return err + } + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + raw, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + var r struct { + Result *acs.Decision `json:"result"` + Error *acs.Error `json:"error"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return fmt.Errorf("%s step %d: %w: %s", rec.Cases[ci].Name, si, err, raw) + } + step.Result, step.Error = r.Result, r.Error + } + } + b, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + return os.WriteFile(out, append(b, '\n'), 0o644) +} diff --git a/reference-implementations/agt-go/internal/tscases/tscases.go b/reference-implementations/agt-go/internal/tscases/tscases.go new file mode 100644 index 00000000..c949ef6b --- /dev/null +++ b/reference-implementations/agt-go/internal/tscases/tscases.go @@ -0,0 +1,188 @@ +// Package tscases holds the parity corpus: sessions of steps sent to the +// TypeScript reference Guardian, whose answers are recorded once and replayed +// against the Go implementation. The corpus exercises the two hooks the TypeScript +// Guardian evaluates, through the reproduced AGT runtime's branches: the +// destructive-command patterns, the egress annotator and its URL parsing, +// the tool registry, the policy target argument, the result redaction, and +// the session's information-flow labels across steps. +package tscases + +import ( + "encoding/json" + "fmt" + "os/exec" + "slices" + "strings" + + "github.com/GenAI-Security-Project/agent-control-standard/reference-implementations/agt-go/acs" +) + +// Recording is the file the recorder writes and the replay reads. +type Recording struct { + // TSTrees maps each of DecidingPaths to its git tree or blob ID in the + // TypeScript tree the answers came from. + TSTrees map[string]string `json:"ts_trees"` + Cases []Case `json:"cases"` +} + +// DecidingPaths are the paths of the TypeScript tree whose content decides a +// step's answer: the Guardian and bridge sources, the mapping, the policies, +// the bridge's package.json, which pins the AGT SDK that runs them, and +// agt.lock, which pins the AGT commit. +var DecidingPaths = []string{"packages/guardian/src", "packages/agt-bridge/src", "packages/agt-bridge/package.json", "agt.lock", "mapping.yaml", "policy"} + +// DecidingTrees returns the object ID of each of DecidingPaths at the commit +// checked out in tree. Object IDs are content-addressed, so they survive the +// squash merge that gives the same content a new commit. +func DecidingTrees(tree string) (map[string]string, error) { + ids := make(map[string]string, len(DecidingPaths)) + for _, p := range DecidingPaths { + out, err := exec.Command("git", "-C", tree, "rev-parse", "HEAD:./"+p).Output() + if err != nil { + return nil, fmt.Errorf("git rev-parse HEAD:./%s in %s: %w", p, tree, err) + } + ids[p] = strings.TrimSpace(string(out)) + } + return ids, nil +} + +// Uncommitted returns an error when tree has changes to DecidingPaths that +// HEAD does not hold, because DecidingTrees names HEAD and the running +// Guardian serves the working tree. +func Uncommitted(tree string) error { + args := append([]string{"-C", tree, "status", "--porcelain", "--"}, DecidingPaths...) + out, err := exec.Command("git", args...).Output() + if err != nil { + return fmt.Errorf("git status in %s: %w", tree, err) + } + if changed := strings.TrimSpace(string(out)); changed != "" { + return fmt.Errorf("commit or discard these changes before recording:\n%s", changed) + } + return nil +} + +// Case is one session: its steps run in order. +type Case struct { + Name string `json:"name"` + Steps []Step `json:"steps"` +} + +// Step is one request and the TypeScript Guardian's answer to it. +type Step struct { + Method string `json:"method"` + Payload json.RawMessage `json:"payload"` + // Result is the decision the TypeScript Guardian answered, nil when it + // answered Error. + Result *acs.Decision `json:"result,omitempty"` + Error *acs.Error `json:"error,omitempty"` +} + +func shell(command string) json.RawMessage { + return mustJSON(map[string]any{ + "tool": map[string]any{"name": "Bash"}, + "arguments": map[string]any{"command": map[string]any{"value": command}}, + "raw_command": command, + }) +} + +func toolCall(tool string, args map[string]any, raw *string) json.RawMessage { + wrapped := map[string]any{} + for k, v := range args { + wrapped[k] = map[string]any{"value": v} + } + p := map[string]any{"tool": map[string]any{"name": tool}, "arguments": wrapped} + if raw != nil { + p["raw_command"] = *raw + } + return mustJSON(p) +} + +func result(tool string, outputs ...any) json.RawMessage { + out := make([]any, len(outputs)) + for i, o := range outputs { + out[i] = map[string]any{"value": o} + } + return mustJSON(map[string]any{"tool": map[string]any{"name": tool}, "exit_status": "success", "outputs": out}) +} + +func mustJSON(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +func request(p json.RawMessage) Step { return Step{Method: acs.StepToolCallRequest, Payload: p} } +func answer(p json.RawMessage) Step { return Step{Method: acs.StepToolCallResult, Payload: p} } + +func one(name string, s Step) Case { return Case{Name: name, Steps: []Step{s}} } + +// Corpus is every case, unrecorded. +func Corpus() []Case { + str := func(s string) *string { return &s } + cases := []Case{} + for name, command := range map[string]string{ + "destructive_root": "rm -rf /", + "destructive_echoed": "echo rm -rf /", + "destructive_flags_reversed": "rm -fr /", + "destructive_upper_case": "RM -RF /", + "destructive_current_directory": "rm -rf ./", + "destructive_parent_directory": "rm -rf ..", + "destructive_with_sudo": "sudo rm -rf / --no-preserve-root", + "split_flags_not_matched": "rm -r -f /", + "benign_listing": "ls -la", + "benign_install": "npm install", + "benign_empty": "", + "secret_in_command": "echo ghp_abcdefgh1234", + "aws_key_in_command": "export K=AKIAABCDEFGHIJKLMNOP", + "egress_off_allowlist": "curl https://evil.example.net/x", + "egress_on_allowlist": "curl https://docs.example.com/a", + "egress_clone": "git clone https://example.org/r", + "egress_default_port": "curl https://docs.example.com:443/", + "egress_other_port": "curl https://docs.example.com:8443/", + "egress_upper_case_host": "curl http://DOCS.EXAMPLE.COM/", + "egress_upper_case_scheme": "curl HTTPS://evil.example.net/", + "egress_hex_ipv4": "wget http://0x7f.1/", + "egress_dotted_ipv4": "curl http://127.0.0.1/", + "egress_userinfo": "curl https://user@evil.example.net/", + "egress_invalid_punycode": "curl https://xn--zz.example.net/", + "egress_suffix_confusion": "curl https://docs.example.com.evil.example.net/", + "egress_quoted": "echo 'https://docs.example.com/q'", + "egress_first_url_counts": "curl https://docs.example.com/?next=https://evil.example.net", + "egress_ftp_ignored": "curl ftp://evil.example.net/", + "egress_trailing_dot": "curl https://docs.example.com./", + "egress_port_out_of_range": "curl https://docs.example.com:70000/", + } { + cases = append(cases, one(name, request(shell(command)))) + } + cases = append(cases, + one("fetch_on_allowlist", request(toolCall("WebFetch", map[string]any{"url": "https://docs.example.com/en"}, nil))), + one("fetch_off_allowlist", request(toolCall("WebFetch", map[string]any{"url": "https://evil.example.net/"}, nil))), + one("fetch_lower_case_tool", request(toolCall("webfetch", map[string]any{"url": "https://evil.example.net/"}, nil))), + one("fetch_userinfo_soft_spot", request(toolCall("WebFetch", map[string]any{"url": "https://docs.example.com@evil.example.net/"}, nil))), + one("fetch_url_not_a_string", request(toolCall("WebFetch", map[string]any{"url": 42}, nil))), + one("unregistered_tool", request(toolCall("Write", map[string]any{"command": "x"}, nil))), + one("policy_target_argument_missing", request(toolCall("Bash", map[string]any{"cmd": "ls"}, str("ls")))), + one("argument_named_like_the_leaf", request(toolCall("Bash", map[string]any{"command": "ls", "acs_policy_target": "x"}, str("ls")))), + one("raw_command_absent", request(toolCall("Bash", map[string]any{"command": "curl https://evil.example.net/"}, nil))), + one("command_not_a_string", request(toolCall("Bash", map[string]any{"command": 5}, str("5")))), + one("url_argument_on_shell", request(toolCall("bash", map[string]any{"command": "ls", "url": "https://evil.example.net/"}, str("ls")))), + one("result_secret_redacted", answer(result("Bash", "TOKEN=ghp_ABCDEF123456"))), + one("result_aws_key_redacted", answer(result("Bash", "key AKIAABCDEFGHIJKLMNOP end"))), + one("result_clean", answer(result("Bash", "total 0"))), + one("result_no_outputs", answer(result("Bash"))), + one("result_secret_in_second_output", answer(result("Bash", "clean", "ghp_ABCDEF123456"))), + one("result_output_is_an_object", answer(result("Bash", map[string]any{"stdout": "ghp_ABCDEF123456"}))), + one("result_unregistered_tool", answer(result("Write", "ok"))), + Case{Name: "labels_across_steps", Steps: []Step{ + request(shell("ls")), + answer(result("Bash", "file")), + request(shell("cat file")), + answer(result("Bash", "TOKEN=ghp_ABCDEF123456")), + request(shell("rm -rf /")), + }}, + ) + slices.SortFunc(cases, func(a, b Case) int { return strings.Compare(a.Name, b.Name) }) + return cases +} diff --git a/reference-implementations/agt-go/internal/tscases/tscases_test.go b/reference-implementations/agt-go/internal/tscases/tscases_test.go new file mode 100644 index 00000000..23e45d18 --- /dev/null +++ b/reference-implementations/agt-go/internal/tscases/tscases_test.go @@ -0,0 +1,59 @@ +package tscases + +import ( + "os" + "strings" + "testing" + + "go.yaml.in/yaml/v3" +) + +// TestDecidingPathsTriggerTheGoWorkflow: a change to a deciding path makes the +// recording stale, so the Go workflow must run on it; otherwise a TypeScript +// pull request merges green and the next Go pull request fails on a +// recording it did not touch. +func TestDecidingPathsTriggerTheGoWorkflow(t *testing.T) { + raw, err := os.ReadFile("../../../../.github/workflows/reference-implementation-go.yml") + if err != nil { + t.Fatal(err) + } + type trigger struct { + Paths []string `yaml:"paths"` + } + var workflow struct { + On struct { + PullRequest trigger `yaml:"pull_request"` + Push trigger `yaml:"push"` + } `yaml:"on"` + } + if err := yaml.Unmarshal(raw, &workflow); err != nil { + t.Fatal(err) + } + for event, on := range map[string]trigger{"pull_request": workflow.On.PullRequest, "push": workflow.On.Push} { + filters := on.Paths + if len(filters) == 0 { + t.Fatalf("the Go workflow has no %s path filter", event) + } + for _, p := range DecidingPaths { + if !covered("reference-implementations/agt/"+p, filters) { + t.Errorf("%s: no path filter covers %s", event, p) + } + } + } +} + +// covered applies the filters as GitHub does: a filter starting with "!" +// excludes, and the last filter that matches decides. It reads exact paths +// and trailing "/**" only, the forms the Go workflow uses; another glob +// matches nothing here, so adding one fails this test until covered reads it. +func covered(path string, filters []string) bool { + included := false + for _, f := range filters { + pattern, excluding := strings.CutPrefix(f, "!") + dir, recursive := strings.CutSuffix(pattern, "/**") + if pattern == path || recursive && (path == dir || strings.HasPrefix(path, dir+"/")) { + included = !excluding + } + } + return included +} diff --git a/reference-implementations/agt-go/scripts/build-agt-native.sh b/reference-implementations/agt-go/scripts/build-agt-native.sh new file mode 100755 index 00000000..ded65e42 --- /dev/null +++ b/reference-implementations/agt-go/scripts/build-agt-native.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Build the pinned native library and OPA. See docs/building.md. +set -euo pipefail + +cd "$(dirname "$0")/.." +lock=agt-native.lock + +die() { + echo "build-agt-native: $*" >&2 + exit 1 +} + +for tool in jq git curl rustup cargo go; do + command -v "$tool" >/dev/null || die "$tool is required on PATH" +done + +pinned() { jq -er "$1" "$lock" || die "$lock has no $1"; } +agt_repo="$(pinned .agt_repo)" +agt_ref="$(pinned .agt_ref)" +workspace_path="$(pinned .workspace_path)" +crate="$(pinned .crate)" +crate_version="$(pinned .crate_version)" +ffi_rs_sha256="$(pinned .ffi_rs_sha256)" +features="$(pinned '.cargo_features | join(",")')" +toolchain="$(pinned .rust_toolchain)" +opa_version="$(pinned .opa_version)" + +host_os="$(go env GOHOSTOS)" +host_arch="$(go env GOHOSTARCH)" +target_os="${GOOS:-$host_os}" +target_arch="${GOARCH:-$host_arch}" +platform="$target_os-$target_arch" +opa_asset="opa_${target_os}_${target_arch}" +opa_name=opa +case "$platform" in + linux-amd64) target=x86_64-unknown-linux-gnu; library="lib$crate.so"; opa_asset="${opa_asset}_static" ;; + linux-arm64) target=aarch64-unknown-linux-gnu; library="lib$crate.so"; opa_asset="${opa_asset}_static" ;; + darwin-amd64) target=x86_64-apple-darwin; library="lib$crate.dylib" ;; + darwin-arm64) target=aarch64-apple-darwin; library="lib$crate.dylib"; opa_asset="${opa_asset}_static" ;; + windows-amd64) target=x86_64-pc-windows-gnu; library="$crate.dll"; opa_asset="${opa_asset}.exe"; opa_name=opa.exe ;; + *) die "unsupported target $platform; supported: linux-amd64, linux-arm64, darwin-amd64, darwin-arm64, windows-amd64" ;; +esac +if [ "$target_os" = darwin ] && [ "$host_os" != darwin ]; then + die "macOS native builds require a Mac and the Apple SDK; use EVALUATOR=go for a Go cross-build" +fi +out="${AGT_OUT:-.acs/agt/$platform}" +opa_sha256="$(pinned ".opa_sha256[\"$platform\"]")" + +sha256() { + if command -v sha256sum >/dev/null; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# No identifying information on outbound git traffic, as +# ../agt/scripts/verify-pin.sh does. +export GIT_TERMINAL_PROMPT=0 +src=.acs/agt/src +if [ ! -d "$src/.git" ]; then + git -c credential.helper= init --quiet "$src" + git -C "$src" -c credential.helper= remote add origin "$agt_repo" +fi +if [ "$(git -C "$src" rev-parse -q --verify HEAD || true)" != "$agt_ref" ]; then + git -C "$src" -c credential.helper= fetch --quiet --depth 1 origin "$agt_ref" + git -C "$src" -c credential.helper= checkout --quiet --detach FETCH_HEAD +fi +[ "$(git -C "$src" rev-parse HEAD)" = "$agt_ref" ] || die "$src is not at $agt_ref" +[ -z "$(git -C "$src" status --porcelain --untracked-files=all)" ] || die "$src has local changes; the pin names the commit, so only unmodified source is built" + +# agteval/acs.h is written from AGT's ffi.rs, and opa_version and opa_sha256 +# from the opa AGT's SDK fetches; a commit that changes either fails here +# until this side is checked against it. +[ "$(sha256 "$src/$workspace_path/core/src/ffi.rs")" = "$ffi_rs_sha256" ] || + die "AGT's ffi.rs at $agt_ref differs from the one agtbridge/agteval/acs.h was written from; update acs.h, then ffi_rs_sha256 in $lock" +fetch_opa="$src/$workspace_path/sdk/node/scripts/fetch-opa.mjs" +grep -q "OPA_VERSION ?? \"$opa_version\"" "$fetch_opa" || + die "AGT's SDK at $agt_ref fetches another opa than $opa_version ($fetch_opa); update opa_version and opa_sha256 in $lock" +for checksum in $(jq -r '.opa_sha256[]' "$lock"); do + grep -q "\"$checksum\"" "$fetch_opa" || die "AGT's SDK at $agt_ref does not name opa checksum $checksum ($fetch_opa)" +done + +rustup toolchain list | grep -q "^$toolchain-" || rustup toolchain install "$toolchain" --profile minimal +rustup target add "$target" --toolchain "$toolchain" +if [ "$target_os" = windows ]; then + compiler="${CC:-gcc}" + command -v "$compiler" >/dev/null || die "$compiler is required for Windows GNU builds" + export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER="$compiler" +elif [ "$target_os" = linux ] && [ "$target_arch" != "$host_arch" ]; then + compiler="${CC:?set CC to the target C compiler for a native cross-build}" + command -v "$compiler" >/dev/null || die "$compiler is required" + linker_variable="CARGO_TARGET_$(printf '%s' "$target" | tr '[:lower:]-' '[:upper:]_')_LINKER" + export "$linker_variable=$compiler" +fi +manifest="$src/$workspace_path/Cargo.toml" +pkgid="$(cargo +"$toolchain" pkgid --locked --manifest-path "$manifest" -p "$crate")" +[ "${pkgid##*[#@]}" = "$crate_version" ] || die "$crate at $agt_ref is version ${pkgid##*[#@]}, and $lock pins $crate_version" +cargo +"$toolchain" build --quiet --release --locked --manifest-path "$manifest" -p "$crate" --features "$features" --target "$target" + +# A running Guardian maps the installed library, so each file is replaced by +# rename, never rewritten in place. +mkdir -p "$out/lib" "$out/bin" +cp "$src/$workspace_path/target/$target/release/$library" "$out/lib/$library.new" +if [ "$target_os" = darwin ]; then + install_name_tool -id "@rpath/$library" "$out/lib/$library.new" + codesign --force --sign - "$out/lib/$library.new" +fi +if [ "$target_os" = windows ]; then + cp "$src/$workspace_path/target/$target/release/lib$crate.dll.a" "$out/lib/" +fi +if [ -f "$out/lib/$library" ] && [ "$(sha256 "$out/lib/$library")" = "$(sha256 "$out/lib/$library.new")" ]; then + rm "$out/lib/$library.new" +else + mv "$out/lib/$library.new" "$out/lib/$library" +fi +jq -n \ + --arg agt_ref "$agt_ref" --arg crate "$crate" --arg crate_version "$crate_version" \ + --arg features "$features" --arg toolchain "$toolchain" --arg sha256 "$(sha256 "$out/lib/$library")" \ + '{agt_ref: $agt_ref, crate: $crate, crate_version: $crate_version, cargo_features: ($features | split(",")), rust_toolchain: $toolchain, sha256: $sha256}' \ + >"$out/lib/agt-native.json.new" +mv "$out/lib/agt-native.json.new" "$out/lib/agt-native.json" + +opa="$out/bin/$opa_name" +if [ ! -f "$opa" ] || [ "$(sha256 "$opa")" != "$opa_sha256" ]; then + curl --proto '=https' --tlsv1.2 -fsSL -o "$opa.download" \ + "https://github.com/open-policy-agent/opa/releases/download/v$opa_version/$opa_asset" + if [ "$(sha256 "$opa.download")" != "$opa_sha256" ]; then + rm -f "$opa.download" + die "opa $opa_version for $platform does not match the SHA-256 $lock pins" + fi + chmod +x "$opa.download" + mv "$opa.download" "$opa" +fi +if [ "$platform" = "$host_os-$host_arch" ]; then + mkdir -p .acs/agt/bin + cp "$opa" ".acs/agt/bin/$opa_name" +fi +echo "build-agt-native: AGT $agt_ref ($crate $crate_version) in $out/lib, opa $opa_version in $out/bin" diff --git a/reference-implementations/agt-go/scripts/check-common-conformance.sh b/reference-implementations/agt-go/scripts/check-common-conformance.sh new file mode 100755 index 00000000..72606843 --- /dev/null +++ b/reference-implementations/agt-go/scripts/check-common-conformance.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +module="$(cd "$(dirname "$0")/.." && pwd)" +typescript="$module/../agt" +port="${ACS_CONFORMANCE_PORT:-0}" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/acs-go-conformance.XXXXXX")" +secret="$scratch/hmac-secret" +guardian_pid="" + +cleanup() { + if [ -n "$guardian_pid" ]; then + kill "$guardian_pid" 2>/dev/null || true + wait "$guardian_pid" 2>/dev/null || true + fi + rm -f "$scratch/envelopes.jsonl" "$scratch/events.jsonl" "$scratch/guardian.log" "$secret" + rmdir "$scratch" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +openssl rand -base64 32 >"$secret" +chmod 600 "$secret" + +cd "$module" +ACS__SERVER__PORT="$port" \ +ACS__SECURITY__HMAC_KEY_ID=conformance \ +ACS__SECURITY__HMAC_SECRET_FILE="$secret" \ +ACS__AUDIT__ENVELOPE_LOG="$scratch/envelopes.jsonl" \ +ACS__AUDIT__EVENT_LOG="$scratch/events.jsonl" \ + "${ACS_GUARDIAN_BIN:?set ACS_GUARDIAN_BIN through make conformance}" --config guardian.yaml >"$scratch/guardian.log" 2>&1 & +guardian_pid=$! + +for attempt in $(seq 1 30); do + if ! kill -0 "$guardian_pid" 2>/dev/null; then + cat "$scratch/guardian.log" + exit 1 + fi + guardian_url="$(sed -n 's#^Guardian listening at \(http://[^ ]*/acs\)$#\1#p' "$scratch/guardian.log" | tail -n 1)" + if [ -n "$guardian_url" ] && curl --fail --silent --show-error "${guardian_url%/acs}/readyz" >/dev/null; then + break + fi + if [ "$attempt" -eq 30 ]; then + cat "$scratch/guardian.log" + exit 1 + fi + sleep 1 +done + +cd "$typescript" +bun test packages/conformance/test/external-guardian.test.ts +ACS_CONFORMANCE_GUARDIAN_URL="$guardian_url" \ +ACS_CONFORMANCE_HMAC_SECRET_FILE="$secret" \ +ACS_CONFORMANCE_HMAC_KEY_ID=conformance \ + "$module/scripts/check-conformance-report.sh" diff --git a/reference-implementations/agt-go/scripts/check-conformance-report.sh b/reference-implementations/agt-go/scripts/check-conformance-report.sh new file mode 100755 index 00000000..b547a94d --- /dev/null +++ b/reference-implementations/agt-go/scripts/check-conformance-report.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +module="$(cd "$(dirname "$0")/.." && pwd)" +cd "$module/../agt" +report="$(PINNED_AGT_CLONE="$module/.acs/agt/src" bun run conformance:guardian)" +printf '%s\n' "$report" +if ! printf '%s\n' "$report" | grep -q '^policy-input schema .*: RAN'; then + printf '%s\n' 'the AGT policy-input schema checks did not run' >&2 + exit 1 +fi +if ! printf '%s\n' "$report" | grep -q '^external Guardian wire checks: RAN'; then + printf '%s\n' 'the external Guardian leg did not run; the Guardian was never probed' >&2 + exit 1 +fi diff --git a/reference-implementations/agt-go/scripts/check-coverage.sh b/reference-implementations/agt-go/scripts/check-coverage.sh new file mode 100755 index 00000000..9398ad2d --- /dev/null +++ b/reference-implementations/agt-go/scripts/check-coverage.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Fails when the statement coverage of the module's packages, counted across +# every test in the module, falls below the floor. Every package counts except +# the ones excluded below: commands, examples and test helpers. A new package +# is therefore counted without anyone listing it. Arguments after the floor go +# to go list and go test, so `-tags agteval` counts the agt evaluator. +# +# Usage: scripts/check-coverage.sh [floor-percent] [go flags...] +set -euo pipefail + +floor="${1:-80}" +shift || true +cd "$(dirname "$0")/.." +module="$(go list -m)" +excluded="^$module/(cmd|examples|internal/docscheck|internal/tscases|guardian/guardiantest)(/|$)" +packages="$(go list "$@" ./... | grep -Ev "$excluded" | paste -sd, -)" +profile="$(mktemp)" +output="$(mktemp)" +trap 'rm -f "$profile" "$output"' EXIT +if ! go test "$@" ./... -coverpkg="$packages" -coverprofile="$profile" >"$output" 2>&1; then + grep -Ev '^(ok |\? |coverage: )' "$output" >&2 + exit 1 +fi +total="$(go tool cover -func="$profile" | awk '/^total:/ {sub("%", "", $3); print $3}')" +echo "package coverage: ${total}% (floor ${floor}%)" +awk -v t="$total" -v f="$floor" 'BEGIN { exit !(t + 0 >= f + 0) }' diff --git a/reference-implementations/agt-go/scripts/check-image.sh b/reference-implementations/agt-go/scripts/check-image.sh new file mode 100755 index 00000000..d0d3b92a --- /dev/null +++ b/reference-implementations/agt-go/scripts/check-image.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +module="$(cd "$(dirname "$0")/.." && pwd)" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/acs-image.XXXXXX")" +container="" +cleanup() { + if [ -n "$container" ]; then docker rm -f "$container" >/dev/null; fi + rm -f "$scratch/hmac-secret" + rmdir "$scratch" +} +trap cleanup EXIT +openssl rand -base64 32 >"$scratch/hmac-secret" +chmod 600 "$scratch/hmac-secret" +container="$(docker run --detach --platform "${IMAGE_PLATFORM:?set IMAGE_PLATFORM through make image-check}" --publish 127.0.0.1::8787 \ + --user "$(id -u):$(id -g)" \ + --tmpfs "/data:uid=$(id -u),gid=$(id -g),mode=0700" \ + --mount "type=bind,src=$scratch/hmac-secret,dst=/run/secrets/acs-hmac,readonly" \ + --env ACS__SECURITY__HMAC_KEY_ID=conformance "${IMAGE:?set IMAGE through make image-check}")" +port="$(docker port "$container" 8787/tcp | sed 's/.*://')" +url="http://127.0.0.1:$port/acs" +for attempt in $(seq 1 30); do + if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]; then + docker logs "$container" + exit 1 + fi + if curl --fail --silent "${url%/acs}/readyz" >/dev/null; then break; fi + if [ "$attempt" -eq 30 ]; then docker logs "$container"; exit 1; fi + sleep 1 +done +ACS_CONFORMANCE_GUARDIAN_URL="$url" \ +ACS_CONFORMANCE_HMAC_SECRET_FILE="$scratch/hmac-secret" \ +ACS_CONFORMANCE_HMAC_KEY_ID=conformance \ + "$module/scripts/check-conformance-report.sh" diff --git a/reference-implementations/agt-go/scripts/check-tracked-files.sh b/reference-implementations/agt-go/scripts/check-tracked-files.sh new file mode 100755 index 00000000..8eb98bfc --- /dev/null +++ b/reference-implementations/agt-go/scripts/check-tracked-files.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Refuses tracked files that look like local build output: a binary file, a +# file over 1 MiB, or a line naming a developer's home directory. Binary test +# data is allowed by path below. +set -euo pipefail + +allowed_binary=( + internal/jcs/testdata/es6-numbers.txt.gz +) +max_bytes=1048576 + +failed=0 +text_files="$(git grep -I --name-only -e '' -- .)" +while IFS= read -r -d '' path; do + [[ -f "$path" ]] || continue + size="$(wc -c <"$path")" + if ((size > max_bytes)); then + printf '%s: %d bytes, over %d; generated output does not belong in the repository\n' "$path" "$size" "$max_bytes" >&2 + failed=1 + fi + if ((size > 0)) && ! grep -qxF -- "$path" <<<"$text_files"; then + allowed=0 + for a in "${allowed_binary[@]}"; do [[ "$path" == "$a" ]] && allowed=1; done + if ((allowed == 0)); then + printf '%s: binary file; add binary test data to scripts/check-tracked-files.sh by path\n' "$path" >&2 + failed=1 + fi + fi +done < <(git ls-files -z -- .) + +if git grep -n -I -E '(/home/|/Users/)[A-Za-z0-9._-]+|(^|[^A-Za-z0-9._-])/root(/|$|[^A-Za-z0-9._-])' -- . ':!scripts/check-tracked-files.sh' >&2; then + printf 'the lines above name a home directory; use a relative path\n' >&2 + failed=1 +fi +exit "$failed" diff --git a/reference-implementations/agt-go/scripts/check.sh b/reference-implementations/agt-go/scripts/check.sh new file mode 100644 index 00000000..0e62d1be --- /dev/null +++ b/reference-implementations/agt-go/scripts/check.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 +mkdir -p .acs/checks +log="$(mktemp ".acs/checks/check-$(date +%Y%m%d-%H%M%S).XXXXXX")" +mv "$log" "$log.log" +log="$log.log" +log="$(pwd)/$log" +printf 'Checking the Guardian with AGT and Go...\n' +inline=0 +if [[ -t 1 && "${CI:-}" != true ]]; then inline=1; fi +set +e +ACS_CHECK_PROGRESS=1 "$@" --no-print-directory check-all 2>&1 | { + active='' + while IFS= read -r line || [[ -n "$line" ]]; do + printf '%s\n' "$line" >>"$log" || exit 1 + case "$line" in + '[check] RUN: '*) + if [[ "$inline" == 1 && -n "$active" ]]; then printf '\n'; fi + active="${line#'[check] RUN: '}" + if [[ "$inline" == 1 ]]; then + printf '%s... ' "$active" + else + printf '%s...\n' "$active" + fi + ;; + '[check] PASS: '*) + stage="${line#'[check] PASS: '}" + if [[ "$inline" == 1 && "$stage" == "$active" ]]; then + printf 'PASS\n' + else + if [[ "$inline" == 1 && -n "$active" ]]; then printf '\n'; fi + printf '%s... PASS\n' "$stage" + fi + if [[ "$stage" == "$active" ]]; then active=''; fi + ;; + esac + done + if [[ -n "$active" ]]; then + if [[ "$inline" == 1 ]]; then printf 'FAIL\n'; else printf '%s... FAIL\n' "$active"; fi + fi +} +statuses=("${PIPESTATUS[@]}") +set -e +status="${statuses[0]}" +if [[ "$status" == 0 ]]; then + status="${statuses[1]}" +fi +if [[ "$status" == 0 ]]; then + printf 'PASS: All checks passed for AGT and Go.\nDetails: %s\n' "$log" +else + printf 'FAIL: Checks stopped with exit code %s.\n' "$status" >&2 + tail -n 80 "$log" >&2 + printf 'Full details: %s\n' "$log" >&2 + exit "$status" +fi diff --git a/reference-implementations/agt-go/scripts/record-ts-cases.sh b/reference-implementations/agt-go/scripts/record-ts-cases.sh new file mode 100755 index 00000000..28fe65f0 --- /dev/null +++ b/reference-implementations/agt-go/scripts/record-ts-cases.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Records the parity corpus from the TypeScript reference Guardian in +# ../agt, at the commit checked out, into agtbridge/testdata/ts-cases.json. +# Needs bun (the version ../agt's workflow pins) on PATH; run it from this +# module's directory. +set -euo pipefail + +module="$(cd "$(dirname "$0")/.." && pwd)" +tree="$module/../agt" +port="${ACS_RECORD_PORT:-8797}" +logs="$(mktemp -d)" + +(cd "$tree" && bun install --frozen-lockfile >/dev/null) +# exec, so the PID the trap kills is the Guardian's own, not a wrapper's. +(cd "$tree" && ACS_GUARDIAN_PORT="$port" ACS_ENVELOPE_LOG="$logs/envelopes.jsonl" \ + ACS_SESSION_CONTEXT_LOG="$logs/session-context.jsonl" exec bun packages/guardian/src/main.ts >"$logs/guardian.log" 2>&1) & +guardian=$! +trap 'kill "$guardian" 2>/dev/null || true' EXIT + +for _ in $(seq 1 50); do + grep -q "Guardian listening" "$logs/guardian.log" 2>/dev/null && break + sleep 0.2 +done + +cd "$module" +go run ./internal/tscases/record \ + -url "http://127.0.0.1:$port/acs" \ + -out agtbridge/testdata/ts-cases.json \ + -ts-tree "$tree" diff --git a/reference-implementations/agt-go/scripts/run-host-example.sh b/reference-implementations/agt-go/scripts/run-host-example.sh new file mode 100755 index 00000000..51684aa7 --- /dev/null +++ b/reference-implementations/agt-go/scripts/run-host-example.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +if (( $# == 0 )); then + echo "usage: scripts/run-host-example.sh command [argument ...]" >&2 + exit 2 +fi + +port="${ACS_HOST_EXAMPLE_PORT:-0}" +if [ "$port" != 0 ] && curl --fail --silent --show-error "http://127.0.0.1:$port/readyz" >/dev/null; then + echo "127.0.0.1:$port is already serving a Guardian" >&2 + exit 1 +fi + +scratch="$(mktemp -d "${TMPDIR:-/tmp}/acs-host-example.XXXXXX")" +guardian_log="$scratch/guardian.log" +ACS__SERVER__PORT="$port" \ +ACS__AUDIT__ENVELOPE_LOG="$scratch/envelopes.jsonl" \ +ACS__AUDIT__EVENT_LOG="$scratch/events.jsonl" \ + "${ACS_GUARDIAN_BIN:?set ACS_GUARDIAN_BIN through make}" --config guardian.yaml >"$guardian_log" 2>&1 & +guardian_pid=$! + +cleanup() { + kill "$guardian_pid" 2>/dev/null || true + wait "$guardian_pid" 2>/dev/null || true + rm -f "$guardian_log" "$scratch/envelopes.jsonl" "$scratch/events.jsonl" + rmdir "$scratch" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +for attempt in $(seq 1 30); do + if ! kill -0 "$guardian_pid" 2>/dev/null; then + cat "$guardian_log" + exit 1 + fi + guardian_url="$(sed -n 's#^Guardian listening at \(http://[^ ]*/acs\)$#\1#p' "$guardian_log" | tail -n 1)" + if [ -n "$guardian_url" ] && curl --fail --silent --show-error "${guardian_url%/acs}/readyz" >/dev/null; then + ACS_GUARDIAN_URL="$guardian_url" "$@" + exit $? + fi + if (( attempt == 30 )); then + cat "$guardian_log" + exit 1 + fi + sleep 1 +done diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index cdc6a862..bb2effc4 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -435,7 +435,10 @@ The audit log and the envelope log cannot be joined on `session_id`. The audit l ## The scenarios the tests check -The suite has 68 test files. Most of them drive a real Guardian, the real host shim and the pinned policy bundle. The scenarios below are grouped by what they prove. The file names are the place to read the exact assertions. +The suite has 69 test files. Most tests use a real Guardian, the host shim, and the pinned policy bundle. +The scenarios below are grouped by the behavior under test. Read each test file for the exact assertions. + +The Go Guardian replays this Guardian's recorded answers. A change to a path in `DecidingPaths` (`../agt-go/internal/tscases/tscases.go`) needs a new recording: run `scripts/record-ts-cases.sh` in `../agt-go`. If the new answers differ, the Go Guardian is changed to match them, or `../agt-go/docs/differences.md` records the difference. Recording needs Go and Bun. A change to `agt_ref` in `agt.lock` also needs the same commit in `../agt-go/agt-native.lock`, where the Go build pins the AGT it compiles. **Policy decisions over the wire** diff --git a/reference-implementations/agt/package.json b/reference-implementations/agt/package.json index 2f5f915d..f212ab61 100644 --- a/reference-implementations/agt/package.json +++ b/reference-implementations/agt/package.json @@ -9,6 +9,7 @@ "verify:pin": "bash scripts/verify-pin.sh", "verify:zero-diff": "bash scripts/verify-zero-diff.sh", "conformance": "bash scripts/run-conformance.sh", + "conformance:guardian": "bun run packages/conformance/src/main.ts", "watch:upstream": "bash scripts/run-upstream-watch.sh", "guardian": "bun run packages/guardian/src/main.ts", "inspector": "bun run packages/inspector/src/main.ts", diff --git a/reference-implementations/agt/packages/conformance/src/external-guardian.ts b/reference-implementations/agt/packages/conformance/src/external-guardian.ts new file mode 100644 index 00000000..3c8fa2a8 --- /dev/null +++ b/reference-implementations/agt/packages/conformance/src/external-guardian.ts @@ -0,0 +1,212 @@ +import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; + +export const EXTERNAL_GUARDIAN_URL_ENV = "ACS_CONFORMANCE_GUARDIAN_URL"; +export const EXTERNAL_GUARDIAN_SECRET_ENV = "ACS_CONFORMANCE_HMAC_SECRET_FILE"; +export const EXTERNAL_GUARDIAN_KEY_ID_ENV = "ACS_CONFORMANCE_HMAC_KEY_ID"; + +type JsonObject = Record; +type JsonRpcResponse = { + jsonrpc?: unknown; + id?: unknown; + result?: JsonObject; + error?: JsonObject; +}; +type Post = (url: string, body: unknown) => Promise; + +export type ExternalGuardianCheck = + | { ran: false; reason: string } + | { ran: true; probes: number }; + +export function canonicalJSON(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("JCS input contains a non-finite number"); + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJSON).join(",")}]`; + } + if (typeof value !== "object") throw new Error(`JCS input contains ${typeof value}`); + const object = value as JsonObject; + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJSON(object[key])}`).join(",")}}`; +} + +// readHMACSecret decodes a secret file's standard base64, surrounding +// whitespace ignored, to at least 32 bytes, as reference-implementations/README.md +// expects of every Guardian. +export function readHMACSecret(encoded: string): Buffer { + const text = encoded.trim(); + const secret = Buffer.from(text, "base64"); + if (secret.toString("base64") !== text) throw new Error("external Guardian HMAC secret is not standard base64"); + if (secret.length < 32) throw new Error("external Guardian HMAC secret decodes to fewer than 32 bytes"); + return secret; +} + +export function deriveSessionKey(secret: Uint8Array, sessionID: string): Buffer { + return Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), Buffer.from(sessionID, "utf8"), 32)); +} + +export function signCanonical(secret: Uint8Array, sessionID: string, canonical: string): Buffer { + return createHmac("sha256", deriveSessionKey(secret, sessionID)).update(canonical).digest(); +} + +function clone(value: T): T { + return structuredClone(value); +} + +function signRequest(envelope: JsonObject, secret: Uint8Array, sessionID: string, keyID: string): JsonObject { + const signed = clone(envelope); + const params = signed.params as JsonObject; + const canonical = canonicalJSON(signed); + params.signature = { + algorithm: "HMAC-SHA256", + value: signCanonical(secret, sessionID, canonical).toString("base64"), + key_id: keyID, + }; + return signed; +} + +function verifyResponse(response: JsonRpcResponse, secret: Uint8Array, sessionID: string, expectedID: unknown, keyID: string): void { + if (response.jsonrpc !== "2.0" || response.id !== expectedID) { + throw new Error(`external Guardian returned an uncorrelated JSON-RPC response: ${JSON.stringify(response)}`); + } + const holderName = response.error === undefined ? "result" : "error"; + const holder = response[holderName] as JsonObject | undefined; + const signature = holder?.signature as JsonObject | undefined; + if (signature?.algorithm !== "HMAC-SHA256" || signature.key_id !== keyID || typeof signature.value !== "string") { + throw new Error(`external Guardian returned no usable signature: ${JSON.stringify(response)}`); + } + const unsigned = clone(response) as JsonRpcResponse; + delete (unsigned[holderName] as JsonObject).signature; + const expected = signCanonical(secret, sessionID, canonicalJSON(unsigned)); + const received = Buffer.from(signature.value, "base64"); + if (received.length !== expected.length || !timingSafeEqual(received, expected)) { + throw new Error("external Guardian response signature does not verify"); + } +} + +async function post(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`external Guardian returned HTTP ${response.status}`); + return await response.json() as JsonRpcResponse; +} + +function envelope(method: string, sessionID: string, agentID: string, payload: JsonObject, requestID = randomUUID()): JsonObject { + return { + jsonrpc: "2.0", + method, + id: requestID, + params: { + acs_version: "0.1.0", + request_id: requestID, + timestamp: new Date().toISOString(), + metadata: { agent_id: agentID, session_id: sessionID }, + payload, + }, + }; +} + +async function signedPost(send: Post, url: string, request: JsonObject, secret: Uint8Array, sessionID: string, keyID: string): Promise { + const signed = signRequest(request, secret, sessionID, keyID); + const response = await send(url, signed); + verifyResponse(response, secret, sessionID, request.id, keyID); + return response; +} + +function expectError(response: JsonRpcResponse, code: number): void { + if (response.error?.code !== code || response.result !== undefined) { + throw new Error(`external Guardian returned ${JSON.stringify(response)}, expected error ${code}`); + } +} + +function expectDecision(response: JsonRpcResponse, disposition: string, requestID: unknown): void { + if (response.error !== undefined || response.result?.decision !== disposition || response.result.request_id !== requestID) { + throw new Error(`external Guardian returned ${JSON.stringify(response)}, expected correlated ${disposition}`); + } +} + +async function handshake(send: Post, url: string, secret: Uint8Array, keyID: string, sessionID: string, agentID: string, methods: string[]): Promise { + return signedPost(send, url, envelope("handshake/hello", sessionID, agentID, { + acs_versions_supported: ["0.1.0"], + methods_implemented: methods, + transports_supported: ["http"], + provenance_producer: "none", + profiles_supported: ["acs-core"], + }), secret, sessionID, keyID); +} + +export async function checkExternalGuardian(environment: NodeJS.ProcessEnv = process.env, send: Post = post): Promise { + const url = environment[EXTERNAL_GUARDIAN_URL_ENV]; + const secretFile = environment[EXTERNAL_GUARDIAN_SECRET_ENV]; + if (url === undefined && secretFile === undefined) { + return { ran: false, reason: `set ${EXTERNAL_GUARDIAN_URL_ENV} and ${EXTERNAL_GUARDIAN_SECRET_ENV}` }; + } + if (url === undefined || secretFile === undefined) { + throw new Error(`${EXTERNAL_GUARDIAN_URL_ENV} and ${EXTERNAL_GUARDIAN_SECRET_ENV} must be set together`); + } + const secret = readHMACSecret(readFileSync(secretFile, "utf8")); + const keyID = environment[EXTERNAL_GUARDIAN_KEY_ID_ENV]; + if (!keyID) throw new Error(`${EXTERNAL_GUARDIAN_KEY_ID_ENV} must name the key_id the Guardian is configured with`); + const agentID = "conformance-observed-agent"; + let probes = 0; + + const invalidJSONRPC = await send(url, { jsonrpc: "1.0", method: "steps/toolCallRequest", id: "bad", params: {} }); + if (invalidJSONRPC.error?.code !== -32600 || invalidJSONRPC.result !== undefined) { + throw new Error(`invalid JSON-RPC was not refused at the JSON-RPC boundary: ${JSON.stringify(invalidJSONRPC)}`); + } + probes++; + + const beforeSession = randomUUID(); + const beforeRequest = envelope("steps/toolCallRequest", beforeSession, agentID, { + tool: { name: "Bash" }, arguments: { command: { value: "ls -la" } }, raw_command: "ls -la", + }); + expectError(await signedPost(send, url, beforeRequest, secret, beforeSession, keyID), -32003); + probes++; + + const transportSession = randomUUID(); + const refusedTransport = envelope("handshake/hello", transportSession, agentID, { + acs_versions_supported: ["0.1.0"], methods_implemented: ["steps/toolCallRequest"], + transports_supported: ["stdio"], provenance_producer: "none", profiles_supported: ["acs-core"], + }); + expectError(await signedPost(send, url, refusedTransport, secret, transportSession, keyID), -32000); + probes++; + + const sessionID = randomUUID(); + const methods = ["steps/toolCallRequest", "steps/toolCallResult"]; + const hello = await handshake(send, url, secret, keyID, sessionID, agentID, methods); + if (hello.result?.negotiated_version !== "0.1.0" || !Array.isArray(hello.result.methods_evaluated)) { + throw new Error(`external Guardian returned an invalid ServerHello: ${JSON.stringify(hello)}`); + } + probes++; + + const allowed = envelope("steps/toolCallRequest", sessionID, agentID, { + tool: { name: "Bash" }, arguments: { command: { value: "ls -la" } }, raw_command: "ls -la", + }); + expectDecision(await signedPost(send, url, allowed, secret, sessionID, keyID), "allow", allowed.id); + probes++; + expectError(await signedPost(send, url, allowed, secret, sessionID, keyID), -32005); + probes++; + + const invalidPayload = envelope("steps/toolCallRequest", sessionID, agentID, { tool: { name: "Bash" } }); + expectError(await signedPost(send, url, invalidPayload, secret, sessionID, keyID), -32602); + probes++; + expectError(await signedPost(send, url, invalidPayload, secret, sessionID, keyID), -32005); + probes++; + + const unnegotiated = envelope("steps/userMessage", sessionID, agentID, { + content: [{ type: "text", value: "hello" }], + }); + expectError(await signedPost(send, url, unnegotiated, secret, sessionID, keyID), -32003); + probes++; + expectError(await signedPost(send, url, unnegotiated, secret, sessionID, keyID), -32005); + probes++; + + return { ran: true, probes }; +} diff --git a/reference-implementations/agt/packages/conformance/src/main.ts b/reference-implementations/agt/packages/conformance/src/main.ts index fa8f5f07..864a3922 100644 --- a/reference-implementations/agt/packages/conformance/src/main.ts +++ b/reference-implementations/agt/packages/conformance/src/main.ts @@ -68,6 +68,7 @@ import { mergeCells } from "./merge-cells.ts"; import { renderCoverageMatrix, renderMappingTable, renderTraceRows } from "./render.ts"; import { checkPolicyInputSchema } from "./policy-input-schema.ts"; import { resolveExitCode } from "./exit-code.ts"; +import { checkExternalGuardian } from "./external-guardian.ts"; const MANIFEST_PATH = "policy/manifest.yaml"; const MAPPING_PATH = "mapping.yaml"; @@ -147,10 +148,14 @@ export async function main(): Promise { const cells = mergeCells(n41, n42, n43, n44); const traceRows = checkTracePillar(); const schemaLeg = await checkPolicyInputSchema(bridge); + const externalGuardian = await checkExternalGuardian(); const schemaLegLine = schemaLeg.ran ? `policy-input schema (AGT's own policy-input.schema.json, at the pinned ref): RAN -- validated ${schemaLeg.points.join(", ")}` : `policy-input schema (AGT's own policy-input.schema.json, at the pinned ref): DID NOT RUN -- ${schemaLeg.reason}`; + const externalGuardianLine = externalGuardian.ran + ? `external Guardian wire checks: RAN -- ${externalGuardian.probes} probes passed` + : `external Guardian wire checks: DID NOT RUN -- ${externalGuardian.reason}`; const output = [ "=== Mapping table: what this implementation declares (from mapping.yaml) ===", @@ -168,6 +173,7 @@ export async function main(): Promise { "verdict round trip (through the Guardian's own verdict mapping): RAN", "action identity (recomputed at pre_tool_call and post_tool_call, merged): RAN", "deny fails closed (live Guardian, over the wire): RAN", + externalGuardianLine, "trace pillar: RAN", ].join("\n"); diff --git a/reference-implementations/agt/packages/conformance/test/external-guardian.test.ts b/reference-implementations/agt/packages/conformance/test/external-guardian.test.ts new file mode 100644 index 00000000..9214e69b --- /dev/null +++ b/reference-implementations/agt/packages/conformance/test/external-guardian.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { canonicalJSON, checkExternalGuardian, deriveSessionKey, signCanonical } from "../src/external-guardian.ts"; + +type Vector = { signed: Record; signing_input: string }; +type Vectors = { + ikm_hex: string; + session_id: string; + session_key_hex: string; + request: Vector; + response: Vector; +}; + +describe("the external Guardian signer", () => { + const vectors = JSON.parse(readFileSync("../agt-go/internal/envelope/testdata/vectors.json", "utf8")) as Vectors; + const secret = Buffer.from(vectors.ikm_hex, "hex"); + + it("matches the Go Guardian's independent JCS and HKDF vectors", () => { + expect(deriveSessionKey(secret, vectors.session_id).toString("hex")).toBe(vectors.session_key_hex); + for (const [holderName, vector] of [["params", vectors.request], ["result", vectors.response]] as const) { + const unsigned = structuredClone(vector.signed); + const holder = unsigned[holderName] as Record; + const signature = holder.signature as { value: string }; + delete holder.signature; + const canonical = canonicalJSON(unsigned); + expect(canonical).toBe(vector.signing_input); + expect(signCanonical(secret, vectors.session_id, canonical).toString("base64")).toBe(signature.value); + } + }); +}); + +describe("the external Guardian wire checks", () => { + it("does not require handshake restart", async () => { + const directory = mkdtempSync(join(tmpdir(), "acs-conformance-")); + const secretFile = join(directory, "hmac-secret"); + const secret = Buffer.alloc(32, 7); + writeFileSync(secretFile, `${secret.toString("base64")}\n`); + const keyID = "test-key"; + const seen = new Set(); + let acceptedHandshakes = 0; + + const signed = (request: Record, holderName: "result" | "error", holder: Record) => { + const params = request.params as Record; + const metadata = params.metadata as { session_id: string }; + const response = { jsonrpc: "2.0", id: request.id, [holderName]: holder }; + const signature = signCanonical(secret, metadata.session_id, canonicalJSON(response)).toString("base64"); + holder.signature = { algorithm: "HMAC-SHA256", key_id: keyID, value: signature }; + return response; + }; + const error = (request: Record, code: number) => signed(request, "error", { code, message: "refused" }); + + try { + const result = await checkExternalGuardian({ + ACS_CONFORMANCE_GUARDIAN_URL: "http://guardian.test", + ACS_CONFORMANCE_HMAC_SECRET_FILE: secretFile, + ACS_CONFORMANCE_HMAC_KEY_ID: keyID, + }, async (_url, body) => { + const request = body as Record; + if (request.jsonrpc !== "2.0") return { jsonrpc: "2.0", id: request.id, error: { code: -32600 } }; + if (seen.has(request.id)) return error(request, -32005); + seen.add(request.id); + + const params = request.params as Record; + const payload = params.payload as Record; + if (request.method === "handshake/hello") { + if ((payload.transports_supported as string[]).includes("stdio")) return error(request, -32000); + acceptedHandshakes++; + if (acceptedHandshakes > 1) return error(request, -32000); + return signed(request, "result", { + negotiated_version: "0.1.0", + methods_evaluated: payload.methods_implemented, + }); + } + if (request.method === "steps/userMessage") return error(request, -32003); + if (payload.arguments === undefined) return error(request, -32602); + if (acceptedHandshakes === 0) return error(request, -32003); + return signed(request, "result", { request_id: request.id, decision: "allow" }); + }); + + expect(result).toEqual({ ran: true, probes: 10 }); + expect(acceptedHandshakes).toBe(1); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +});