Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions reference-implementations/agt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 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 @@ -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));
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;
append(sessionId: string, step: SessionStep): SessionContextEntry;
replaceIfcLabels(sessionId: string, labels: IfcLabels): void;
Expand Down Expand Up @@ -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;
Expand All @@ -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<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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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); }
}
Original file line number Diff line number Diff line change
@@ -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<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"]);
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(); }
});
});
Loading