diff --git a/reference-implementations/agt/bun.lock b/reference-implementations/agt/bun.lock index cc2dc5a0..c137d744 100644 --- a/reference-implementations/agt/bun.lock +++ b/reference-implementations/agt/bun.lock @@ -65,6 +65,10 @@ "packages/host-adapter": { "name": "host-adapter", "version": "0.1.0", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + }, "devDependencies": { "guardian": "workspace:*", }, diff --git a/reference-implementations/agt/hosts/claude-code/test/post-tool-use.test.ts b/reference-implementations/agt/hosts/claude-code/test/post-tool-use.test.ts index 2e02d2a6..d3ed4c7f 100644 --- a/reference-implementations/agt/hosts/claude-code/test/post-tool-use.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/post-tool-use.test.ts @@ -166,7 +166,11 @@ async function answering(decision: Record, 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", @@ -180,7 +184,16 @@ async function answering(decision: Record, 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 { @@ -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), ); @@ -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, }, }, diff --git a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts index 3b8b5971..e371fa70 100644 --- a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts @@ -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, @@ -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", @@ -352,7 +374,22 @@ 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 { @@ -360,27 +397,20 @@ describe("acs-hook — the negotiated posture, end to end", () => { 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); } }); diff --git a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts index 38cf37d3..836d16ec 100644 --- a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts @@ -87,7 +87,11 @@ async function renderedBy(decision: Record, 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", @@ -101,7 +105,16 @@ async function renderedBy(decision: Record, 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 { @@ -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", ), @@ -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, }, }, diff --git a/reference-implementations/agt/packages/host-adapter/package.json b/reference-implementations/agt/packages/host-adapter/package.json index 2b426330..93016962 100644 --- a/reference-implementations/agt/packages/host-adapter/package.json +++ b/reference-implementations/agt/packages/host-adapter/package.json @@ -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:*" } diff --git a/reference-implementations/agt/packages/host-adapter/src/audit-sink.ts b/reference-implementations/agt/packages/host-adapter/src/audit-sink.ts index f12cf67d..80c0111f 100644 --- a/reference-implementations/agt/packages/host-adapter/src/audit-sink.ts +++ b/reference-implementations/agt/packages/host-adapter/src/audit-sink.ts @@ -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. */ @@ -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 diff --git a/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts b/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts index da9561d2..49979eb5 100644 --- a/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts +++ b/reference-implementations/agt/packages/host-adapter/src/failure-kinds.ts @@ -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` @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts index 9de89c63..d665fad1 100644 --- a/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts +++ b/reference-implementations/agt/packages/host-adapter/src/failure-posture.ts @@ -9,16 +9,11 @@ * same way -- apply the deployment's declared posture -- and that every step * which proceeds without a decision MUST be audited. * - * With ONE exception, which is not §6.4's case at all, and which is why this - * module now reads the failure before it reads the posture: a JSON-RPC error - * whose code means the Guardian was alive and REFUSED this envelope. §6.4 is - * about a decision that failed to arrive; a refusal is a decision withheld, - * by the very component this host defers to. So a refusal resolves to `deny` - * regardless of posture, and is still audited. Without that, a live Guardian - * answering `-32020 evaluation failed` and a dead socket were the same kind, - * and under the shipped default posture (`proceed`) both became `allow` -- - * a governance tool proceeding on the Guardian's own "no". See - * REFUSAL_RPC_CODES for which codes mean that, and why the set is enumerated. + * Two answered-response cases are not §6.4 delivery failures, so this module + * reads the failure before the posture: a JSON-RPC refusal and a response that + * fails the canonical ACS schema. The Guardian answered in both cases, but no + * valid decision exists. Both resolve to `deny` regardless of posture and are + * audited under distinct failure kinds. * * It is NOT reached when a decision arrived. A `deny` that arrives is * honoured regardless of posture, and that is enforced by the caller never @@ -36,14 +31,15 @@ * with a write it was told did not happen. * * Nothing here knows the policy runtime behind the wire. A delivery failure - * is a property of the wire, and a refusal is a property of the Guardian's - * willingness to answer this envelope -- neither is a property of whatever - * evaluates policy on the other side of it, and nothing here names one. + * is a property of the wire; a refusal or invalid response is a property of + * what the Guardian returned. None is a property of the policy runtime. */ import type { AuditEvent, AuditSink } from "./audit-sink.ts"; import type { DeliveryFailureKind, FailureStage, + HostFailureKind, + InvalidResponseFailureKind, RefusalFailureKind, SessionFailureKind, StepFailureKind, @@ -51,6 +47,11 @@ import type { import type { AcsDecision } from "./decision-message.ts"; import { GuardianTimeoutError } from "./guardian-client.ts"; import { SessionConfigNotStoredError, type ResolvedSessionConfig } from "./handshake.ts"; +import { + GuardianDecisionResponseError, + GuardianResponseValidationError, + GuardianResponseValidatorError, +} from "./validate-response.ts"; // The failure taxonomies this module classifies into live in // `./failure-kinds.ts`, shared with the audit sink that stores them, and are @@ -60,6 +61,7 @@ export type { DeliveryFailureKind, FailureStage, HostFailureKind, + InvalidResponseFailureKind, RefusalFailureKind, SessionFailureKind, StepFailureKind, @@ -183,11 +185,20 @@ const REFUSAL_RPC_CODES = new Set([-32700, -32010, -32011, -32020]); */ export function classifyDeliveryFailure( failure: unknown, -): { kind: DeliveryFailureKind | RefusalFailureKind; message: string } { +): { + kind: DeliveryFailureKind | RefusalFailureKind | InvalidResponseFailureKind | HostFailureKind; + message: string; +} { try { if (failure instanceof GuardianTimeoutError) { return { kind: "timeout", message: failure.message }; } + if (failure instanceof GuardianResponseValidationError || failure instanceof GuardianDecisionResponseError) { + return { kind: "invalid_response", message: failure.message }; + } + if (failure instanceof GuardianResponseValidatorError) { + return { kind: "response_validation_unavailable", message: failure.message }; + } if (failure instanceof Error) { // Two independent routes to "transport", because no single one is // reliable across runtimes: fetch does not always throw a TypeError @@ -310,15 +321,17 @@ export function applyFailurePosture({ }: ApplyFailurePostureInput): FailureResolvedAcsDecision { const { config: sessionConfig, failure: sessionFailure } = session; const posture = sessionConfig?.on_decision_failure ?? DEFAULT_POSTURE; - // Classified BEFORE the resolution is read, because for one kind it decides - // the resolution. Everything else is the wire's business and the posture's; - // a refusal is the Guardian's, and a governance tool does not proceed on it - // -- see REFUSAL_RPC_CODES and REFUSAL_RESOLUTION. + // Classified BEFORE the resolution is read, because two answered-response + // kinds decide it. A refusal and an invalid response both came from a live + // Guardian, and a governance tool does not proceed on either. const classified = classifyStepFailure(stage, failure); const refused = classified.kind === "refused"; + const invalidResponse = classified.kind === "invalid_response"; + const validationUnavailable = classified.kind === "response_validation_unavailable"; + const failClosed = refused || invalidResponse; // One resolution, read once, in the three vocabularies it is expressed in -- // see RESOLUTION_BY_POSTURE. - const { decision, outcome } = refused ? REFUSAL_RESOLUTION : RESOLUTION_BY_POSTURE[posture]; + const { decision, outcome } = failClosed ? FAIL_CLOSED_RESOLUTION : RESOLUTION_BY_POSTURE[posture]; // Computed before the audit write, and written into it: "the guardian was // down for this whole session" (default) and "this deployment chose to @@ -369,10 +382,16 @@ export function applyFailurePosture({ // (github.com/afogel/ACS_reference_implementation, docs/demos/v3-runbook.md), reproduced // against a live Guardian. // It is not to be reworded without re-capturing them. - const cause = refused - ? `the guardian refused the envelope for ${method ?? "this step"} rather than deciding it ` + - `(${classified.kind}: ${classified.message})` - : stage === "request" + const cause = failClosed + ? invalidResponse + ? `the guardian returned an invalid response for ${method ?? "this step"} ` + + `(${classified.kind}: ${classified.message})` + : `the guardian refused the envelope for ${method ?? "this step"} rather than deciding it ` + + `(${classified.kind}: ${classified.message})` + : validationUnavailable + ? `this host could not validate the response for ${method ?? "this step"} ` + + `(${classified.kind}: ${classified.message})` + : stage === "request" ? `this host could not build a request for this step, so no decision was ever sought ` + `(${classified.kind}: ${classified.message})` : stage === "render" @@ -388,22 +407,21 @@ export function applyFailurePosture({ // Returns before both of the two exits below, because both of them speak // about `on_decision_failure` -- what this deployment declared should happen - // when a decision does not arrive -- and a refusal is a decision withheld, - // not one lost. Saying "on_decision_failure=proceed, so this step was + // when no response arrives. A refusal or invalid response is an answer, not + // a lost delivery. Saying "on_decision_failure=proceed, so this step was // blocked" would read as a posture contradicting itself; saying it was // blocked for want of an audit entry would name the wrong reason entirely. // It is still audited -- the write above already happened -- and an - // unauditable refusal needs no downgrade for the same reason an unauditable - // deny does not: the step is blocked either way, and the failed write is - // reported by the sink's own error path. - if (refused) { + // unauditable fail-closed outcome needs no downgrade: the step is blocked + // either way, and the failed write is reported by the sink's own error path. + if (failClosed) { return { decision, reasoning: `${cause};${sessionNote} the guardian was reachable and answered, so this is not a delivery failure ` + - `and on_decision_failure=${posture} does not apply -- a step the guardian would not decide is not a ` + - "step this host may proceed with, so it was blocked.", - reason_codes: [REFUSAL_REASON_CODE], + `and on_decision_failure=${posture} does not apply -- a step without a valid guardian decision is not ` + + "a step this host may proceed with, so it was blocked.", + reason_codes: [invalidResponse ? INVALID_RESPONSE_REASON_CODE : REFUSAL_REASON_CODE], }; } @@ -430,7 +448,7 @@ export function applyFailurePosture({ return { decision, reasoning: `${cause};${sessionNote} ${postureOrigin} -- on_decision_failure=${posture}, so this step was ${outcome}.`, - reason_codes: [REASON_CODE_BY_STAGE[stage]], + reason_codes: [validationUnavailable ? "response_validation_unavailable" : REASON_CODE_BY_STAGE[stage]], }; } @@ -455,9 +473,9 @@ export function applyFailurePosture({ * word no posture can ever resolve to, admitted into the one table whose job * is that the posture, the decision and the outcome agree by construction. * - * Consulted for every failure except a refusal, which REFUSAL_RESOLUTION - * answers instead -- there is no posture row for it, because the deployment - * never got to declare one. + * Consulted for delivery and host failures. Refusals and invalid responses use + * FAIL_CLOSED_RESOLUTION instead because they are answered-response failures, + * outside the posture's declaration about missing delivery. */ type PostureResolvedAuditEvent = Extract; @@ -470,26 +488,21 @@ const RESOLUTION_BY_POSTURE: Record< }; /** - * A refusal's resolution: the one that is not read out of a posture. + * The resolution for an answered response that cannot supply a valid decision. * * `blocked` rather than a fourth outcome word, because `AuditEntry.outcome` * reports what became of the step and the step was blocked -- inventing - * `refused` there would give the Inspector a badge stem to learn for a fact - * the entry already carries twice over, in `failure.kind` and in the code - * inside `failure.message`. A refusal-driven block stays distinguishable from - * a posture-driven one in the log without that: `failure.kind` names it - * outright, and `posture: "proceed"` beside `outcome: "blocked"` is a pair no - * posture-driven entry can produce (the unauditable-proceed downgrade denies - * only when the write failed, which is precisely when no entry exists). + * A new outcome word would give the Inspector another badge stem for a fact + * already carried by `failure.kind`. These blocks stay distinguishable from + * posture-driven ones because the kind is `refused` or `invalid_response`. * * Shaped like a RESOLUTION_BY_POSTURE row, and read off the same * `PostureResolvedAuditEvent` arm, so the two cannot drift into disagreeing * about what a `blocked` step is. The ARM and not `AuditEvent` whole, for the * reason RESOLUTION_BY_POSTURE gives: the union's other member carries - * `"ungoverned"`, and a refusal is a step this deployment governed and - * blocked -- the one thing it is certainly not is ungoverned. + * `"ungoverned"`; these are steps this deployment governed and blocked. */ -const REFUSAL_RESOLUTION: { decision: "allow" | "deny"; outcome: PostureResolvedAuditEvent["outcome"] } = { +const FAIL_CLOSED_RESOLUTION: { decision: "allow" | "deny"; outcome: PostureResolvedAuditEvent["outcome"] } = { decision: "deny", outcome: "blocked", }; @@ -506,6 +519,8 @@ const REASON_CODE_BY_STAGE: Record = { * stage, and `decision_failure` there would tell a machine reader that no * decision arrived when the Guardian's refusal is exactly what did. */ const REFUSAL_REASON_CODE = "guardian_refused"; +/** A schema-invalid response has its own machine-readable reason. */ +const INVALID_RESPONSE_REASON_CODE = "guardian_response_invalid"; /** * The classified `{kind, message}` for one step, at the stage that failed -- diff --git a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts index 2695d66e..13c4a967 100644 --- a/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts +++ b/reference-implementations/agt/packages/host-adapter/src/guardian-client.ts @@ -47,6 +47,11 @@ */ import type { AcsRequestEnvelope } from "./build-envelope.ts"; import type { AcsDecision } from "./decision-message.ts"; +import { + GuardianDecisionResponseError, + validateGuardianDecisionResult, + validateGuardianResponse, +} from "./validate-response.ts"; /** * The minimal JSON-RPC 2.0 request shape this client sends -- the transport @@ -141,7 +146,8 @@ export type PostOptions = { * discriminated by the only question that matters at this seam: did a decision * arrive? * - * - `decisionArrived: true` -- one did. What the Guardian sent, unvalidated. + * - `decisionArrived: true` -- one did, after the response envelope and its + * method-specific AcsResult passed the canonical schema. * - `decisionArrived: false` -- none did, and `failure` is whatever stands * in its place: the throw from the wire, the JSON-RPC `error` object the * Guardian answered with, or (for a response carrying neither) an Error @@ -262,6 +268,16 @@ export function createGuardianClient(url: string): GuardianClient { let response: JsonRpcResponse; try { response = await post(envelope, options); + // `post` also serves handshake/hello, whose result is not a decision. + // Validate here, at the one boundary that turns a Guardian result into + // an AcsDecision, so every decision disposition and its conditional + // fields come from response-envelope.json rather than a hand-written + // subset. A schema failure travels as a no-decision failure and is + // classified fail closed by applyFailurePosture. + validateGuardianResponse(response); + if (response.result !== undefined) { + validateGuardianDecisionResult(response.result); + } } catch (failure) { // A timeout, a dead transport, an uncorrelated response, a body that is // not JSON. @@ -272,16 +288,24 @@ export function createGuardianClient(url: string): GuardianClient { return { decisionArrived: false, failure }; } - // An arriving decision is checked for FIRST. A JSON-RPC response - // carrying both `error` and `result` is malformed per JSON-RPC, but if - // the `result` names a decision then a decision did arrive, and an - // arriving `deny` must be honoured -- answering it with a failure - // instead would let a delivery-failure rule overrule a policy decision. + // Schema validation above guarantees that exactly one of `result` and + // `error` is present, and that every decision result carries the fields + // its disposition requires. This check narrows the validated response; + // it no longer decides validity by itself. const arrived = response.result as AcsDecision | undefined; - if (typeof arrived?.decision === "string") { + if (arrived?.type === "final" && typeof arrived.decision === "string") { return { decisionArrived: true, decision: arrived }; } + // Defensive fallback: method-aware validation above already requires a + // defined result to satisfy the complete AcsResult schema. + if (response.result !== undefined) { + return { + decisionArrived: false, + failure: new GuardianDecisionResponseError("/", "result carried no string decision"), + }; + } + // Anything with no decision in it is a failure of this exchange, and the // `error` is used as the failure when there is one -- unwrapped, the // JSON-RPC error object itself, because its `code` is what tells the diff --git a/reference-implementations/agt/packages/host-adapter/src/index.ts b/reference-implementations/agt/packages/host-adapter/src/index.ts index 43c2ce40..c6aa39f4 100644 --- a/reference-implementations/agt/packages/host-adapter/src/index.ts +++ b/reference-implementations/agt/packages/host-adapter/src/index.ts @@ -104,6 +104,7 @@ export { type FailureResolvedAcsDecision, type FailureStage, type HostFailureKind, + type InvalidResponseFailureKind, type SessionFailureKind, type StepFailureKind, } from "./failure-posture.ts"; @@ -127,6 +128,13 @@ export { type HostOutputLocation, } from "./result-output.ts"; export { validateDecision, type ValidateDecisionContext } from "./validate-decision.ts"; +export { + GuardianDecisionResponseError, + GuardianResponseValidationError, + GuardianResponseValidatorError, + validateGuardianDecisionResult, + validateGuardianResponse, +} from "./validate-response.ts"; // The one shared surface for JavaScript's prototype-machinery names, and // the value-tree walker that checks a rendered value against them at any // depth -- exported so a host applier (hosts/opencode/apply-opencode-output.ts diff --git a/reference-implementations/agt/packages/host-adapter/src/validate-response.ts b/reference-implementations/agt/packages/host-adapter/src/validate-response.ts new file mode 100644 index 00000000..11c22a1f --- /dev/null +++ b/reference-implementations/agt/packages/host-adapter/src/validate-response.ts @@ -0,0 +1,134 @@ +/** + * Validates a Guardian response before the host accepts its result as an ACS + * decision. The schema is the repository's response-envelope.json. Every + * v0.1.0 schema is registered first so its relative references resolve from + * the canonical `$id` graph. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import type { ErrorObject } from "ajv"; + +const SCHEMA_ROOT = fileURLToPath(new URL("../../../../../specification/v0.1.0/", import.meta.url)); +const SCHEMA_BASE = "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/"; +const RESPONSE_ENVELOPE_SCHEMA_ID = `${SCHEMA_BASE}response-envelope.json`; +const ACS_RESULT_SCHEMA_ID = `${RESPONSE_ENVELOPE_SCHEMA_ID}#/$defs/AcsResult`; + +function listSchemaFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listSchemaFiles(full)); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + out.push(full); + } + } + return out; +} + +function buildValidators() { + const ajv = new Ajv2020({ strict: true, allErrors: false, strictRequired: false, allowUnionTypes: true }); + addFormats(ajv); + const schemaIds = new Set(); + for (const file of listSchemaFiles(SCHEMA_ROOT)) { + const schema = JSON.parse(readFileSync(file, "utf8")) as { $id?: string }; + if (schema.$id) { + if (schemaIds.has(schema.$id)) { + throw new Error(`duplicate schema id: ${schema.$id}`); + } + schemaIds.add(schema.$id); + ajv.addSchema(schema); + } + } + const responseEnvelope = ajv.getSchema(RESPONSE_ENVELOPE_SCHEMA_ID); + if (!responseEnvelope) { + throw new Error(`response schema is not registered: ${RESPONSE_ENVELOPE_SCHEMA_ID}`); + } + const acsResult = ajv.getSchema(ACS_RESULT_SCHEMA_ID); + if (!acsResult) { + throw new Error(`decision result schema is not registered: ${ACS_RESULT_SCHEMA_ID}`); + } + return { responseEnvelope, acsResult }; +} + +let validators: ReturnType | undefined; + +function pointerOf(error: ErrorObject | undefined): string { + if (!error) return "/"; + if (error.keyword === "required") { + const missing = (error.params as { missingProperty: string }).missingProperty; + return `${error.instancePath}/${missing}`; + } + return error.instancePath || "/"; +} + +/** A Guardian answered, but its response does not satisfy the ACS contract. */ +export class GuardianResponseValidationError extends Error { + readonly pointer: string; + + constructor(pointer: string, detail: string) { + super(`Guardian response failed response-envelope.json at ${pointer}: ${detail}`); + this.name = "GuardianResponseValidationError"; + this.pointer = pointer; + } +} + +/** The host could not run its local canonical-schema validator. */ +export class GuardianResponseValidatorError extends Error { + constructor(detail: string) { + super(`Host could not validate the Guardian response: ${detail}`); + this.name = "GuardianResponseValidatorError"; + } +} + +/** The envelope is canonical, but its result is not valid for a decision method. */ +export class GuardianDecisionResponseError extends Error { + readonly pointer: string; + + constructor(pointer: string, detail: string) { + super(`Guardian response to a decision method failed the AcsResult contract at ${pointer}: ${detail}`); + this.name = "GuardianDecisionResponseError"; + this.pointer = pointer; + } +} + +function getValidators(): ReturnType { + try { + return validators ?? (validators = buildValidators()); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new GuardianResponseValidatorError(detail); + } +} + +function runValidator(validate: ReturnType["responseEnvelope"], value: unknown): boolean { + let valid: boolean; + try { + valid = validate(value) as boolean; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new GuardianResponseValidatorError(detail); + } + return valid; +} + +/** Throws when `response` is not a canonical ACS response envelope. */ +export function validateGuardianResponse(response: unknown): void { + const validate = getValidators().responseEnvelope; + if (runValidator(validate, response)) return; + + const first = validate.errors?.[0]; + throw new GuardianResponseValidationError(pointerOf(first), first?.message ?? "invalid response"); +} + +/** Throws when a canonical response's result is not an AcsResult for a decision method. */ +export function validateGuardianDecisionResult(result: unknown): void { + const validate = getValidators().acsResult; + if (runValidator(validate, result)) return; + + const first = validate.errors?.[0]; + throw new GuardianDecisionResponseError(pointerOf(first), first?.message ?? "invalid decision result"); +} diff --git a/reference-implementations/agt/packages/host-adapter/test/client.test.ts b/reference-implementations/agt/packages/host-adapter/test/client.test.ts index 299219c3..3c0e2625 100644 --- a/reference-implementations/agt/packages/host-adapter/test/client.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/client.test.ts @@ -21,6 +21,7 @@ import { } from "../src/handshake.ts"; import { renderDecision } from "../src/render-decision.ts"; import { createMemorySessionConfigStore } from "../src/session-config.ts"; +import { GuardianDecisionResponseError, GuardianResponseValidationError } from "../src/validate-response.ts"; const hookmap: Hookmap = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml"); @@ -35,6 +36,22 @@ function preToolUsePayload(command: string): Record { }; } +function decisionResponse( + envelope: ReturnType, + decision: Record, +): Record { + return { + jsonrpc: "2.0", + id: envelope.id, + result: { + type: "final", + acs_version: "0.1.0", + request_id: envelope.params.request_id, + ...decision, + }, + }; +} + let guardian: StartedGuardian; beforeAll(async () => { @@ -295,18 +312,122 @@ describe("GuardianClient.requestDecision", () => { expect(outcome.decisionArrived).toBe(false); }); - it("honours a decision that arrives alongside a malformed `error`, never letting the failure outrank it", async () => { - // A response carrying both is malformed per JSON-RPC, but a decision is in - // it -- and a caller that checked `error` first would answer a real deny - // with a delivery-failure path instead of honouring it. + it("accepts a schema-valid deny and preserves its required reasoning", async () => { const mock = Bun.serve({ port: 0, async fetch(req) { - const body = (await req.json()) as { id: string | number }; + const envelope = (await req.json()) as ReturnType; + return Response.json(decisionResponse(envelope, { decision: "deny", reasoning: "blocked by policy" })); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(true); + expect(outcome.decisionArrived && outcome.decision.decision).toBe("deny"); + expect(outcome.decisionArrived && outcome.decision.reasoning).toBe("blocked by policy"); + } finally { + mock.stop(true); + } + }); + + it("rejects a deny without required reasoning before it becomes an AcsDecision", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json(decisionResponse(envelope, { decision: "deny" })); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianResponseValidationError); + expect(classifyDeliveryFailure(outcome.decisionArrived === false ? outcome.failure : undefined)).toMatchObject({ + kind: "invalid_response", + }); + } finally { + mock.stop(true); + } + }); + + it("rejects a deny whose reasoning has the wrong type", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json(decisionResponse(envelope, { decision: "deny", reasoning: 7 })); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianResponseValidationError); + } finally { + mock.stop(true); + } + }); + + it("fails a schema-invalid response closed even under a proceed posture", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json(decisionResponse(envelope, { decision: "deny" })); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + expect(outcome.decisionArrived).toBe(false); + + const events: AuditEvent[] = []; + const decision = applyFailurePosture({ + failure: outcome.decisionArrived === false ? outcome.failure : undefined, + session: { + config: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallRequest"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + }, + failure: undefined, + }, + sessionId: "sess-1", + method: envelope.method, + rpcId: envelope.id, + audit: { path: "test", write: (event) => (events.push(event), true) }, + }); + + expect(decision.decision).toBe("deny"); + expect(decision.reason_codes).toEqual(["guardian_response_invalid"]); + expect(events[0]).toMatchObject({ + posture: "proceed", + outcome: "blocked", + failure: { kind: "invalid_response" }, + }); + } finally { + mock.stop(true); + } + }); + + it("rejects a response carrying both a result and an error", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; return Response.json({ - jsonrpc: "2.0", - id: body.id, - result: { decision: "deny", reasoning: "blocked" }, + ...decisionResponse(envelope, { decision: "deny", reasoning: "blocked" }), error: { code: -32020, message: "something also went wrong" }, }); }, @@ -316,8 +437,170 @@ describe("GuardianClient.requestDecision", () => { const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); - expect(outcome.decisionArrived).toBe(true); - expect(outcome.decisionArrived && outcome.decision.decision).toBe("deny"); + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianResponseValidationError); + } finally { + mock.stop(true); + } + }); + + it("rejects an unknown decision before it reaches host rendering", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json(decisionResponse(envelope, { decision: "quarantine" })); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianResponseValidationError); + } finally { + mock.stop(true); + } + }); + + it("rejects additional top-level response fields under the canonical envelope", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json({ + ...decisionResponse(envelope, { decision: "allow" }), + unexpected: true, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianResponseValidationError); + } finally { + mock.stop(true); + } + }); + + it("rejects a schema-valid ServerHello result on a decision method", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json({ + jsonrpc: "2.0", + id: envelope.id, + result: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallRequest"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("ls -la"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianDecisionResponseError); + + const events: AuditEvent[] = []; + const decision = applyFailurePosture({ + failure: outcome.decisionArrived === false ? outcome.failure : undefined, + session: { + config: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallRequest"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + }, + failure: undefined, + }, + sessionId: "sess-1", + method: envelope.method, + rpcId: envelope.id, + audit: { path: "test", write: (event) => (events.push(event), true) }, + }); + expect(decision.decision).toBe("deny"); + expect(decision.reason_codes).toEqual(["guardian_response_invalid"]); + expect(events[0]).toMatchObject({ + posture: "proceed", + outcome: "blocked", + failure: { kind: "invalid_response" }, + }); + } finally { + mock.stop(true); + } + }); + + it("rejects a ServerHello carrying stray AcsResult discriminator fields", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json({ + jsonrpc: "2.0", + id: envelope.id, + result: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallRequest"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + type: "final", + decision: "allow", + }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianDecisionResponseError); + } finally { + mock.stop(true); + } + }); + + it("rejects a result that satisfies both the ServerHello and AcsResult branches", async () => { + const mock = Bun.serve({ + port: 0, + async fetch(req) { + const envelope = (await req.json()) as ReturnType; + return Response.json({ + jsonrpc: "2.0", + id: envelope.id, + result: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallRequest"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + type: "final", + acs_version: "0.1.0", + request_id: envelope.params.request_id, + decision: "allow", + }, + }); + }, + }); + + try { + const envelope = buildEnvelope("PreToolUse", preToolUsePayload("rm -rf /"), hookmap); + const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); + expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianResponseValidationError); } finally { mock.stop(true); } @@ -352,12 +635,8 @@ describe("GuardianClient.requestDecision", () => { const mock = Bun.serve({ port: 0, async fetch(req) { - const body = (await req.json()) as { id: string | number }; - return Response.json({ - jsonrpc: "2.0", - id: body.id, - result: { decision: "allow", request_id: crypto.randomUUID() }, - }); + const envelope = (await req.json()) as ReturnType; + return Response.json(decisionResponse(envelope, { decision: "allow", request_id: crypto.randomUUID() })); }, }); @@ -366,6 +645,32 @@ describe("GuardianClient.requestDecision", () => { const outcome = await createGuardianClient(`http://localhost:${mock.port}/acs`).requestDecision(envelope); expect(outcome.decisionArrived).toBe(false); + expect(outcome.decisionArrived === false && outcome.failure).toBeInstanceOf(GuardianResultCorrelationError); + + const events: AuditEvent[] = []; + const decision = applyFailurePosture({ + failure: outcome.decisionArrived === false ? outcome.failure : undefined, + session: { + config: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallRequest"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + }, + failure: undefined, + }, + sessionId: "sess-1", + method: envelope.method, + rpcId: envelope.id, + audit: { path: "test", write: (event) => (events.push(event), true) }, + }); + expect(decision.decision).toBe("allow"); + expect(events[0]).toMatchObject({ + posture: "proceed", + outcome: "proceeded", + failure: { kind: "unknown" }, + }); } finally { mock.stop(true); } diff --git a/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts b/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts index c9562692..b988b68f 100644 --- a/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/failure-posture.test.ts @@ -9,6 +9,7 @@ import { import { GuardianTimeoutError } from "../src/guardian-client.ts"; import { SessionConfigStoreFailedError, type ResolvedSessionConfig } from "../src/handshake.ts"; import type { SessionConfig } from "../src/session-config.ts"; +import { GuardianResponseValidatorError } from "../src/validate-response.ts"; /** The arm of `AuditEvent` this module writes. `applyFailurePosture` answers * a failure with a posture, and every entry it files is that; the other arm @@ -376,6 +377,13 @@ describe("classifyDeliveryFailure — what came back instead of a decision", () expect(classifyDeliveryFailure(new GuardianTimeoutError(5000)).kind).toBe("timeout"); }); + it("classifies local validator unavailability as a host fault", () => { + expect(classifyDeliveryFailure(new GuardianResponseValidatorError("schema files are unavailable"))).toEqual({ + kind: "response_validation_unavailable", + message: "Host could not validate the Guardian response: schema files are unavailable", + }); + }); + // MUST NOT be replaced with a synthetic error. A hand-built // `new TypeError(...)` would only prove classifyDeliveryFailure handles a // TypeError correctly, never that a real refused connection produces one. @@ -437,6 +445,37 @@ describe("classifyDeliveryFailure — what came back instead of a decision", () }); }); +describe("applyFailurePosture — local response validation is unavailable", () => { + it("uses the negotiated posture without blaming the Guardian", () => { + const proceeding = recordingSink(); + const proceed = applyFailurePosture({ + failure: new GuardianResponseValidatorError("schema files are unavailable"), + session: SESSION("proceed"), + audit: proceeding.sink, + ...CALL, + }); + expect(proceed.decision).toBe("allow"); + expect(proceed.reason_codes).toEqual(["response_validation_unavailable"]); + expect(proceed.reasoning).toMatch(/this host could not validate/i); + expect(proceed.reasoning).not.toMatch(/guardian returned an invalid response/i); + expect(proceeding.events[0]).toMatchObject({ + posture: "proceed", + outcome: "proceeded", + failure: { kind: "response_validation_unavailable" }, + }); + + const denying = recordingSink(); + expect( + applyFailurePosture({ + failure: new GuardianResponseValidatorError("schema files are unavailable"), + session: SESSION("deny"), + audit: denying.sink, + ...CALL, + }).decision, + ).toBe("deny"); + }); +}); + // The finding this describe exists for: the host could not tell "the Guardian // is down" from "the Guardian is alive and refused your envelope", and under // the shipped default posture (`proceed`) both became `allow`. Every test here