From 0c275dda7ef10717d22b389712d1b1007040253b Mon Sep 17 00:00:00 2001 From: Anton Mishel Date: Thu, 26 Mar 2026 00:31:16 -0700 Subject: [PATCH 1/5] feat(security): add behavioral session tracker with trifecta detection Tracks three capability classes per session: read_sensitive, ingested_untrusted, has_egress. When all three appear (trifecta), risk escalates to critical, detecting multi-step exfiltration attacks that per-action gates miss. --- docs/reference/session-tracker.md | 162 +++++++++ nemoclaw/src/security/session-tracker.test.ts | 344 ++++++++++++++++++ nemoclaw/src/security/session-tracker.ts | 263 +++++++++++++ 3 files changed, 769 insertions(+) create mode 100644 docs/reference/session-tracker.md create mode 100644 nemoclaw/src/security/session-tracker.test.ts create mode 100644 nemoclaw/src/security/session-tracker.ts diff --git a/docs/reference/session-tracker.md b/docs/reference/session-tracker.md new file mode 100644 index 000000000..6f944474a --- /dev/null +++ b/docs/reference/session-tracker.md @@ -0,0 +1,162 @@ +--- +title: + page: "Session Tracker — Behavioral Trifecta Detection" + nav: "Session Tracker" +description: "Reference for the behavioral session tracker that detects multi-step exfiltration attacks by tracking three capability classes per agent session." +keywords: ["nemoclaw session tracker", "trifecta detection", "behavioral tracking", "exfiltration detection"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "security", "session", "trifecta"] +content: + type: reference + difficulty: intermediate + audience: ["developer", "engineer"] +status: published +--- + + + +# Session Tracker + +The session tracker module detects multi-step exfiltration attacks by tracking three capability classes per agent session. + +Per-action policy gates evaluate each tool call in isolation. +An agent that reads a secret, ingests untrusted input, and opens an outbound connection across separate actions can bypass per-action checks. +The session tracker aggregates these capabilities over the lifetime of a session and raises the risk level when the combination is dangerous. + +## Trifecta Detection + +The tracker monitors three capability classes. + +| Capability | Enum value | What it means | +|---|---|---| +| Read sensitive | `read_sensitive` | The agent accessed a sensitive file or secret | +| Ingested untrusted | `ingested_untrusted` | The agent consumed input from an external or untrusted source | +| Has egress | `has_egress` | The agent made or attempted an outbound network connection | + +When all three capabilities appear in a single session, the session has a "trifecta." +A trifecta indicates a possible exfiltration chain: read a secret, get instructions from an attacker, and send the secret out. + +## Risk Levels + +The tracker classifies each session into one of three risk levels. + +| Level | Condition | +|---|---| +| `clean` | No capabilities recorded | +| `elevated` | One or two capabilities recorded | +| `critical` | All three capabilities recorded (trifecta) | + +## Event Storage + +Each call to `record()` creates a `CapabilityEvent` with a capability, tool name, detail string, and timestamp. +The tracker stores up to 100 events per session. +Events beyond the 100th are dropped, but the capability set continues to update. + +## API + +The module exports the following from `nemoclaw/src/security/session-tracker.ts`. + +### `SessionStore` + +Class that tracks capability events per agent session. + +```typescript +import { SessionStore, Capability } from "./security/session-tracker.js"; + +const store = new SessionStore(); +store.record("session-1", Capability.ReadSensitive, "cat", "/etc/passwd"); +store.record("session-1", Capability.HasEgress, "curl", "https://example.com"); +``` + +#### `record(sessionId: string, cap: Capability, tool: string, detail: string): void` + +Record a capability event against a session. +Empty `sessionId` values are silently ignored. + +#### `getCapabilities(sessionId: string): Record | null` + +Return the capability map for a session. +Returns `null` if the session does not exist or `sessionId` is empty. + +#### `hasTrifecta(sessionId: string): boolean` + +Return `true` if the session has all three capability classes. + +#### `listSessions(): SessionSummary[]` + +Return summaries of all active sessions. + +#### `getExposure(sessionId: string): SessionExposure | null` + +Return detailed exposure data for a session. +Returns `null` if the session does not exist. + +The exposure object categorizes events into three lists. + +- `sensitiveFilesAccessed` contains deduplicated file paths from `read_sensitive` events. +- `externalUrlsContacted` contains deduplicated URLs from `ingested_untrusted` events. +- `egressAttempts` contains every `has_egress` event formatted as `tool + " " + detail`, without deduplication. + +### `Capability` + +Enum with three members. + +```typescript +enum Capability { + ReadSensitive = "read_sensitive", + IngestedUntrusted = "ingested_untrusted", + HasEgress = "has_egress", +} +``` + +### `CapabilityEvent` + +```typescript +interface CapabilityEvent { + readonly capability: Capability; + readonly tool: string; + readonly detail: string; + readonly time: string; +} +``` + +### `SessionSummary` + +```typescript +interface SessionSummary { + readonly sessionId: string; + readonly capabilities: Record; + readonly trifecta: boolean; + readonly riskLevel: RiskLevel; + readonly eventCount: number; +} +``` + +### `SessionExposure` + +```typescript +interface SessionExposure { + readonly sessionId: string; + readonly capabilities: Record; + readonly trifecta: boolean; + readonly riskLevel: RiskLevel; + readonly events: readonly CapabilityEvent[]; + readonly sensitiveFilesAccessed: readonly string[]; + readonly externalUrlsContacted: readonly string[]; + readonly egressAttempts: readonly string[]; +} +``` + +### `RiskLevel` + +```typescript +type RiskLevel = "clean" | "elevated" | "critical"; +``` + +## Next Steps + +- Review the injection scanner in {doc}`/reference/injection-scanner` to understand how NemoClaw detects prompt injection in agent tool calls. +- See the audit chain in {doc}`/reference/audit-chain` for tamper-evident logging of all policy decisions. diff --git a/nemoclaw/src/security/session-tracker.test.ts b/nemoclaw/src/security/session-tracker.test.ts new file mode 100644 index 000000000..b5dc9874e --- /dev/null +++ b/nemoclaw/src/security/session-tracker.test.ts @@ -0,0 +1,344 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach } from "vitest"; +import { SessionStore, Capability } from "./session-tracker.js"; + +// ── Test helpers ───────────────────────────────────────────── + +let store: SessionStore; + +beforeEach(() => { + store = new SessionStore(); +}); + +/** Assert a value is non-null and return it with the narrowed type. */ +function assertDefined(value: T | null | undefined): T { + expect(value).toBeDefined(); + expect(value).not.toBeNull(); + return value as T; +} + +// ── record + getCapabilities ───────────────────────────────── + +describe("record and getCapabilities", () => { + it("records a capability and retrieves it", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + const caps = assertDefined(store.getCapabilities("s1")); + expect(caps[Capability.ReadSensitive]).toBe(true); + }); + + it("tracks multiple capabilities per session", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/shadow"); + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + const caps = assertDefined(store.getCapabilities("s1")); + expect(caps[Capability.ReadSensitive]).toBe(true); + expect(caps[Capability.HasEgress]).toBe(true); + expect(caps[Capability.IngestedUntrusted]).toBeUndefined(); + }); + + it("returns null for unknown session", () => { + const caps = store.getCapabilities("nonexistent"); + expect(caps).toBeNull(); + }); + + it("ignores empty session ID", () => { + store.record("", Capability.ReadSensitive, "cat", "/etc/passwd"); + const caps = store.getCapabilities(""); + expect(caps).toBeNull(); + }); + + it("returns a copy of capabilities, not a reference", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + const caps1 = store.getCapabilities("s1"); + const caps2 = store.getCapabilities("s1"); + expect(caps1).toEqual(caps2); + expect(caps1).not.toBe(caps2); + }); +}); + +// ── hasTrifecta ────────────────────────────────────────────── + +describe("hasTrifecta", () => { + it("returns false with zero capabilities", () => { + expect(store.hasTrifecta("empty")).toBe(false); + }); + + it("returns false with one capability", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + expect(store.hasTrifecta("s1")).toBe(false); + }); + + it("returns false with two capabilities", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://x.com"); + expect(store.hasTrifecta("s1")).toBe(false); + }); + + it("returns true with all three capabilities", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://x.com"); + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + expect(store.hasTrifecta("s1")).toBe(true); + }); + + it("returns false for unknown session", () => { + expect(store.hasTrifecta("unknown")).toBe(false); + }); +}); + +// ── Risk classification ────────────────────────────────────── + +describe("risk classification", () => { + it("classifies elevated with one capability", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + const sessions = store.listSessions(); + const s1 = assertDefined(sessions.find((s) => s.sessionId === "s1")); + expect(s1.riskLevel).toBe("elevated"); + }); + + it("classifies elevated with two capabilities", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.HasEgress, "curl", "https://x.com"); + const sessions = store.listSessions(); + const s1 = assertDefined(sessions.find((s) => s.sessionId === "s1")); + expect(s1.riskLevel).toBe("elevated"); + }); + + it("classifies critical with trifecta", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://x.com"); + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + const sessions = store.listSessions(); + const s1 = assertDefined(sessions.find((s) => s.sessionId === "s1")); + expect(s1.riskLevel).toBe("critical"); + expect(s1.trifecta).toBe(true); + }); +}); + +// ── Event cap at 100 ───────────────────────────────────────── + +describe("event cap", () => { + it("stores exactly 100 events", () => { + for (let i = 0; i < 100; i++) { + store.record("s1", Capability.ReadSensitive, "cat", `/file-${String(i)}`); + } + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.events).toHaveLength(100); + }); + + it("drops the 101st event", () => { + for (let i = 0; i < 101; i++) { + store.record("s1", Capability.ReadSensitive, "cat", `/file-${String(i)}`); + } + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.events).toHaveLength(100); + const lastDetail = exposure.events[99].detail; + expect(lastDetail).toBe("/file-99"); + }); + + it("still records capability even when event log is full", () => { + for (let i = 0; i < 100; i++) { + store.record("s1", Capability.ReadSensitive, "cat", `/file-${String(i)}`); + } + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + const caps = assertDefined(store.getCapabilities("s1")); + expect(caps[Capability.HasEgress]).toBe(true); + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.events).toHaveLength(100); + }); +}); + +// ── Session isolation ──────────────────────────────────────── + +describe("session isolation", () => { + it("tracks sessions independently", () => { + store.record("a", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("b", Capability.HasEgress, "curl", "https://evil.com"); + + const capsA = assertDefined(store.getCapabilities("a")); + const capsB = assertDefined(store.getCapabilities("b")); + + expect(capsA[Capability.ReadSensitive]).toBe(true); + expect(capsA[Capability.HasEgress]).toBeUndefined(); + + expect(capsB[Capability.HasEgress]).toBe(true); + expect(capsB[Capability.ReadSensitive]).toBeUndefined(); + }); + + it("does not leak trifecta across sessions", () => { + store.record("a", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("b", Capability.IngestedUntrusted, "fetch", "https://x.com"); + store.record("c", Capability.HasEgress, "curl", "https://evil.com"); + + expect(store.hasTrifecta("a")).toBe(false); + expect(store.hasTrifecta("b")).toBe(false); + expect(store.hasTrifecta("c")).toBe(false); + }); +}); + +// ── getExposure ────────────────────────────────────────────── + +describe("getExposure", () => { + it("returns null for unknown session", () => { + const exposure = store.getExposure("nonexistent"); + expect(exposure).toBeNull(); + }); + + it("categorizes events into exposure fields", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://untrusted.com/payload"); + store.record("s1", Capability.HasEgress, "curl", "https://evil.com/exfil"); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.sensitiveFilesAccessed).toEqual(["/etc/passwd"]); + expect(exposure.externalUrlsContacted).toEqual(["https://untrusted.com/payload"]); + expect(exposure.egressAttempts).toEqual(["curl https://evil.com/exfil"]); + }); + + it("deduplicates sensitive files by detail", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.ReadSensitive, "head", "/etc/passwd"); + store.record("s1", Capability.ReadSensitive, "cat", "/etc/shadow"); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.sensitiveFilesAccessed).toEqual(["/etc/passwd", "/etc/shadow"]); + }); + + it("deduplicates external URLs by detail", () => { + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://x.com/a"); + store.record("s1", Capability.IngestedUntrusted, "wget", "https://x.com/a"); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://y.com/b"); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.externalUrlsContacted).toEqual(["https://x.com/a", "https://y.com/b"]); + }); + + it("does NOT deduplicate egress attempts", () => { + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + store.record("s1", Capability.HasEgress, "wget", "https://evil.com"); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.egressAttempts).toEqual([ + "curl https://evil.com", + "curl https://evil.com", + "wget https://evil.com", + ]); + }); + + it("formats egress with tool only when detail is empty", () => { + store.record("s1", Capability.HasEgress, "curl", ""); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.egressAttempts).toEqual(["curl"]); + }); + + it("skips empty details for sensitive files", () => { + store.record("s1", Capability.ReadSensitive, "cat", ""); + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.sensitiveFilesAccessed).toEqual(["/etc/passwd"]); + }); + + it("skips empty details for external URLs", () => { + store.record("s1", Capability.IngestedUntrusted, "fetch", ""); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://x.com"); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.externalUrlsContacted).toEqual(["https://x.com"]); + }); + + it("includes correct risk level and trifecta flag", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://x.com"); + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.trifecta).toBe(true); + expect(exposure.riskLevel).toBe("critical"); + }); +}); + +// ── listSessions ───────────────────────────────────────────── + +describe("listSessions", () => { + it("returns empty array with no sessions", () => { + const sessions = store.listSessions(); + expect(sessions).toEqual([]); + }); + + it("returns all sessions with summaries", () => { + store.record("a", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("b", Capability.HasEgress, "curl", "https://evil.com"); + store.record("b", Capability.IngestedUntrusted, "fetch", "https://x.com"); + + const sessions = store.listSessions(); + expect(sessions).toHaveLength(2); + + const a = assertDefined(sessions.find((s) => s.sessionId === "a")); + const b = assertDefined(sessions.find((s) => s.sessionId === "b")); + + expect(a.eventCount).toBe(1); + expect(a.trifecta).toBe(false); + expect(a.riskLevel).toBe("elevated"); + + expect(b.eventCount).toBe(2); + expect(b.trifecta).toBe(false); + expect(b.riskLevel).toBe("elevated"); + }); + + it("reflects trifecta in session summary", () => { + store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); + store.record("s1", Capability.IngestedUntrusted, "fetch", "https://x.com"); + store.record("s1", Capability.HasEgress, "curl", "https://evil.com"); + + const sessions = store.listSessions(); + const s1 = assertDefined(sessions.find((s) => s.sessionId === "s1")); + expect(s1.trifecta).toBe(true); + expect(s1.riskLevel).toBe("critical"); + }); +}); + +// ── Error-path tests ───────────────────────────────────────── + +describe("error paths", () => { + it("handles empty string capability details gracefully", () => { + store.record("s1", Capability.ReadSensitive, "", ""); + const caps = assertDefined(store.getCapabilities("s1")); + expect(caps[Capability.ReadSensitive]).toBe(true); + }); + + it("handles rapid session creation", () => { + for (let i = 0; i < 1000; i++) { + store.record(`session-${String(i)}`, Capability.ReadSensitive, "cat", `/file-${String(i)}`); + } + const sessions = store.listSessions(); + expect(sessions).toHaveLength(1000); + }); +}); + +// ── Boundary tests ─────────────────────────────────────────── + +describe("boundary conditions", () => { + it("stores exactly 100 events at the boundary", () => { + for (let i = 0; i < 100; i++) { + store.record("s1", Capability.ReadSensitive, "cat", `/file-${String(i)}`); + } + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.events).toHaveLength(100); + expect(exposure.events[0].detail).toBe("/file-0"); + expect(exposure.events[99].detail).toBe("/file-99"); + }); + + it("the 101st event is not stored in the event log", () => { + for (let i = 0; i < 101; i++) { + store.record("s1", Capability.ReadSensitive, "cat", `/file-${String(i)}`); + } + const exposure = assertDefined(store.getExposure("s1")); + expect(exposure.events).toHaveLength(100); + const details = exposure.events.map((e) => e.detail); + expect(details).not.toContain("/file-100"); + }); +}); diff --git a/nemoclaw/src/security/session-tracker.ts b/nemoclaw/src/security/session-tracker.ts new file mode 100644 index 000000000..0bc59820f --- /dev/null +++ b/nemoclaw/src/security/session-tracker.ts @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Behavioral session tracker with trifecta detection. + * + * Tracks three capability classes per agent session: read_sensitive, + * ingested_untrusted, and has_egress. When all three capabilities appear + * in a single session (the "trifecta"), the risk level escalates to + * critical, detecting multi-step exfiltration attacks that per-action + * gates miss. + */ + +// ── Public types ───────────────────────────────────────────── + +/** The three capability classes tracked per session. */ +export enum Capability { + ReadSensitive = "read_sensitive", + IngestedUntrusted = "ingested_untrusted", + HasEgress = "has_egress", +} + +/** Risk classification for a session. */ +export type RiskLevel = "clean" | "elevated" | "critical"; + +/** A single capability event recorded against a session. */ +export interface CapabilityEvent { + readonly capability: Capability; + readonly tool: string; + readonly detail: string; + readonly time: string; +} + +/** Compact summary of a session for listing. */ +export interface SessionSummary { + readonly sessionId: string; + readonly capabilities: Record; + readonly trifecta: boolean; + readonly riskLevel: RiskLevel; + readonly eventCount: number; +} + +/** Detailed exposure data for a single session. */ +export interface SessionExposure { + readonly sessionId: string; + readonly capabilities: Record; + readonly trifecta: boolean; + readonly riskLevel: RiskLevel; + readonly events: readonly CapabilityEvent[]; + readonly sensitiveFilesAccessed: readonly string[]; + readonly externalUrlsContacted: readonly string[]; + readonly egressAttempts: readonly string[]; +} + +// ── Constants ──────────────────────────────────────────────── + +const MAX_EVENTS_PER_SESSION = 100; + +// ── Internal session state ─────────────────────────────────── + +interface Session { + capabilities: Map; + events: CapabilityEvent[]; + updatedAt: string; +} + +// ── Risk classification ────────────────────────────────────── + +/** Check whether a capability map contains all three trifecta capabilities. */ +function isTrifecta(caps: Map): boolean { + return ( + caps.get(Capability.ReadSensitive) === true && + caps.get(Capability.IngestedUntrusted) === true && + caps.get(Capability.HasEgress) === true + ); +} + +function classifyRisk(caps: Map, trifecta: boolean): RiskLevel { + if (trifecta) { + return "critical"; + } + let count = 0; + for (const v of caps.values()) { + if (v) { + count++; + } + } + if (count === 0) { + return "clean"; + } + if (count <= 2) { + return "elevated"; + } + return "critical"; +} + +// ── SessionStore class ─────────────────────────────────────── + +/** + * In-memory store that tracks capability events per agent session. + * + * Node.js is single-threaded, so no mutex is needed (unlike the Go + * implementation). Create one instance and share it across the + * request-handling code. + */ +export class SessionStore { + private readonly sessions = new Map(); + + /** + * Record a capability event against a session. + * + * Empty `sessionId` values are silently ignored. + * Once a session reaches {@link MAX_EVENTS_PER_SESSION} events, + * additional events still update the capability set but are not + * appended to the event log. + */ + record(sessionId: string, cap: Capability, tool: string, detail: string): void { + if (!sessionId) { + return; + } + + let sess = this.sessions.get(sessionId); + if (!sess) { + sess = { + capabilities: new Map(), + events: [], + updatedAt: "", + }; + this.sessions.set(sessionId, sess); + } + + sess.capabilities.set(cap, true); + sess.updatedAt = new Date().toISOString(); + + if (sess.events.length < MAX_EVENTS_PER_SESSION) { + sess.events.push({ + capability: cap, + tool, + detail, + time: sess.updatedAt, + }); + } + } + + /** + * Return the capability map for a session, or `null` if the session + * does not exist. + */ + getCapabilities(sessionId: string): Record | null { + if (!sessionId) { + return null; + } + const sess = this.sessions.get(sessionId); + if (!sess) { + return null; + } + const out: Record = {}; + for (const [k, v] of sess.capabilities) { + out[k] = v; + } + return out; + } + + /** + * Return `true` if the session has all three capability classes + * (read_sensitive, ingested_untrusted, has_egress). + */ + hasTrifecta(sessionId: string): boolean { + if (!sessionId) { + return false; + } + const sess = this.sessions.get(sessionId); + if (!sess) { + return false; + } + return isTrifecta(sess.capabilities); + } + + /** Return summaries of all active sessions. */ + listSessions(): SessionSummary[] { + const result: SessionSummary[] = []; + for (const [id, sess] of this.sessions) { + const capsCopy: Record = {}; + for (const [k, v] of sess.capabilities) { + capsCopy[k] = v; + } + const trifecta = isTrifecta(sess.capabilities); + result.push({ + sessionId: id, + capabilities: capsCopy, + trifecta, + riskLevel: classifyRisk(sess.capabilities, trifecta), + eventCount: sess.events.length, + }); + } + return result; + } + + /** + * Return detailed exposure data for a session, or `null` if the + * session does not exist. + * + * Sensitive files and external URLs are deduplicated by detail value. + * Egress attempts are not deduplicated because each attempt is + * independently significant. + */ + getExposure(sessionId: string): SessionExposure | null { + const sess = this.sessions.get(sessionId); + if (!sess) { + return null; + } + + const capsCopy: Record = {}; + for (const [k, v] of sess.capabilities) { + capsCopy[k] = v; + } + const trifecta = isTrifecta(sess.capabilities); + + const eventsCopy: CapabilityEvent[] = [...sess.events]; + + const sensitiveFiles: string[] = []; + const externalUrls: string[] = []; + const egressAttempts: string[] = []; + const seenFiles = new Set(); + const seenUrls = new Set(); + + for (const evt of sess.events) { + switch (evt.capability) { + case Capability.ReadSensitive: + if (evt.detail !== "" && !seenFiles.has(evt.detail)) { + sensitiveFiles.push(evt.detail); + seenFiles.add(evt.detail); + } + break; + case Capability.IngestedUntrusted: + if (evt.detail !== "" && !seenUrls.has(evt.detail)) { + externalUrls.push(evt.detail); + seenUrls.add(evt.detail); + } + break; + case Capability.HasEgress: { + let entry = evt.tool; + if (evt.detail !== "") { + entry += " " + evt.detail; + } + egressAttempts.push(entry); + break; + } + } + } + + return { + sessionId, + capabilities: capsCopy, + trifecta, + riskLevel: classifyRisk(sess.capabilities, trifecta), + events: eventsCopy, + sensitiveFilesAccessed: sensitiveFiles, + externalUrlsContacted: externalUrls, + egressAttempts, + }; + } +} From a17e65dab35db9cb74b1d82517914a6cf5e6d491 Mon Sep 17 00:00:00 2001 From: Anton Mishel Date: Thu, 26 Mar 2026 00:35:18 -0700 Subject: [PATCH 2/5] fix(security): add empty sessionId guard and test for getExposure Align getExposure with getCapabilities and hasTrifecta by adding an explicit empty-string guard. Add corresponding test case. --- nemoclaw/src/security/session-tracker.test.ts | 5 +++++ nemoclaw/src/security/session-tracker.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/nemoclaw/src/security/session-tracker.test.ts b/nemoclaw/src/security/session-tracker.test.ts index b5dc9874e..34e78940a 100644 --- a/nemoclaw/src/security/session-tracker.test.ts +++ b/nemoclaw/src/security/session-tracker.test.ts @@ -185,6 +185,11 @@ describe("getExposure", () => { expect(exposure).toBeNull(); }); + it("returns null for empty session ID", () => { + const exposure = store.getExposure(""); + expect(exposure).toBeNull(); + }); + it("categorizes events into exposure fields", () => { store.record("s1", Capability.ReadSensitive, "cat", "/etc/passwd"); store.record("s1", Capability.IngestedUntrusted, "fetch", "https://untrusted.com/payload"); diff --git a/nemoclaw/src/security/session-tracker.ts b/nemoclaw/src/security/session-tracker.ts index 0bc59820f..a9557d491 100644 --- a/nemoclaw/src/security/session-tracker.ts +++ b/nemoclaw/src/security/session-tracker.ts @@ -205,6 +205,9 @@ export class SessionStore { * independently significant. */ getExposure(sessionId: string): SessionExposure | null { + if (!sessionId) { + return null; + } const sess = this.sessions.get(sessionId); if (!sess) { return null; From 7a8ee44ba36c31b8235d0c83fceb484897db7fe0 Mon Sep 17 00:00:00 2001 From: Anton Mishel Date: Thu, 26 Mar 2026 01:03:02 -0700 Subject: [PATCH 3/5] fix(security): deep-copy events in getExposure, fix docs accuracy - Deep-copy CapabilityEvent objects in getExposure to prevent external mutation of internal session state - Document getExposure null return for empty sessionId - Clarify egressAttempts formatting when detail is empty --- docs/reference/session-tracker.md | 5 +++-- nemoclaw/src/security/session-tracker.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/reference/session-tracker.md b/docs/reference/session-tracker.md index 6f944474a..42fea940d 100644 --- a/docs/reference/session-tracker.md +++ b/docs/reference/session-tracker.md @@ -92,13 +92,14 @@ Return summaries of all active sessions. #### `getExposure(sessionId: string): SessionExposure | null` Return detailed exposure data for a session. -Returns `null` if the session does not exist. +Returns `null` if the session does not exist or `sessionId` is empty. The exposure object categorizes events into three lists. - `sensitiveFilesAccessed` contains deduplicated file paths from `read_sensitive` events. - `externalUrlsContacted` contains deduplicated URLs from `ingested_untrusted` events. -- `egressAttempts` contains every `has_egress` event formatted as `tool + " " + detail`, without deduplication. +- `egressAttempts` contains every `has_egress` event as `tool` when `detail` is empty, or `tool + " " + detail` otherwise. + Egress attempts are not deduplicated. ### `Capability` diff --git a/nemoclaw/src/security/session-tracker.ts b/nemoclaw/src/security/session-tracker.ts index a9557d491..20eea7643 100644 --- a/nemoclaw/src/security/session-tracker.ts +++ b/nemoclaw/src/security/session-tracker.ts @@ -219,7 +219,8 @@ export class SessionStore { } const trifecta = isTrifecta(sess.capabilities); - const eventsCopy: CapabilityEvent[] = [...sess.events]; + // Deep-copy events to prevent external mutation of internal state. + const eventsCopy: CapabilityEvent[] = sess.events.map((e) => ({ ...e })); const sensitiveFiles: string[] = []; const externalUrls: string[] = []; From ce785dc83d9725fef9267141405aff8b103db8ab Mon Sep 17 00:00:00 2001 From: Anton Mishel Date: Thu, 26 Mar 2026 07:27:51 -0700 Subject: [PATCH 4/5] docs: shorten title.page to match H1 convention Align with the pattern used by other reference pages where H1 is the short name and title.page adds descriptive context. --- docs/reference/session-tracker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/session-tracker.md b/docs/reference/session-tracker.md index 42fea940d..19e068825 100644 --- a/docs/reference/session-tracker.md +++ b/docs/reference/session-tracker.md @@ -1,6 +1,6 @@ --- title: - page: "Session Tracker — Behavioral Trifecta Detection" + page: "Session Tracker — Detect Multi-Step Exfiltration Attacks" nav: "Session Tracker" description: "Reference for the behavioral session tracker that detects multi-step exfiltration attacks by tracking three capability classes per agent session." keywords: ["nemoclaw session tracker", "trifecta detection", "behavioral tracking", "exfiltration detection"] From 282315fc046fa51813bbfe48adc8acbf1799d3d4 Mon Sep 17 00:00:00 2001 From: Anton Mishel Date: Thu, 26 Mar 2026 08:02:30 -0700 Subject: [PATCH 5/5] docs: match H1 to title.page frontmatter value --- docs/reference/session-tracker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/session-tracker.md b/docs/reference/session-tracker.md index 19e068825..3ca8c7714 100644 --- a/docs/reference/session-tracker.md +++ b/docs/reference/session-tracker.md @@ -18,7 +18,7 @@ status: published SPDX-License-Identifier: Apache-2.0 --> -# Session Tracker +# Session Tracker — Detect Multi-Step Exfiltration Attacks The session tracker module detects multi-step exfiltration attacks by tracking three capability classes per agent session.