Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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": "85c4878c9bda9e5b4f2fc6aae242d31f",
"spanId": "dea6530ad1681c0e",
"traceId": "fec4863f765aa75f58d6b73a73fb1452",
"spanId": "c9ebadffc7ce3b81",
"name": "gen_ai.tool.call",
"kind": 1,
"startTimeUnixNano": "1791928279698000000",
"endTimeUnixNano": "1791928279749301083",
"startTimeUnixNano": "1792168061449000000",
"endTimeUnixNano": "1792168061501907988",
"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": "b169efd600a813f5bc104a46626934f436d2066dfeb30018de9e2676f9482351"
}
}
],
"name": "acs.decision",
"timeUnixNano": "1791928279749295500",
"timeUnixNano": "1792168061501896967",
"droppedAttributesCount": 0
}
],
Expand Down
90 changes: 56 additions & 34 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 @@ -1229,3 +1229,88 @@ 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();
}
});
});
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
45 changes: 35 additions & 10 deletions reference-implementations/agt/hosts/opencode/acs-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ import {
auditUngovernedStep,
createAuditSink,
createGuardianClient,
hmacKeyFromEnv,
type HmacKey,
createMemorySessionConfigStore,
DEFAULT_TIMEOUT_MS,
governStep,
Expand All @@ -129,6 +131,7 @@ import {
// and would still audit.
governsTool,
loadHookmap,
negotiateSessionConfig,
resolveSessionConfig,
toSessionUuid,
// `AuditSink`/`GuardianClient`/`SessionConfigStore` -- the three
Expand Down Expand Up @@ -1182,6 +1185,10 @@ type Deployment = {
readonly guardian: GuardianClient;
readonly audit: AuditSink;
readonly store: SessionConfigStore;
readonly hmacKey?: HmacKey;
/** Why the HMAC key could not be read, if it could not. Thrown from every
* hook rather than from the factory: see `AcsPlugin`. */
readonly hmacKeyError?: unknown;
};

/**
Expand Down Expand Up @@ -1268,6 +1275,8 @@ async function runExchange(
input: { tool: string; sessionID: string; callID: string },
assemble: (tool: string, sessionID: string) => AssembledStep,
): Promise<void> {
if (deployment.hmacKeyError !== undefined) throw deployment.hmacKeyError;

// `tool` first, ahead of the `tools` check below: a list membership test
// cannot tell a malformed `tool` from a genuinely out-of-scope one, so a
// broken host contract for `tool` has to be refused here, the same
Expand Down Expand Up @@ -1317,15 +1326,15 @@ async function runExchange(
// mixed.
const { payload, live } = assemble(input.tool, input.sessionID);

const session = await resolveSessionConfig(
{
guardian: deployment.guardian,
agentId: deployment.hookmap.host,
sessionId: toSessionUuid(input.sessionID),
timeoutMs: DEFAULT_TIMEOUT_MS,
},
deployment.store,
);
const handshake = {
guardian: deployment.guardian,
agentId: deployment.hookmap.host,
sessionId: toSessionUuid(input.sessionID),
timeoutMs: DEFAULT_TIMEOUT_MS,
hmacKey: deployment.hmacKey,
};
const session = await resolveSessionConfig(handshake, deployment.store);
const renegotiate = () => negotiateSessionConfig(handshake, deployment.store);

const governed = await governStep({
hookEventName,
Expand All @@ -1349,6 +1358,8 @@ async function runExchange(
// the actual command through. Both of this shim's gates pass through
// here, so this one line is both of its call sites.
scopedTool: input.tool,
hmacKey: deployment.hmacKey,
renegotiate,
});

applyOpenCodeOutput(governed.output, live);
Expand Down Expand Up @@ -1394,15 +1405,29 @@ export const AcsPlugin: Plugin = async () => {
// Built once, here, and handed to `runExchange` on every call -- the four
// this deployment runs on, in one object so the exchange both gates
// share can take them as one argument.
// Not thrown here, unlike the hookmap checks above: an HMAC secret set
// without its ACS_HMAC_KEY_ID is the likeliest misconfiguration after an
// upgrade, and a factory throw would unload this plugin and leave every
// tool ungoverned. Thrown from each hook instead, so every tool call is
// refused until the deployment is fixed.
let hmacKey: HmacKey | undefined;
let hmacKeyError: unknown;
try {
hmacKey = hmacKeyFromEnv();
} catch (error) {
hmacKeyError = error;
}
const deployment: Deployment = {
hookmap,
guardian: createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL),
guardian: createGuardianClient(process.env.ACS_GUARDIAN_URL ?? DEFAULT_GUARDIAN_URL, { hmacKey }),
audit: createAuditSink({ path: process.env.ACS_AUDIT_LOG ?? ".acs/audit.jsonl" }),
// In memory: one plugin object per session, so the negotiated config
// survives in a variable and the second hook of a session skips the
// handshake round trip. One interface, two implementations, and the
// adapter never learns which host is running.
store: createMemorySessionConfigStore(),
hmacKey,
hmacKeyError,
};

// Named as a const rather than returned inline, so `Object.keys` below
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,27 @@ describe("AcsPlugin's load-time gate, on the hooks it actually registers", () =>
}
});

// A secret without its label must not unload the plugin: OpenCode would
// run every tool ungoverned. It registers, and every tool call is refused.
it("registers and refuses every tool when ACS_HMAC_SECRET is set without ACS_HMAC_KEY_ID", async () => {
const previous = { secret: process.env.ACS_HMAC_SECRET, keyId: process.env.ACS_HMAC_KEY_ID };
process.env.ACS_HMAC_SECRET = Buffer.alloc(32, 0x73).toString("base64");
delete process.env.ACS_HMAC_KEY_ID;
try {
const hooks = await AcsPlugin({} as never);
const before = hooks["tool.execute.before"];
expect(before).toBeDefined();
await expect(
before!({ tool: "bash", sessionID: "s-1", callID: "c-1" }, { args: { command: "ls" } }),
).rejects.toThrow(/ACS_HMAC_KEY_ID/);
} finally {
for (const [name, value] of [["ACS_HMAC_SECRET", previous.secret], ["ACS_HMAC_KEY_ID", previous.keyId]] as const) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
});

// Direction one: a rename applied to the hook METHOD and not to the table.
// This is the shape an upstream rename takes while it is half-applied, and
// it is the shape that used to register silently -- OpenCode would have
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ describe("a request-gate modify the hookmap gives no way to land -- the measured
// claims would silently become false.
//
// The reason code is the half that says why it is over-blocking and not
// governance: `runtime_error:tool_unknown`, from `agt_stock`. The name on
// governance: ACS-Core's `tool_unregistered` classification. The name on
// the wire is the command, so this deny is the tool registry refusing a
// tool it does not know -- this deployment's own `rm -rf /` rule was
// never consulted, because the envelope never said `bash`. A plain
Expand All @@ -648,7 +648,7 @@ describe("a request-gate modify the hookmap gives no way to land -- the measured
expect({
decision: governed.decision?.decision,
reasonCodes: (governed.decision as { reason_codes?: string[] } | null)?.reason_codes,
}).toEqual({ decision: "deny", reasonCodes: ["runtime_error:tool_unknown"] });
}).toEqual({ decision: "deny", reasonCodes: ["tool_unregistered"] });
});

// A declared decision name the tables do not know is silently unchecked,
Expand Down
Loading
Loading