Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b7f02e1
fix(agt): implement ACS-Core wire safeguards
jrcrittenden Oct 6, 2026
a08542c
fix(agt): address review on ACS-Core wire safeguards
jrcrittenden Oct 7, 2026
2b5f081
fix(agt): enforce the negotiated handshake on hook traffic
jrcrittenden Oct 7, 2026
7437f3e
fix(agt): refuse with -32000 when the replay cache is full
jrcrittenden Oct 12, 2026
b5c976e
fix(agt): keep the signing block total and label the evaluator
jrcrittenden Oct 12, 2026
7a02825
fix(agt): answer an uncanonicalisable signed request with -32004
jrcrittenden Oct 13, 2026
3e17980
fix(agt): fail closed on an unsigned refusal or an unsignable request
jrcrittenden Oct 14, 2026
7f171b1
fix(agt): evaluate the MCP message that runs, and only string targets
jrcrittenden Oct 14, 2026
9a0bff8
fix(agt): treat every server-error code as a refusal
jrcrittenden Oct 15, 2026
d1f8838
fix(agt): handshake the trace exporter's tests and sample, and list t…
jrcrittenden Oct 16, 2026
bc68cdf
docs(trace): copy the regenerated denied span into the OpenTelemetry …
jrcrittenden Oct 16, 2026
8fdaf5c
fix(agt): name the HMAC secret by a configured key_id
jrcrittenden Oct 21, 2026
db532c8
Merge branch 'integration' into fix/acs-core-conformance-d1-d10
jrcrittenden Oct 21, 2026
f94d393
Merge branch 'integration' into fix/acs-core-conformance-d1-d10
jrcrittenden Oct 22, 2026
9d3efa3
Merge branch 'integration' into fix/acs-core-conformance-d1-d10
jrcrittenden Oct 28, 2026
eb1aeb1
fix(agt): block a reply that fails verification, and keep a rejected …
jrcrittenden Oct 31, 2026
1b8c79a
fix(agt): treat -32600 as a refusal
jrcrittenden Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions docs/spec/trace/extend_opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ This hierarchy is deterministic — implementations that follow the mapping prod

