From 53bde88a4b8747d524484211d922cf90d736c2c3 Mon Sep 17 00:00:00 2001 From: Pramod Ubbala <16706816+upramod@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:35:01 -0700 Subject: [PATCH 1/6] Bind Guardian session state to declared identity before evaluation Part of #166. Reject identity changes without appending to the session chain; preserve bindings across chain eviction and fail closed at capacity. Signed-off-by: Pramod Ubbala <16706816+upramod@users.noreply.github.com> --- reference-implementations/agt/README.md | 24 ++++ .../agt/packages/guardian/src/server.ts | 19 +++ .../guardian/src/session-context-store.ts | 28 ++++ .../packages/guardian/src/session-identity.ts | 21 +++ .../guardian/test/session-identity.test.ts | 121 ++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 reference-implementations/agt/packages/guardian/src/session-identity.ts create mode 100644 reference-implementations/agt/packages/guardian/test/session-identity.test.ts diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index bb69941b..ed6afb67 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -365,6 +365,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 9fd4e21f..97facbfa 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"; @@ -693,6 +694,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 11e0ca9a..288ecc0c 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 00000000..1004e79c --- /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 00000000..891e7f7f --- /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(); } + }); +}); From c4680982caa8aed5d5d3bd9f3f3c89ee85913d52 Mon Sep 17 00:00:00 2001 From: Pramod Ubbala <16706816+upramod@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:40:04 -0700 Subject: [PATCH 2/6] fix(agt): allow multiple users in an agent session Bind session consistency to agent and tenant only. Keep user context request-scoped and update HTTP regression expectations and documentation. Signed-off-by: Pramod Ubbala <16706816+upramod@users.noreply.github.com> --- reference-implementations/agt/README.md | 8 +++++--- .../agt/packages/guardian/src/session-identity.ts | 9 +++------ .../guardian/test/session-identity.test.ts | 14 ++++++++------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index 00d64e21..fd62982a 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -491,9 +491,11 @@ The suite has 68 test files. Most of them drive a real Guardian, the real host s ## 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. +the declared agent ID and tenant ID. Later requests 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. Policies must evaluate each request\'s user context; +the session binding does not authorize a user or freeze their permissions. 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. diff --git a/reference-implementations/agt/packages/guardian/src/session-identity.ts b/reference-implementations/agt/packages/guardian/src/session-identity.ts index 1004e79c..e56e20f1 100644 --- a/reference-implementations/agt/packages/guardian/src/session-identity.ts +++ b/reference-implementations/agt/packages/guardian/src/session-identity.ts @@ -4,15 +4,12 @@ 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. + // 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, - user?.user_id ?? null, - user?.authentication_method ?? null, - [...new Set(user?.roles ?? [])].sort(), ])).digest("hex"); } diff --git a/reference-implementations/agt/packages/guardian/test/session-identity.test.ts b/reference-implementations/agt/packages/guardian/test/session-identity.test.ts index 891e7f7f..e40a083b 100644 --- a/reference-implementations/agt/packages/guardian/test/session-identity.test.ts +++ b/reference-implementations/agt/packages/guardian/test/session-identity.test.ts @@ -42,8 +42,8 @@ describe("session identity binding over loopback HTTP", () => { 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"; + 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); @@ -77,12 +77,14 @@ describe("identity binding lifetime", () => { 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; + 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!.roles = ["a", "b"]; + params.metadata.user_context = { user_id: "bob", roles: ["admin"], authentication_method: "other" }; expect(sessionIdentityDigest(params)).toBe(digest); - params.metadata.user_context!.user_id = ""; + delete params.metadata.user_context; + expect(sessionIdentityDigest(params)).toBe(digest); + params.tenant_id = ""; expect(sessionIdentityDigest(params)).not.toBe(digest); }); From 7b03a7c5afb83a81eb0a006cf589d5e43aba6d1e Mon Sep 17 00:00:00 2001 From: Pramod Ubbala <16706816+upramod@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:58:51 -0700 Subject: [PATCH 3/6] fix(agt): evict identity bindings with sessions --- .../guardian/src/session-context-store.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) 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 cf617b7d..a49e748c 100644 --- a/reference-implementations/agt/packages/guardian/src/session-context-store.ts +++ b/reference-implementations/agt/packages/guardian/src/session-context-store.ts @@ -141,9 +141,6 @@ 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; @@ -170,10 +167,6 @@ 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) { @@ -211,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); } }; @@ -244,10 +238,11 @@ export function createMemorySessionContextStore( 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); + // 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 From 4e5262118eb09bc31da4219f7b1750a51c95da10 Mon Sep 17 00:00:00 2001 From: Pramod Ubbala <16706816+upramod@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:59:04 -0700 Subject: [PATCH 4/6] fix(agt): return session refused for identity mismatch --- .../agt/packages/guardian/src/server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/reference-implementations/agt/packages/guardian/src/server.ts b/reference-implementations/agt/packages/guardian/src/server.ts index c806e339..c69d56f7 100644 --- a/reference-implementations/agt/packages/guardian/src/server.ts +++ b/reference-implementations/agt/packages/guardian/src/server.ts @@ -209,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 @@ -868,7 +869,10 @@ async function dispatch( })); if (denial) return denial; } - return errorResponse(rpcId, ENVELOPE_INVALID_CODE, error.message, { reason_code: error.reasonCode }); + const code = error.reasonCode === "session_identity_mismatch" + ? SESSION_REFUSED_CODE + : ENVELOPE_INVALID_CODE; + return errorResponse(rpcId, code, error.message, { reason_code: error.reasonCode }); } } From 5c8a2daf293ea73a2a73f676ef5a99898f7d4fb6 Mon Sep 17 00:00:00 2001 From: Pramod Ubbala <16706816+upramod@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:59:19 -0700 Subject: [PATCH 5/6] docs(agt): clarify session identity lifecycle and policy context --- reference-implementations/agt/README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index 50872810..83218265 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -491,11 +491,13 @@ The suite has 68 test files. Most of them drive a real Guardian, the real host s ## 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 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. Policies must evaluate each request\'s user context; -the session binding does not authorize a user or freeze their permissions. +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. @@ -506,13 +508,12 @@ 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. +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 From 2fb4976758d5318ccea80a0f1c5ffa176542c51e Mon Sep 17 00:00:00 2001 From: Pramod Ubbala <16706816+upramod@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:59:21 -0700 Subject: [PATCH 6/6] test(agt): cover session eviction, capacity churn, and refusal code --- .../guardian/test/session-identity.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/reference-implementations/agt/packages/guardian/test/session-identity.test.ts b/reference-implementations/agt/packages/guardian/test/session-identity.test.ts index e40a083b..d595e0be 100644 --- a/reference-implementations/agt/packages/guardian/test/session-identity.test.ts +++ b/reference-implementations/agt/packages/guardian/test/session-identity.test.ts @@ -59,16 +59,19 @@ describe("session identity binding over loopback HTTP", () => { }); describe("identity binding lifetime", () => { - it("survives audit-chain eviction and fails closed at identity capacity", () => { - const store = createMemorySessionContextStore({ maxSessions: 1, maxIdentityBindings: 2, onEvict() {} }); + 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); - 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(); + // 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", () => { @@ -113,7 +116,8 @@ describe("identity binding lifetime", () => { await started; const competing = await send("b"); expect(competing.result.reason_codes).toEqual(["session_identity_mismatch"]); - expect((await send("b", "handshake/hello")).error).toBeDefined(); + 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();