Skip to content
Draft
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
4 changes: 4 additions & 0 deletions reference-implementations/agt/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,11 @@ async function answering<T>(decision: Record<string, unknown>, body: (url: strin
const stub = Bun.serve({
port: 0,
async fetch(req) {
const rpc = (await req.json()) as { id: string | number; method: string };
const rpc = (await req.json()) as {
id: string | number;
method: string;
params: { request_id: string };
};
if (rpc.method === "handshake/hello") {
return Response.json({
jsonrpc: "2.0",
Expand All @@ -180,7 +184,16 @@ async function answering<T>(decision: Record<string, unknown>, body: (url: strin
},
});
}
return Response.json({ jsonrpc: "2.0", id: rpc.id, result: decision });
return Response.json({
jsonrpc: "2.0",
id: rpc.id,
result: {
type: "final",
acs_version: "0.1.0",
request_id: rpc.params.request_id,
...decision,
},
});
},
});
try {
Expand Down Expand Up @@ -357,7 +370,11 @@ describe("the result gate, end to end through the real shim and a real Guardian"
{
decision: "ask",
reasoning: "a human should see this output first",
ask_details: { approver: "security-team", question: "release this output?", timeout_seconds: 300 },
ask_details: {
approver: { type: "human", id: "security-team" },
question: "release this output?",
timeout_seconds: 300,
},
},
(url) => runHook(postToolUsePayload("TOKEN=ghp_ABCDEF123456"), url),
);
Expand Down Expand Up @@ -401,8 +418,8 @@ describe("the result gate, end to end through the real shim and a real Guardian"
decision: "defer",
reasoning: "waiting on an out-of-band approval",
defer_details: {
reason: "awaiting change ticket",
resolution_method: "external",
reason: "pending_dependency",
resolution_method: "human_approval",
resolution_timeout_ms: 300_000,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe("acs-hook — the negotiated posture, end to end", () => {
expect(audit).toHaveLength(1);
expect(audit[0]).toMatchObject({ posture: "deny", outcome: "blocked" });
});
it("honours a deny that arrives alongside an error, rather than answering with the posture", async () => {
it("fails closed when a response carries both a result and an error", async () => {
const dir = scratch();
const stub = Bun.serve({
port: 0,
Expand Down Expand Up @@ -316,29 +316,51 @@ describe("acs-hook — the negotiated posture, end to end", () => {
});
const hook = expectQuietDecision(out);
expect(hook.permissionDecision).toBe("deny");
expect(hook.permissionDecisionReason).toBe("blocked by policy");
// A decision arrived, so nothing was a delivery failure and nothing is
// audited as one.
expect(existsSync(join(dir, "audit.jsonl"))).toBe(false);
expect(hook.permissionDecisionReason).toContain("guardian returned an invalid response");
const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
expect(audit).toHaveLength(1);
expect(audit[0]).toMatchObject({
posture: "proceed",
outcome: "blocked",
failure: { kind: "invalid_response" },
});
} finally {
stub.stop(true);
}
});

it("still exits 0 with a decision when the Guardian returns a decision this hookmap cannot render", async () => {
it("still applies the posture when a schema-valid decision cannot be rendered by the hookmap", async () => {
const dir = scratch();
// A stub, not a real Guardian: answers handshake/hello honestly (so
// this hook negotiates a real "proceed" posture), then answers
// steps/toolCallRequest with a decision no hookmap entry names.
// validateDecision passes an unrecognised decision through unchanged,
// so an unguarded renderDecision would throw after the shim's last
// try/catch, main().catch would exit 1 with empty stdout, and Claude
// Code would proceed -- ungoverned and unaudited. Same shape as every
// other fail-open this project has found.
const hookmapPath = join(dir, "request-without-defer.yaml");
writeFileSync(
hookmapPath,
"host: claude-code\n" +
"hooks:\n" +
" PreToolUse:\n" +
" acs_method: steps/toolCallRequest\n" +
" tool_name: $.tool_name\n" +
" arguments: $.tool_input\n" +
" decisions:\n" +
" allow:\n" +
" output:\n" +
" hookSpecificOutput.permissionDecision: { value: allow }\n" +
" hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string }\n" +
" deny:\n" +
" output:\n" +
" hookSpecificOutput.permissionDecision: { value: deny }\n" +
" hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string }\n",
);
// The response is canonical and the decision is known to ACS. This
// deployment's deliberately limited hookmap has no `defer` entry, so the
// failure remains a host rendering fault after response validation lands.
const stub = Bun.serve({
port: 0,
async fetch(req) {
const body = (await req.json()) as { id: string | number; method: string };
const body = (await req.json()) as {
id: string | number;
method: string;
params: { request_id: string };
};
if (body.method === "handshake/hello") {
return Response.json({
jsonrpc: "2.0",
Expand All @@ -352,35 +374,43 @@ describe("acs-hook — the negotiated posture, end to end", () => {
},
});
}
return Response.json({ jsonrpc: "2.0", id: body.id, result: { decision: "quarantine" } });
return Response.json({
jsonrpc: "2.0",
id: body.id,
result: {
type: "final",
acs_version: "0.1.0",
request_id: body.params.request_id,
decision: "defer",
reasoning: "waiting on a dependency",
defer_details: {
reason: "pending_dependency",
resolution_method: "human_approval",
resolution_timeout_ms: 300_000,
},
},
});
},
});
try {
const out = await runShim(payload("ls -la"), {
ACS_GUARDIAN_URL: `http://localhost:${stub.port}/acs`,
ACS_SESSION_DIR: join(dir, "sessions"),
ACS_AUDIT_LOG: join(dir, "audit.jsonl"),
ACS_HOOKMAP_PATH: hookmapPath,
});
expect(out.stdout.length).toBeGreaterThan(0);
const hook = expectQuietDecision(out);
// The negotiated posture was "proceed", so the undeliverable decision
// resolves to a plain allow, and it is audited like any other
// fail-open proceed (§6.4's MUST).
expect(hook.permissionDecision).toBe("allow");
const audit = readFileSync(join(dir, "audit.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
expect(audit).toHaveLength(1);
expect(audit[0]).toMatchObject({ posture: "proceed", outcome: "proceeded" });
// A decision did arrive here and was honoured in principle -- only this
// host's rendering of it failed. Auditing that as a delivery failure
// ("no decision arrived from the guardian", kind "unknown") would send
// an incident reviewer to look at a Guardian that answered correctly --
// the same misattribution `host_configuration` guards against one step
// earlier in the exchange.
expect(audit[0].failure.kind).toBe("decision_unrenderable");
expect(JSON.parse(out.stdout).hookSpecificOutput.permissionDecisionReason)
.toMatch(/a decision arrived from the guardian .* and was honoured/i);
} finally {
stub.stop(true);
unlinkSync(hookmapPath);
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ async function renderedBy(decision: Record<string, unknown>, command: string): P
const stub = Bun.serve({
port: 0,
async fetch(req) {
const body = (await req.json()) as { id: string | number; method: string };
const body = (await req.json()) as {
id: string | number;
method: string;
params: { request_id: string };
};
if (body.method === "handshake/hello") {
return Response.json({
jsonrpc: "2.0",
Expand All @@ -101,7 +105,16 @@ async function renderedBy(decision: Record<string, unknown>, command: string): P
},
});
}
return Response.json({ jsonrpc: "2.0", id: body.id, result: decision });
return Response.json({
jsonrpc: "2.0",
id: body.id,
result: {
type: "final",
acs_version: "0.1.0",
request_id: body.params.request_id,
...decision,
},
});
},
});
try {
Expand Down Expand Up @@ -198,7 +211,11 @@ describe("the wire shape this host writes to stdout, pinned decision by decision
{
decision: "ask",
reasoning: "this needs a human",
ask_details: { approver: "security-team", question: "run this?", timeout_seconds: 300 },
ask_details: {
approver: { type: "human", id: "security-team" },
question: "run this?",
timeout_seconds: 300,
},
},
"curl example.com",
),
Expand All @@ -225,8 +242,8 @@ describe("the wire shape this host writes to stdout, pinned decision by decision
decision: "defer",
reasoning: "waiting on an out-of-band approval",
defer_details: {
reason: "awaiting change ticket",
resolution_method: "external",
reason: "pending_dependency",
resolution_method: "human_approval",
resolution_timeout_ms: 300_000,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"ajv": "8.20.0",
"ajv-formats": "3.0.1"
},
"devDependencies": {
"guardian": "workspace:*"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,12 @@ type PostureResolvedAuditEntry = AuditEntryIdentity & {
*
* Derived from the posture in exactly one place (failure-posture.ts's
* RESOLUTION_BY_POSTURE), so the two can never disagree about a step --
* with one exception, which is the one combination worth knowing how to
* read here. A step the Guardian REFUSED (`failure.kind: "refused"`) is
* `blocked` whatever the posture said, because the posture answers a
* decision that did not arrive and a refusal is one that was withheld. So
* `posture: "proceed"` beside `outcome: "blocked"` is a refusal, always:
* the only other route from a `proceed` posture to a blocked step is the
* unauditable-proceed downgrade, and that one denies precisely because the
* write failed, so it leaves no entry to read.
* with two answered-response exceptions. A Guardian refusal
* (`failure.kind: "refused"`) and a schema-invalid Guardian response
* (`failure.kind: "invalid_response"`) are blocked whatever the posture
* said. The kind distinguishes them. The only other route from a `proceed`
* posture to a blocked step is the unauditable-proceed downgrade, which
* leaves no entry because the write itself failed.
*/
outcome: "proceeded" | "blocked";
/** The posture in force -- negotiated, or the ACS default when nothing was. */
Expand All @@ -121,10 +119,10 @@ type PostureResolvedAuditEntry = AuditEntryIdentity & {
*
* `kind` carries the taxonomy rather than a bare `string`.
* `applyFailurePosture` goes to some trouble to tell a delivery failure
* from a refusal and from a host-side fault -- `DeliveryFailureKind` was
* deliberately narrowed for it, `RefusalFailureKind` exists because a
* Guardian that answered "no" is not a Guardian that failed to answer, and
* `HostFailureKind` exists so a host misconfiguration is not filed as an
* from an answered-response failure and from a host-side fault --
* `DeliveryFailureKind` was deliberately narrowed, the Guardian-response
* kinds distinguish refusal from invalid structure, and `HostFailureKind`
* exists so a host misconfiguration is not filed as an
* unknown delivery failure -- and this is the only boundary that outlives
* the process, so a widening here is where all of that would have been
* lost. It is also the only place an incident review ever reads: a value
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* The failure taxonomies one step's audit entry can name -- what went wrong on
* the wire, what the Guardian refused, what went wrong on this side of the
* wire, and what went wrong while establishing the session -- plus the stage
* of the exchange each belongs to.
* the wire, what the Guardian refused or answered incorrectly, what went wrong
* on this side of the wire, and what went wrong while establishing the session
* -- plus the stage of the exchange each belongs to.
*
* A module of its own, so the writer and the classifier share one
* declaration. `failure-posture.ts` builds these values and `audit-sink.ts`
Expand All @@ -14,11 +14,10 @@
* role), so the shared vocabulary lives here, the same way
* `decision-message.ts` holds the decision every module on this side speaks.
*
* These name failures of the wire, refusals by the Guardian, and faults of
* this host. No policy-runtime vocabulary: a delivery failure is a property
* of the wire, and a refusal is a property of the Guardian's willingness to
* answer this envelope at all -- neither names a rule, a verdict, or
* anything else belonging to whatever evaluates policy on the other side.
* These name failures of the wire, refusals or invalid responses from the
* Guardian, and faults of this host. No policy-runtime vocabulary: a delivery
* failure is a property of the wire, while a refusal and an invalid response
* are properties of what the Guardian returned. None names a rule or verdict.
*
* The wire/refusal split is the load-bearing one, and it did not exist at
* first: every no-decision outcome was a `DeliveryFailureKind`, so a
Expand All @@ -36,10 +35,10 @@
* Deliberately narrow, in two directions. `host_configuration` and
* `decision_unrenderable` are not delivery failures at all -- both are
* precisely-known, entirely host-side causes -- so including them here would
* mean `failure.kind` presents them under a delivery-shaped name. And
* `refused` is not one either: something answered, so the wire worked. A type
* that lies is a lie the type system then teaches every reader. The first two
* live in `HostFailureKind` below, the third in `RefusalFailureKind`.
* mean `failure.kind` presents them under a delivery-shaped name. Neither
* `refused` nor `invalid_response` belongs here either: something answered, so
* the wire worked. The first two live in `HostFailureKind` below; the latter
* two have their own Guardian-response kinds.
*
* `error_without_decision` is what is left of the JSON-RPC-error case once
* the refusals are taken out of it: an error whose code this host does not
Expand Down Expand Up @@ -72,7 +71,12 @@ export type HostFailureKind =
* guardian" tells an incident reviewer to go and look at a Guardian that
* answered correctly, when the fault is in this host's own rendering table.
*/
| "decision_unrenderable";
| "decision_unrenderable"
/**
* A response arrived, but this host could not run the canonical validator.
* This says nothing about whether the Guardian's response was valid.
*/
| "response_validation_unavailable";

/**
* What the Guardian did when it was ALIVE and would not answer this envelope
Expand All @@ -96,16 +100,20 @@ export type HostFailureKind =
*/
export type RefusalFailureKind = "refused";

/** A Guardian answered, but the response failed the canonical ACS schema. */
export type InvalidResponseFailureKind = "invalid_response";

/**
* Everything `AuditEntry.failure.kind` can name about one step: the wire's
* failures, the Guardian's refusals, and this host's own faults.
* failures, the Guardian's refusals and invalid responses, and this host's
* own faults.
*
* A union rather than one widened `DeliveryFailureKind`, so the honest half
* stays honest. A reader asking "what can a delivery failure be?" gets four
* answers; a reader asking "what can an audit entry say about a step?" gets
* seven; and neither question is answered with the other one's list.
* nine; and neither question is answered with the other one's list.
*/
export type StepFailureKind = DeliveryFailureKind | RefusalFailureKind | HostFailureKind;
export type StepFailureKind = DeliveryFailureKind | RefusalFailureKind | InvalidResponseFailureKind | HostFailureKind;

/**
* WHERE in the exchange the failure happened. Three materially different
Expand Down
Loading
Loading