This is one span the reference Guardian exported, a `steps/toolCallRequest` the stock policy denied, copied from the committed [sample trace](https://github.com/GenAI-Security-Project/agent-control-standard/blob/main/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl). Every value in it is synthetic. It's the OTLP/JSON shape the exporter writes, so attribute values are typed (`stringValue`, `arrayValue`).

The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool name, and the two Provenance columns. `acs.provenance.source_id` has `""` where the second object has no `source_id`. The decision rides on the span as the `acs.decision` event, with the verdict, the reasoning, the reason codes and the three policy arrays. `acs.policy.versions` is `[""]`, because this Guardian doesn't send a policy version.
The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool name, and the two Provenance columns. `acs.provenance.source_id` has `""` where the second object has no `source_id`. The decision rides on the span as the `acs.decision` event, with the verdict, the evaluator, the reasoning, the reason codes, the three policy arrays and the chain hash. `acs.policy.versions` is `[""]`, because this Guardian doesn't send a policy version.

```json
{
"traceId": "13717f291e2368066d2fa3f2434ed2f6",
"spanId": "2a7efedbb7ee3881",
"traceId": "667339a876d6d9de3d7265afd85109cf",
"spanId": "c1511753cf6602c6",
"name": "gen_ai.tool.call",
"kind": 1,
"startTimeUnixNano": "1792534253716796645",
"endTimeUnixNano": "1792534253780000000",
"startTimeUnixNano": "1793214917878555408",
"endTimeUnixNano": "1793214917942000000",
"attributes": [
{
"key": "acs.session.id",
Expand Down Expand Up @@ -90,6 +90,12 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool
"stringValue": "deny"
}
},
{
"key": "acs.evaluator",
"value": {
"stringValue": "deterministic"
}
},
{
"key": "acs.reasoning",
"value": {
Expand Down Expand Up @@ -143,10 +149,16 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool
]
}
}
},
{
"key": "acs.chain_hash",
"value": {
"stringValue": "3aa3e605a2715d3e17e85e36969fdfdfdd5e15a942e66d696c54d6eb3fc2b15c"
}
}
],
"name": "acs.decision",
"timeUnixNano": "1792534253780000000",
"timeUnixNano": "1793214917942000000",
"droppedAttributesCount": 0
}
],
Expand Down
91 changes: 56 additions & 35 deletions reference-implementations/agt/README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion reference-implementations/agt/hosts/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Get started

1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command.
1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` and `ACS_HMAC_KEY_ID` values in the terminal that starts Claude Code.

2. Register the hook in the project you run Claude Code in.

Expand Down Expand Up @@ -42,6 +42,8 @@ The first hook of a session sends `handshake/hello` and stores the Guardian's an
| `ACS_SESSION_DIR` | `.acs/sessions` | Where the negotiated ServerHello is stored, one file per session |
| `ACS_AUDIT_LOG` | `.acs/audit.jsonl` | Where fail-open proceeds and posture-driven blocks are written |
| `ACS_HOOKMAP_PATH` | `claude-code.hookmap.yaml`, beside the shim | Which hookmap to load. This changes what governance means for the host. Leave it unset in a deployment |
| `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs ClientHello and step requests and verifies non-ping responses. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core |
| `ACS_HMAC_KEY_ID` | required with `ACS_HMAC_SECRET` | The same label supplied to the Guardian; names the secret as `key_id` in every request and must match the label on every verified response. Setting only one of the two is a configuration error |

## Exit codes

Expand Down
25 changes: 14 additions & 11 deletions reference-implementations/agt/hosts/claude-code/acs-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ import {
createAuditSink,
createFileSessionConfigStore,
createGuardianClient,
hmacKeyFromEnv,
DEFAULT_TIMEOUT_MS,
governStep,
loadHookmap,
negotiateSessionConfig,
resolveSessionConfig,
toSessionUuid,
withholdsAtResultGate,
Expand Down Expand Up @@ -533,7 +535,8 @@ async function main(): Promise<void> {
const audit = createAuditSink({ path: process.env.ACS_AUDIT_LOG ?? ".acs/audit.jsonl" });

const guardianUrl = process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL;
const guardian = createGuardianClient(guardianUrl);
const hmacKey = hmacKeyFromEnv();
const guardian = createGuardianClient(guardianUrl, { hmacKey });

// Step 4: this session's negotiated config, and whatever went wrong getting
// it -- one call, and this shim is told both rather than running the
Expand All @@ -554,15 +557,15 @@ async function main(): Promise<void> {
// without a bound, a Guardian that accepts the connection and never answers
// would hang this hook until Claude Code's own hook timeout kills the
// process.
const session = await resolveSessionConfig(
{
guardian,
agentId: hookmap.host,
sessionId: toSessionUuid(sessionId),
timeoutMs: DEFAULT_TIMEOUT_MS,
},
store,
);
const handshake = {
guardian,
agentId: hookmap.host,
sessionId: toSessionUuid(sessionId),
timeoutMs: DEFAULT_TIMEOUT_MS,
hmacKey,
};
const session = await resolveSessionConfig(handshake, store);
const renegotiate = () => negotiateSessionConfig(handshake, store);

// Step 5: resolve the attempt. One call, one answer, and this shim branches
// on nothing: `governStep` builds the ACS request, asks the Guardian for a
Expand All @@ -574,7 +577,7 @@ async function main(): Promise<void> {
// rules produce for it, is stated once inside `governStep`, host-agnostically,
// so a second host inherits it by calling the function rather than
// reimplementing it.
const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit });
const governed = await governStep({ hookEventName, payload, hookmap, guardian, session, sessionId, audit, hmacKey, renegotiate });

process.stdout.write(JSON.stringify(asClaudeCodeOutput(governed.output, hookEventName)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFile
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { startGuardian } from "guardian";
import { deriveSessionKey, signResponseHmacSha256, startGuardian } from "guardian";
// The adapter's own rule for which dispositions withhold at a result gate,
// read rather than restated: a copy here could pass a shipped hookmap the
// shim's gate would refuse, or miss one it would accept.
Expand Down Expand Up @@ -1229,3 +1229,242 @@ describe("acs-hook — the negotiated posture, end to end", () => {
}
});
});

/**
* Turning HMAC on must never turn a deny into an allow. The Guardian cannot
* sign a refusal it issues before any session key exists, so these pin what
* the host DECIDES when such a refusal arrives unsigned, not merely which
* error the client raised on the way.
*/
describe("acs-hook — an unsigned refusal under HMAC still refuses", () => {
const GUARDIAN_KEY = Buffer.alloc(32, 0x61).toString("base64");

it("withholds a keyed tool result over the Guardian's 1 MiB request cap, as guardian_refused", async () => {
const dir = scratch();
const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY, hmacKeyId: "test-key" });
try {
const env = {
ACS_GUARDIAN_URL: guardian.url,
ACS_SESSION_DIR: join(dir, "sessions"),
ACS_AUDIT_LOG: join(dir, "audit.jsonl"),
ACS_HMAC_SECRET: GUARDIAN_KEY,
ACS_HMAC_KEY_ID: "test-key",
};
const secret = "TOKEN=ghp_ABCDEF123456\n";
const out = await runShim(resultPayload(secret + "x".repeat(2 * 1024 * 1024)), env);
const decision = JSON.parse(out.stdout) as Record<string, unknown>;
expect(decision.decision).toBe("block");
expect(out.stdout).not.toContain("ghp_ABCDEF123456");
const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
expect(audit.at(-1)).toMatchObject({ outcome: "blocked", failure: { kind: "refused" } });
expect(audit.at(-1).reason_codes ?? []).not.toContain("on_decision_failure_proceed");
} finally {
await guardian.close();
}
});

it("denies a keyed tool call whose input is too deep to sign, under the proceed posture", async () => {
const dir = scratch();
const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: GUARDIAN_KEY, hmacKeyId: "test-key" });
try {
const deep = "[".repeat(20_000) + "]".repeat(20_000);
const stdin = JSON.stringify({
session_id: "sess-1",
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command: "ls -la" },
}).replace('"ls -la"}', `"ls -la","extra":${deep}}`);
const out = await runShim(stdin, {
ACS_GUARDIAN_URL: guardian.url,
ACS_SESSION_DIR: join(dir, "sessions"),
ACS_AUDIT_LOG: join(dir, "audit.jsonl"),
ACS_HMAC_SECRET: GUARDIAN_KEY,
ACS_HMAC_KEY_ID: "test-key",
});
expect(expectQuietDecision(out).permissionDecision).toBe("deny");
const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
expect(audit.at(-1)).toMatchObject({ posture: "proceed", outcome: "blocked", failure: { kind: "unsignable" } });
} finally {
await guardian.close();
}
});

it("denies rm -rf / from a host whose key does not match a deny-posture Guardian's", async () => {
const dir = scratch();
const guardian = await startGuardian({
port: 0,
manifestPath: MANIFEST,
hmacSecret: GUARDIAN_KEY,
hmacKeyId: "test-key",
onDecisionFailure: "deny",
});
try {
const out = await runShim(payload("rm -rf /"), {
ACS_GUARDIAN_URL: guardian.url,
ACS_SESSION_DIR: join(dir, "sessions"),
ACS_AUDIT_LOG: join(dir, "audit.jsonl"),
ACS_HMAC_SECRET: Buffer.alloc(32, 0x62).toString("base64"),
ACS_HMAC_KEY_ID: "test-key",
});
expect(expectQuietDecision(out).permissionDecision).toBe("deny");
const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
expect(audit.at(-1)).toMatchObject({ outcome: "blocked", failure: { kind: "refused" } });
} finally {
await guardian.close();
}
});
});

type Rpc = Record<string, any>;

/**
* A proxy between the shim and a real Guardian that rewrites the Guardian's
* replies, the way an on-path attacker could. `tamper` sees each request and
* the reply it got, and returns the reply to send on, or a Response to send
* instead. Close it with `stop(true)`.
*/
function tamperingProxy(target: string, tamper: (request: Rpc, reply: Rpc) => Rpc | Response) {
return Bun.serve({
port: 0,
async fetch(req) {
const body = await req.text();
const request = JSON.parse(body) as Rpc;
const upstream = await fetch(target, { method: "POST", headers: { "content-type": "application/json" }, body });
const out = tamper(request, (await upstream.json()) as Rpc);
return out instanceof Response ? out : Response.json(out);
},
});
}

/** Re-signs `reply` with `secret`, as the Guardian holding it would. */
function resign(reply: Rpc, request: Rpc, secret: string): Rpc {
const carrier = reply.result ?? reply.error;
delete carrier.signature;
const key = deriveSessionKey(Buffer.from(secret, "base64"), request.params.metadata.session_id);
carrier.signature = { algorithm: "HMAC-SHA256", key_id: "test-key", value: signResponseHmacSha256(reply, key) };
return reply;
}

const isHello = (request: Rpc) => request.method === "handshake/hello";

// Issue #308: a reply that fails verification blocks the step whatever the
// posture, and is recorded as signature_invalid. Each case runs under the
// shipped `proceed`, on a command the policy allows, so only that rule can
// turn it into a deny.
describe("acs-hook — a reply that fails verification blocks the step under proceed", () => {
const KEY = Buffer.alloc(32, 0x61).toString("base64");
const env = (dir: string, url: string) => ({
ACS_GUARDIAN_URL: url,
ACS_SESSION_DIR: join(dir, "sessions"),
ACS_AUDIT_LOG: join(dir, "audit.jsonl"),
ACS_HMAC_SECRET: KEY,
ACS_HMAC_KEY_ID: "test-key",
});
const lastAudit = (dir: string) =>
readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).at(-1);

async function viaProxy(tamper: (request: Rpc, reply: Rpc) => Rpc | Response, stdin = payload("ls -la")) {
const dir = scratch();
const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: KEY, hmacKeyId: "test-key" });
const proxy = tamperingProxy(guardian.url, tamper);
try {
const out = await runShim(stdin, env(dir, `http://localhost:${proxy.port}/acs`));
return { decision: expectQuietDecision(out).permissionDecision, audit: lastAudit(dir) };
} finally {
proxy.stop(true);
await guardian.close();
}
}

