Skip to content
27 changes: 27 additions & 0 deletions reference-implementations/agt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions reference-implementations/agt/packages/guardian/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, string>();
const onEvict = options.onEvict ?? warnEvictedSession;
if (!Number.isInteger(maxSessions) || maxSessions < 1) {
// Thrown rather than clamped, for the reason `ACS_ON_DECISION_FAILURE`
Expand Down Expand Up @@ -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);
}
};
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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); }
}
Original file line number Diff line number Diff line change
@@ -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<void>(resolve => { entered = resolve; });
const pending = new Promise<void>(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<any>;
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(); }
});
});
Loading