diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index c50cd580..83218265 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -488,6 +488,33 @@ 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 and tenant ID. Later requests in the retained session +must carry the same agent and tenant. Adding, removing, or changing the tenant +requires a new session ID. User ID, authentication method, and roles remain +request-scoped and may change within a shared session. The current policy +bridge does not pass user context to policy rules, so rules cannot yet make +authorization decisions from those fields. The session binding does not +authenticate or authorize a user. +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 share the memory store's bounded session lifecycle. When the store +evicts a session, it removes that session's identity binding too. Do not reuse +session IDs after eviction; a later request with an evicted ID is a new session. +Bindings do not survive process restart. Custom `SessionContextStore` +implementations must provide a synchronous, atomic `bindIdentity` method and +remove bindings when their corresponding session state is retired. + ## 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 ba5e90c9..c69d56f7 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"; @@ -208,6 +209,7 @@ export function toRepoRelativeMessage(error: unknown): string { * part of the reserved band, so every application-level failure stays inside * -32000..-32099. */ +const SESSION_REFUSED_CODE = -32000; const ENVELOPE_INVALID_CODE = -32010; /** * Exported, unlike its two neighbours, because the conformance harness posts @@ -853,6 +855,27 @@ 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; + } + const code = error.reasonCode === "session_identity_mismatch" + ? SESSION_REFUSED_CODE + : ENVELOPE_INVALID_CODE; + return errorResponse(rpcId, 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 e4bb2937..a49e748c 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; hasToolCallRequest(sessionId: string, requestId: string): boolean; hasRequestId(sessionId: string, requestId: string): boolean; @@ -164,6 +167,7 @@ export function createMemorySessionContextStore( const now = options.now ?? (() => new Date()); const appendLine = options.appendLine; const maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; + 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` @@ -200,6 +204,7 @@ export function createMemorySessionContextStore( const oldest = sessions.keys().next(); if (oldest.done === true) return; sessions.delete(oldest.value); + identities.delete(oldest.value); reportEviction(onEvict, oldest.value); } }; @@ -221,6 +226,24 @@ 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"); + } + identities.set(sessionId, identityDigest); + // Count a handshake-only session against the same bounded lifecycle as + // sessions with steps. This prevents unauthenticated hello floods from + // growing the identity map independently of the session cap. + ensure(sessionId); + }, 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 00000000..e56e20f1 --- /dev/null +++ b/reference-implementations/agt/packages/guardian/src/session-identity.ts @@ -0,0 +1,18 @@ +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 { + // A shared session may contain requests from several users. User context + // remains request-scoped for policy evaluation; only agent and tenant bind. + // An absent tenant differs from an explicit tenant. + return createHash("sha256").update(JSON.stringify([ + params.tenant_id ?? null, + params.metadata.agent_id, + ])).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 00000000..d595e0be --- /dev/null +++ b/reference-implementations/agt/packages/guardian/test/session-identity.test.ts @@ -0,0 +1,127 @@ +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 === "different-agent" || scenario === "changed-tenant" ? "deny" : "allow"); + const rejected = scenario === "different-agent" || scenario === "changed-tenant"; + 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("evicts identity bindings with session state so long-running stores keep accepting sessions", () => { + const store = createMemorySessionContextStore({ maxSessions: 1, 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); + // Once its state is evicted, the ID has a new session lifecycle. + expect(() => store.bindIdentity("a", "new-session-identity")).not.toThrow(); + for (let i = 0; i < 65_540; i++) { + store.bindIdentity(`session-${i}`, `identity-${i}`); + } + expect(() => store.bindIdentity("fresh", "fresh-identity")).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("keeps user context request-scoped and distinguishes tenant absence", () => { + const params = { metadata: { agent_id: "a", session_id: "s", user_context: { user_id: "alice", roles: ["reader"], authentication_method: "test" } } } as AcsRequestParams; + const digest = sessionIdentityDigest(params); + params.metadata.user_context = { user_id: "bob", roles: ["admin"], authentication_method: "other" }; + expect(sessionIdentityDigest(params)).toBe(digest); + delete params.metadata.user_context; + expect(sessionIdentityDigest(params)).toBe(digest); + params.tenant_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"]); + const refused = await send("b", "handshake/hello"); + expect(refused.error.code).toBe(-32000); + expect(evaluations).toBe(1); + expect(store.context(session).entries).toHaveLength(1); + release(); + expect((await first).result.decision).toBe("allow"); + } finally { release(); await guardian.close(); } + }); +});