it("blocks an allow whose signature was stripped", async () => {
const { decision, audit } = await viaProxy((request, reply) => {
if (!isHello(request)) delete reply.result.signature;
return reply;
});
expect(decision).toBe("deny");
expect(audit).toMatchObject({ posture: "proceed", outcome: "blocked", failure: { kind: "signature_invalid" } });
});

it("blocks an allow signed with a key other than the host's", async () => {
const { decision, audit } = await viaProxy((request, reply) =>
isHello(request) ? reply : resign(reply, request, Buffer.alloc(32, 0x62).toString("base64")),
);
expect(decision).toBe("deny");
expect(audit).toMatchObject({ outcome: "blocked", failure: { kind: "signature_invalid" } });
});

it("blocks an earlier signed allow replayed as the answer to a later step", async () => {
let captured: Rpc | undefined;
const tamper = (request: Rpc, reply: Rpc) => {
if (isHello(request)) return reply;
captured ??= structuredClone(reply);
return structuredClone(captured);
};
const dir = scratch();
const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: KEY, hmacKeyId: "test-key" });
const proxy = tamperingProxy(guardian.url, tamper);
try {
const url = `http://localhost:${proxy.port}/acs`;
expect(expectQuietDecision(await runShim(payload("ls -la"), env(dir, url))).permissionDecision).toBe("allow");
const second = await runShim(payload("rm -rf /"), env(dir, url));
expect(expectQuietDecision(second).permissionDecision).toBe("deny");
expect(lastAudit(dir)).toMatchObject({ outcome: "blocked", failure: { kind: "signature_invalid" } });
} finally {
proxy.stop(true);
await guardian.close();
}
});

