diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index cdc6a86..00d64e2 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -488,6 +488,30 @@ The suite has 68 test files. Most of them drive a real Guardian, the real host s - The policy input the Guardian sends validates against AGT's own schema at the pinned commit and at upstream `main`. (`packages/conformance/test/policy-input-schema.test.ts`, `bun run watch:upstream`) - Each of AGT's eight declared contract surfaces is diffed against upstream `main`, field by field. (`packages/conformance/test/diff-surfaces.test.ts`) +## Session identity consistency + +The first validated handshake or supported tool step binds its session ID to +the declared agent ID, tenant ID, user ID, authentication method, and role set. +Later requests must carry the same identity fields. Role ordering does not +matter. Adding or removing an identity field requires a new session ID. +An identity mismatch returns an explicit deny for a tool step, before policy +evaluation, label access, or an append to the session chain. A mismatched +handshake returns an error and cannot replace the binding. + +This is a consistency check, not authentication: a caller who can impersonate +the first declaration can still claim that identity. Deployments must bind +these declarations to authenticated peers. Issue #166 remains broader than +this check; session-start dispatch and authenticated-principal resolution are +not implemented here. + +Bindings belong to the session store and survive its audit-chain eviction. +The memory store retains at most 65,536 identity bindings, configurable through +`maxIdentityBindings`. At capacity it refuses new sessions while retaining old +bindings. Bindings do not survive process restart. There is no automatic expiry +or rotation; durable storage and safe session retirement require lifecycle +work. Custom `SessionContextStore` implementations must provide a synchronous, +atomic `bindIdentity` method and preserve bindings for retained session state. + ## Operational debt These are known limits of the tree as it stands. None is fixed. diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index 922c11b..f09a826 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -64,6 +64,7 @@ * `hostname` option (main.ts reads ACS_GUARDIAN_HOST for it). */ import { appendFileSync, mkdirSync } from "node:fs"; +import { sessionIdentityDigest, SessionIdentityError } from "./session-identity.ts"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import type { Annotator, PolicyBridge } from "agt-bridge"; @@ -747,6 +748,24 @@ async function dispatch( throw error; } + // Bind before the first handshake or supported step, before reading labels, + // appending to the chain, or awaiting policy evaluation. Repeated handshakes + // check the binding; they cannot replace it. Unknown methods create no state. + if (envelope.method === HANDSHAKE_METHOD || isToolCallRequest(envelope) || isToolCallResult(envelope)) { + try { + sessionContextStore.bindIdentity(envelope.params.metadata.session_id, sessionIdentityDigest(envelope.params)); + } catch (error) { + if (!(error instanceof SessionIdentityError)) throw error; + if (isStepMethod(raw)) { + const denial = asDecisionResponse(rpcId, denyOnInvalidEnvelope(raw, { + reasonCode: error.reasonCode, message: error.message, + })); + if (denial) return denial; + } + return errorResponse(rpcId, ENVELOPE_INVALID_CODE, error.message, { reason_code: error.reasonCode }); + } + } + if (envelope.method === HANDSHAKE_METHOD) { const env = onDecisionFailure === undefined ? undefined : { ACS_ON_DECISION_FAILURE: onDecisionFailure }; return successResponse(envelope.id, buildServerHello(env)); diff --git a/reference-implementations/agt/packages/guardian/src/session-context-store.ts b/reference-implementations/agt/packages/guardian/src/session-context-store.ts index 11e0ca9..288ecc0 100644 --- a/reference-implementations/agt/packages/guardian/src/session-context-store.ts +++ b/reference-implementations/agt/packages/guardian/src/session-context-store.ts @@ -73,6 +73,7 @@ * keeps instead of rebuilding the history around each new entry, so a * session's hundredth step costs what its first one did. */ +import { SessionIdentityError } from "./session-identity.ts"; import { GENESIS_HASH, emptySessionState, @@ -98,6 +99,8 @@ export { /** What the Guardian's request path is told and asks on every step. */ export interface SessionContextStore { + /** Synchronous check-and-bind before any session read, append or evaluation. */ + bindIdentity(sessionId: string, identityDigest: string): void; context(sessionId: string): SessionContext; append(sessionId: string, step: SessionStep): SessionContextEntry; replaceIfcLabels(sessionId: string, labels: IfcLabels): void; @@ -136,6 +139,9 @@ export interface SessionProvenanceReader { const DEFAULT_MAX_SESSIONS = 1024; export type CreateMemorySessionContextStoreOptions = { + /** Identity bindings outlive chain eviction. At capacity, new sessions fail + * closed instead of evicting an identity and permitting session rebinding. */ + maxIdentityBindings?: number; now?: () => Date; /** Called once per appended entry with its JSON line, no trailing newline. */ appendLine?: (line: string) => void; @@ -162,6 +168,11 @@ export function createMemorySessionContextStore( const now = options.now ?? (() => new Date()); const appendLine = options.appendLine; const maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; + const maxIdentityBindings = options.maxIdentityBindings ?? 65_536; + if (!Number.isInteger(maxIdentityBindings) || maxIdentityBindings < 1) { + throw new Error("maxIdentityBindings must be a positive integer"); + } + const identities = new Map(); const onEvict = options.onEvict ?? warnEvictedSession; if (!Number.isInteger(maxSessions) || maxSessions < 1) { // Thrown rather than clamped, for the reason `ACS_ON_DECISION_FAILURE` @@ -219,6 +230,23 @@ export function createMemorySessionContextStore( }; return { + bindIdentity(sessionId, identityDigest) { + const existing = identities.get(sessionId); + if (existing !== undefined) { + if (existing !== identityDigest) { + throw new SessionIdentityError("session_identity_mismatch", "session identity does not match its established binding"); + } + return; + } + // Do not adopt an existing chain whose original identity is unknown. + if (sessions.has(sessionId)) { + throw new SessionIdentityError("session_identity_unbound", "existing session state has no identity binding"); + } + if (identities.size >= maxIdentityBindings) { + throw new SessionIdentityError("session_identity_capacity", "session identity store is full; new sessions are refused"); + } + identities.set(sessionId, identityDigest); + }, context(sessionId) { // Copied on the way out, like the labels below, and for one reason // more: `append` pushes onto the stored array, so an uncopied diff --git a/reference-implementations/agt/packages/guardian/src/session-identity.ts b/reference-implementations/agt/packages/guardian/src/session-identity.ts new file mode 100644 index 0000000..1004e79 --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/session-identity.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import type { AcsRequestParams } from "./validate-envelope.ts"; + +/** Consistency of declared identity is not authentication. The deployment must + * authenticate peers before trusting their first declaration. */ +export function sessionIdentityDigest(params: AcsRequestParams): string { + const user = params.metadata.user_context; + // Ignore unknown extension fields and treat role ordering as insignificant. + // Absence differs from an explicit identity or authentication method. + return createHash("sha256").update(JSON.stringify([ + params.tenant_id ?? null, + params.metadata.agent_id, + user?.user_id ?? null, + user?.authentication_method ?? null, + [...new Set(user?.roles ?? [])].sort(), + ])).digest("hex"); +} + +export class SessionIdentityError extends Error { + constructor(public readonly reasonCode: string, message: string) { super(message); } +} diff --git a/reference-implementations/agt/packages/guardian/test/session-identity.test.ts b/reference-implementations/agt/packages/guardian/test/session-identity.test.ts new file mode 100644 index 0000000..891e7f7 --- /dev/null +++ b/reference-implementations/agt/packages/guardian/test/session-identity.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "bun:test"; +import { startGuardian, createMemorySessionContextStore, loadSessionContext } from "../src/index.ts"; +import { sessionIdentityDigest } from "../src/session-identity.ts"; +import type { AcsRequestParams } from "../src/validate-envelope.ts"; + +// Regression coverage for issue #166. +// A controlled policy bridge isolates session handling from policy decisions. +describe("session identity binding over loopback HTTP", () => { + for (const method of ["steps/toolCallRequest", "steps/toolCallResult"]) { + for (const scenario of ["same-agent", "different-agent", "changed-user", "changed-roles", "changed-auth", "changed-tenant", "separate-session"] as const) { + it(`${method}: ${scenario}`, async () => { + const store = createMemorySessionContextStore(); + let evaluations = 0; + const guardian = await startGuardian({ + port: 0, manifestPath: "policy/manifest.yaml", sessionContextStore: store, + bridge: { async evaluate() { evaluations++; return { decision: "allow" }; } }, + }); + const session = crypto.randomUUID(); + const otherSession = scenario === "separate-session" ? crypto.randomUUID() : session; + const ids: string[] = []; + async function send(agent: string, sid: string, user: string, expected: "allow" | "deny" = "allow") { + const requestId = crypto.randomUUID(); + ids.push(requestId); + const response = await fetch(guardian.url, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: ids.length, method, + params: { acs_version: "0.1.0", request_id: requestId, timestamp: new Date().toISOString(), + tenant_id: ids.length === 2 && scenario === "changed-tenant" ? "other" : "tenant-a", + metadata: { agent_id: agent, session_id: sid, + user_context: { user_id: user, roles: ids.length === 2 && scenario === "changed-roles" ? ["admin"] : ["reader"], authentication_method: ids.length === 2 && scenario === "changed-auth" ? "other" : "test" } }, + payload: method === "steps/toolCallResult" + ? { tool: { name: "run_shell" }, exit_status: "success", outputs: [{ value: "synthetic" }] } + : { tool: { name: "run_shell" }, arguments: { command: { value: "echo synthetic" } } }, + }, + }), + }); + expect(response.status).toBe(200); + const body = await response.json() as { result?: { decision?: string } }; + expect(body.result?.decision).toBe(expected); + } + try { + await send("agent-a", session, "user-a"); + await send(scenario === "different-agent" ? "agent-b" : "agent-a", otherSession, + scenario === "changed-user" ? "user-b" : "user-a", + scenario === "same-agent" || scenario === "separate-session" ? "allow" : "deny"); + const rejected = scenario !== "same-agent" && scenario !== "separate-session"; + expect(evaluations).toBe(rejected ? 1 : 2); + const original = loadSessionContext(store, session).entries; + expect(original.map(e => e.request_id)).toEqual(scenario === "separate-session" || rejected ? [ids[0]!] : ids); + if (scenario === "separate-session") { + expect(loadSessionContext(store, otherSession).entries.map(e => e.request_id)).toEqual([ids[1]!]); + } else if (!rejected) { + expect(original[1]!.prev_hash).toBe(original[0]!.hash); + } + } finally { await guardian.close(); } + }); + } + } +}); + +describe("identity binding lifetime", () => { + it("survives audit-chain eviction and fails closed at identity capacity", () => { + const store = createMemorySessionContextStore({ maxSessions: 1, maxIdentityBindings: 2, onEvict() {} }); + store.bindIdentity("a", "identity-a"); + store.append("a", { method: "steps/toolCallRequest", request_id: "a", tool_name: "x" }); + store.bindIdentity("b", "identity-b"); + store.append("b", { method: "steps/toolCallRequest", request_id: "b", tool_name: "x" }); + expect(store.context("a").entries).toHaveLength(0); + expect(() => store.bindIdentity("a", "other")).toThrow("does not match"); + expect(() => store.bindIdentity("c", "identity-c")).toThrow("store is full"); + expect(() => store.bindIdentity("a", "identity-a")).not.toThrow(); + }); + + it("refuses to adopt pre-existing state with an unknown identity", () => { + const store = createMemorySessionContextStore(); + store.append("a", { method: "steps/toolCallRequest", request_id: "a", tool_name: "x" }); + expect(() => store.bindIdentity("a", "identity-a")).toThrow("no identity binding"); + }); + + it("normalizes role sets while retaining identity absence", () => { + const params = { metadata: { agent_id: "a", session_id: "s", user_context: { roles: ["b", "a", "a"] } } } as AcsRequestParams; + const digest = sessionIdentityDigest(params); + params.metadata.user_context!.roles = ["a", "b"]; + expect(sessionIdentityDigest(params)).toBe(digest); + params.metadata.user_context!.user_id = ""; + expect(sessionIdentityDigest(params)).not.toBe(digest); + }); + + it("rejects a competing identity while the first evaluation awaits", async () => { + const store = createMemorySessionContextStore(); + let entered!: () => void; + let release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + let evaluations = 0; + const guardian = await startGuardian({ port: 0, manifestPath: "policy/manifest.yaml", sessionContextStore: store, + bridge: { async evaluate() { evaluations++; entered(); await pending; return { decision: "allow" }; } }, + }); + const session = crypto.randomUUID(); + const send = (agent: string, method = "steps/toolCallRequest") => fetch(guardian.url, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: agent, method, params: { + acs_version: "0.1.0", request_id: crypto.randomUUID(), timestamp: new Date().toISOString(), + metadata: { agent_id: agent, session_id: session }, + payload: method === "handshake/hello" ? {} : { tool: { name: "run_shell" }, arguments: { command: { value: "echo synthetic" } } }, + } }), + }).then(response => response.json()) as Promise; + try { + expect((await send("a", "handshake/hello")).result).toBeDefined(); + const first = send("a"); + await started; + const competing = await send("b"); + expect(competing.result.reason_codes).toEqual(["session_identity_mismatch"]); + expect((await send("b", "handshake/hello")).error).toBeDefined(); + expect(evaluations).toBe(1); + expect(store.context(session).entries).toHaveLength(1); + release(); + expect((await first).result.decision).toBe("allow"); + } finally { release(); await guardian.close(); } + }); +});