diff --git a/README.md b/README.md index 9b1fb48..77f655f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A contestable, checkable public record for humans and AI. | [Protocol Spec](./docs/protocol-spec.md) | Object types, schemas, state machines, transition rules | | [Policy](./docs/policy.md) | Tunable parameters — budgets, thresholds, timing | | [Technical Architecture](./docs/tech-architecture.md) | Implementation: what to build, how, and why | +| [Agent Shell Supervisor Receipt Example](./examples/agent-shell-supervisor/) | Supervisor-side receipt fixture for `google/agent-shell-tools#29` trust-boundary discussion | ## Status diff --git a/examples/agent-shell-supervisor/README.md b/examples/agent-shell-supervisor/README.md new file mode 100644 index 0000000..74de776 --- /dev/null +++ b/examples/agent-shell-supervisor/README.md @@ -0,0 +1,67 @@ +# Agent Shell Supervisor Receipt Example + +This example responds narrowly to the trust-boundary question in `google/agent-shell-tools#29`. + +## Core position + +`grpc_execd` is not the trusted observer. + +In the `Agent outside the sandbox` composition, the agent may be able to run arbitrary commands through `grpc_execd`, and those commands may affect `grpc_execd` itself. A value returned by `grpc_execd` is therefore self-reported correlation data, not trustworthy third-party evidence. + +The environment commitment should be reported by the component that owns or manages the execution environment: `wsb`, a wrapper that launches `wsb`, or a separate trust domain that can observe the request and response from outside `grpc_execd`. + +The safer composition is: + +1. `grpc_exec` stays minimal and returns execution results. +2. The environment owner computes the environment commitment. +3. A supervisor or observer signs a receipt that binds the command digest, working-directory digest, environment commitment, response digest, exit code, and observer identity. + +## Useful trust states + +| Context | Meaning | +| --- | --- | +| `self-reported` | The process that returned the value also reports the commitment. Useful for local correlation only. | +| `supervisor-attested` | A separate local supervisor observed the request and response and signed the receipt. Useful for stronger local audit. | +| `third-party-attested` | A separate trust domain observed or verified the event and signed the receipt. Useful for external verification. | + +## Receipt boundary + +The supervisor receipt binds: + +| Field | Purpose | +| --- | --- | +| `command.digest` | Commits to the command and arguments. | +| `working_directory.digest` | Commits to the execution directory identity without exporting the full tree. | +| `environment.commitment` | Commits to an allowlisted environment snapshot created by the environment owner. | +| `environment.context` | States whether the commitment is self-reported, supervisor-attested, or third-party-attested. | +| `environment.owner` | Identifies the component that owns or manages the execution environment. | +| `response.digest` | Commits to stdout, stderr, exit code, and response metadata. | +| `observer.id` | Identifies the supervisor or observer. | +| `signature` | Signs the receipt from outside the controlled process. | + +## Why not put this directly in `ExecuteResponse` + +A raw `environment_commitment` in `ExecuteResponse` is easy to misread as proof. In the `Agent outside the sandbox` composition, it is only a statement from the process being controlled. + +That does not make the value useless. It can still be useful for local correlation. It just should not be represented as evidence unless the receipt also says who observed it and which trust boundary produced it. + +For Google Agent Shell, the cleanest design appears to be a sidecar receipt emitted by `wsb`, a `wsb` launcher wrapper, or another supervisor. The receipt can live beside the `grpc_exec` response without expanding the `grpc_exec` response shape. + +## Files + +- `receipt.schema.json`: JSON schema for the supervisor receipt shape. +- `make_sample.go`: deterministic sample generator using Go stdlib Ed25519. +- `sample-execute-receipt.json`: generated receipt with a real signature over deterministic canonical JSON. +- `REPLY-agent-shell-tools-29.md`: concise reply text for the GitHub issue. + +## Regenerate sample + +```bash +go run make_sample.go +``` + +Then run the repository-level example verifier: + +```bash +npm run verify:examples +``` diff --git a/examples/agent-shell-supervisor/REPLY-agent-shell-tools-29.md b/examples/agent-shell-supervisor/REPLY-agent-shell-tools-29.md new file mode 100644 index 0000000..c5cead7 --- /dev/null +++ b/examples/agent-shell-supervisor/REPLY-agent-shell-tools-29.md @@ -0,0 +1,13 @@ +That makes sense to me. I agree that `grpc_execd` is the wrong trust boundary for this. + +The clean split seems to be: + +1. `grpc_exec` returns execution results and stays minimal. +2. The environment owner, for example `//wsb` or a wrapper that launches/manages `//wsb`, computes the environment commitment. +3. A separate observer signs a receipt binding the command digest, response digest, exit code, environment commitment, and observer identity. + +I tightened the example here: https://github.com/VeritasActa/Acta/pull/2 + +I will not pursue an `ExecuteResponse` field based on this discussion. If useful later, I can turn the sketch into a small `wsb`-side example or wrapper that emits a sidecar receipt, but I agree that it should not come from `grpc_execd` itself. + +Happy to close this issue as out of scope for `grpc_exec`, or leave it open as a marker for a possible future `wsb`/supervisor-side experiment, whichever you prefer. diff --git a/examples/agent-shell-supervisor/make_sample.go b/examples/agent-shell-supervisor/make_sample.go new file mode 100644 index 0000000..13064a9 --- /dev/null +++ b/examples/agent-shell-supervisor/make_sample.go @@ -0,0 +1,122 @@ +package main + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sort" + "strings" +) + +func digest(v any) string { + b := []byte(canonical(v)) + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func canonical(v any) string { + switch x := v.(type) { + case map[string]any: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + kb, _ := json.Marshal(k) + parts = append(parts, string(kb)+":"+canonical(x[k])) + } + return "{" + strings.Join(parts, ",") + "}" + case []any: + parts := make([]string, 0, len(x)) + for _, item := range x { + parts = append(parts, canonical(item)) + } + return "[" + strings.Join(parts, ",") + "]" + default: + b, _ := json.Marshal(v) + return string(b) + } +} + +func withoutFields(r map[string]any, fields ...string) map[string]any { + omit := map[string]bool{} + for _, f := range fields { + omit[f] = true + } + out := make(map[string]any, len(r)) + for k, v := range r { + if omit[k] { + continue + } + out[k] = v + } + return out +} + +func main() { + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = byte(i + 1) + } + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + + command := map[string]any{ + "argv": []any{"python", "-m", "pytest", "tests/test_policy.py"}, + "digest": digest(map[string]any{"argv": []any{"python", "-m", "pytest", "tests/test_policy.py"}}), + } + workdir := map[string]any{ + "path_hint": "/workspace/project", + "digest": digest(map[string]any{"git_commit": "abc123", "path": "/workspace/project"}), + } + env := map[string]any{ + "commitment": digest(map[string]any{"PATH": "/usr/bin:/bin", "PYTHONPATH": "tests"}), + "context": "supervisor-attested", + "owner": "wsb-launcher.local", + "allowlist": []any{"PATH", "PYTHONPATH"}, + } + response := map[string]any{ + "exit_code": 0, + "digest": digest(map[string]any{"stdout": "2 passed", "stderr": "", "exit_code": 0}), + } + + receipt := map[string]any{ + "acta_version": "0.1", + "receipt_type": "acta:exec-observation", + "subject": "grpc_exec.ExecuteResponse", + "observed_at": "2026-04-30T00:00:00Z", + "command": command, + "working_directory": workdir, + "environment": env, + "response": response, + "observer": map[string]any{ + "id": "agent-shell-supervisor.local", + "trust_domain": "local-supervisor", + }, + } + receipt["receipt_id"] = digest(receipt) + + signable := withoutFields(receipt, "signature") + sig := ed25519.Sign(priv, []byte(canonical(signable))) + receipt["signature"] = map[string]any{ + "alg": "Ed25519", + "kid": "did:key:example-agent-shell-supervisor", + "public_key": hex.EncodeToString(pub), + "sig": hex.EncodeToString(sig), + } + + out, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + panic(err) + } + out = append(out, '\n') + if err := os.WriteFile("sample-execute-receipt.json", out, 0o644); err != nil { + panic(err) + } + fmt.Println("wrote sample-execute-receipt.json") +} diff --git a/examples/agent-shell-supervisor/receipt.schema.json b/examples/agent-shell-supervisor/receipt.schema.json new file mode 100644 index 0000000..85e4974 --- /dev/null +++ b/examples/agent-shell-supervisor/receipt.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/agent-shell-supervisor-receipt.schema.json", + "title": "Agent Shell Supervisor Execution Receipt", + "type": "object", + "required": [ + "acta_version", + "receipt_type", + "receipt_id", + "subject", + "observed_at", + "command", + "working_directory", + "environment", + "response", + "observer", + "signature" + ], + "properties": { + "acta_version": { "type": "string" }, + "receipt_type": { "const": "acta:exec-observation" }, + "receipt_id": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "subject": { "const": "grpc_exec.ExecuteResponse" }, + "observed_at": { "type": "string", "format": "date-time" }, + "command": { + "type": "object", + "required": ["argv", "digest"], + "properties": { + "argv": { "type": "array", "items": { "type": "string" } }, + "digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "additionalProperties": false + }, + "working_directory": { + "type": "object", + "required": ["path_hint", "digest"], + "properties": { + "path_hint": { "type": "string" }, + "digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "additionalProperties": false + }, + "environment": { + "type": "object", + "required": ["commitment", "context", "owner", "allowlist"], + "properties": { + "commitment": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "context": { "enum": ["self-reported", "supervisor-attested", "third-party-attested"] }, + "owner": { "type": "string", "minLength": 1 }, + "allowlist": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "response": { + "type": "object", + "required": ["exit_code", "digest"], + "properties": { + "exit_code": { "type": "integer" }, + "digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "additionalProperties": false + }, + "observer": { + "type": "object", + "required": ["id", "trust_domain"], + "properties": { + "id": { "type": "string" }, + "trust_domain": { "type": "string" } + }, + "additionalProperties": false + }, + "signature": { + "type": "object", + "required": ["alg", "kid", "public_key", "sig"], + "properties": { + "alg": { "const": "Ed25519" }, + "kid": { "type": "string" }, + "public_key": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "sig": { "type": "string", "pattern": "^[a-f0-9]{128}$" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/examples/agent-shell-supervisor/sample-execute-receipt.json b/examples/agent-shell-supervisor/sample-execute-receipt.json new file mode 100644 index 0000000..b4256ed --- /dev/null +++ b/examples/agent-shell-supervisor/sample-execute-receipt.json @@ -0,0 +1,43 @@ +{ + "acta_version": "0.1", + "command": { + "argv": [ + "python", + "-m", + "pytest", + "tests/test_policy.py" + ], + "digest": "sha256:d8ad885e20e03f5d1180eb01b4bdfe2075eecec31232daed47b6ee758a90acaf" + }, + "environment": { + "allowlist": [ + "PATH", + "PYTHONPATH" + ], + "commitment": "sha256:ec9c819d304bd0e353373645303d9d5fc29cb9bf584a68d48a2657e04b49222d", + "context": "supervisor-attested", + "owner": "wsb-launcher.local" + }, + "observed_at": "2026-04-30T00:00:00Z", + "observer": { + "id": "agent-shell-supervisor.local", + "trust_domain": "local-supervisor" + }, + "receipt_id": "sha256:191f26ad301c198e3e1e9c3dca74aaabbd0010fb5efbd1a8517f91ffddf77b84", + "receipt_type": "acta:exec-observation", + "response": { + "digest": "sha256:0aca7e06260b79b4f15791b83f17775f1538d9fcfb4c08b56718a653e185ec93", + "exit_code": 0 + }, + "signature": { + "alg": "Ed25519", + "kid": "did:key:example-agent-shell-supervisor", + "public_key": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "sig": "c8126d965ebb0b179b65e17d10d388c0e4320ca708dc43cba2676d4e69d4d5ddaf60adaee5d5d0773bc724ce4d8c58556f2becf463c98dbb2bd519045f27c106" + }, + "subject": "grpc_exec.ExecuteResponse", + "working_directory": { + "digest": "sha256:74b92206b71369e383d267a2c7f1031549d4568771aacc44db4c41993ccdc59e", + "path_hint": "/workspace/project" + } +} diff --git a/package.json b/package.json index 5eeb1e6..dd2f4a1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "test": "vitest", - "verify": "node tools/verify.js" + "verify": "node tools/verify.js", + "verify:examples": "node tools/verify-examples.js" }, "dependencies": { "@noble/curves": "^1.9.7" diff --git a/tools/verify-examples.js b/tools/verify-examples.js new file mode 100644 index 0000000..1f88be0 --- /dev/null +++ b/tools/verify-examples.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * Verify repository examples without trusting the generator code. + */ + +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { ed25519 } from '@noble/curves/ed25519'; + +function sortKeysDeep(value) { + if (value === null || value === undefined) return value; + if (typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(sortKeysDeep); + return Object.keys(value).sort().reduce((acc, key) => { + acc[key] = sortKeysDeep(value[key]); + return acc; + }, {}); +} + +function canonical(value) { + return JSON.stringify(sortKeysDeep(value)); +} + +function sha256(value) { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function digest(value) { + return `sha256:${sha256(canonical(value))}`; +} + +function withoutFields(value, fields) { + const omit = new Set(fields); + return Object.fromEntries(Object.entries(value).filter(([key]) => !omit.has(key))); +} + +function hexToBytes(hex) { + if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) { + throw new Error(`invalid hex string: ${hex}`); + } + return Uint8Array.from(Buffer.from(hex, 'hex')); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +function verifySignature({ message, signatureHex, publicKeyHex, label }) { + const ok = ed25519.verify(hexToBytes(signatureHex), new TextEncoder().encode(message), hexToBytes(publicKeyHex)); + assert(ok, `${label}: signature verification failed`); +} + +async function verifyAgentShellSupervisor() { + const path = 'examples/agent-shell-supervisor/sample-execute-receipt.json'; + const receipt = await readJson(path); + + const idPayload = withoutFields(receipt, ['receipt_id', 'signature']); + assert(receipt.receipt_id === digest(idPayload), `${path}: receipt_id mismatch`); + assert(receipt.subject === 'grpc_exec.ExecuteResponse', `${path}: unexpected subject`); + assert(receipt.environment?.context === 'supervisor-attested', `${path}: unexpected environment context`); + assert(receipt.environment?.owner === 'wsb-launcher.local', `${path}: unexpected environment owner`); + + const signable = withoutFields(receipt, ['signature']); + verifySignature({ + message: canonical(signable), + signatureHex: receipt.signature.sig, + publicKeyHex: receipt.signature.public_key, + label: path, + }); + + console.log(`ok ${path}`); +} + +async function main() { + await verifyAgentShellSupervisor(); + console.log('ok all example receipts verified'); +} + +main().catch((err) => { + console.error(err.message); + process.exitCode = 1; +});