it("blocks an unsigned allow from a Guardian that holds no key", async () => {
const dir = scratch();
const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST });
try {
const out = await runShim(payload("ls -la"), env(dir, guardian.url));
expect(expectQuietDecision(out).permissionDecision).toBe("deny");
expect(lastAudit(dir)).toMatchObject({ outcome: "blocked", failure: { kind: "signature_invalid" } });
} finally {
await guardian.close();
}
});
});

// A hello this host rejects, or cannot verify, must not downgrade a
// declared deny to the ACS default proceed. The step's reply is replaced
// with a gateway error, so the posture is what decides.
describe("acs-hook — a rejected or unverifiable hello keeps the step from proceeding", () => {
const KEY = Buffer.alloc(32, 0x61).toString("base64");
const badGateway = () => new Response("bad gateway", { status: 502 });

async function stepUnderPosture(guardian: { url: string; close(): Promise<void> }, tamperHello: (request: Rpc, reply: Rpc) => Rpc) {
const dir = scratch();
const proxy = tamperingProxy(guardian.url, (request, reply) => (isHello(request) ? tamperHello(request, reply) : badGateway()));
try {
const out = await runShim(payload("ls -la"), {
ACS_GUARDIAN_URL: `http://localhost:${proxy.port}/acs`,
ACS_SESSION_DIR: join(dir, "sessions"),
ACS_AUDIT_LOG: join(dir, "audit.jsonl"),
ACS_HMAC_SECRET: KEY,
ACS_HMAC_KEY_ID: "test-key",
});
return expectQuietDecision(out).permissionDecision;
} finally {
proxy.stop(true);
await guardian.close();
}
}

it("keeps a deny-posture Guardian's posture when its signed hello lacks acs-core", async () => {
const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, hmacSecret: KEY, hmacKeyId: "test-key", onDecisionFailure: "deny" });
const decision = await stepUnderPosture(guardian, (request, reply) => {
reply.result.profiles_accepted = [];
return resign(reply, request, KEY);
});
expect(decision).toBe("deny");
});

it("blocks a keyed host's step when the deny-posture Guardian holds no key, so its hello is unsigned", async () => {
const guardian = await startGuardian({ port: 0, manifestPath: MANIFEST, onDecisionFailure: "deny" });
expect(await stepUnderPosture(guardian, (_request, reply) => reply)).toBe("deny");
});
});

4 changes: 3 additions & 1 deletion reference-implementations/agt/hosts/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Get started

1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command.
1. Start the Guardian from `reference-implementations/agt`. The [tree README](../../README.md#start-the-guardian) shows the command. Export the same `ACS_HMAC_SECRET` and `ACS_HMAC_KEY_ID` values in the terminal that starts OpenCode.

2. Point OpenCode at the plugin. The `opencode.json` at the root of this tree already does, with a relative path, so an `opencode` started from `reference-implementations/agt` needs no edit. From any other project, add the plugin to that project's `opencode.json` by absolute path.

Expand Down Expand Up @@ -39,6 +39,8 @@ Because OpenCode has no field that reads a reason back after the tool has run, t
| `ACS_AUDIT_LOG` | `.acs/audit.jsonl` | Where fail-open proceeds, posture-driven blocks and ungoverned skips are written |
| `ACS_HOOKMAP_PATH` | `opencode.hookmap.yaml`, beside the plugin | Which hookmap to load. Leave it unset in a deployment |
| `ACS_DEBUG` | unset | Prints each decision's reason on stderr |
| `ACS_HMAC_SECRET` | required with the standalone Guardian | The same base64-encoded secret supplied to the Guardian; signs ClientHello and step requests and verifies non-ping responses. Omission is compatibility mode for an explicitly unsecured in-process Guardian, not ACS-Core |
| `ACS_HMAC_KEY_ID` | required with `ACS_HMAC_SECRET` | The same label supplied to the Guardian; names the secret as `key_id` in every request and must match the label on every verified response. Setting only one of the two is a configuration error |

There is no session directory. The plugin lives for the whole session and keeps the negotiated ServerHello in memory, so only the first hook of a session sends `handshake/hello`.

Expand Down
Loading
Loading