diff --git a/.env.example b/.env.example index 34a4f60f..d0a43fd0 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,6 @@ GITHUB_TOKEN= # Optional: Venice uncensored inference (OpenAI-compatible). https://venice.ai VENICE_API_KEY= +# Opt in only when the local Claude Code configuration is trusted as a separate +# execution authority. Session reuse is disabled by default. +T3MP3ST_TRUST_CLAUDE_SESSION=0 diff --git a/README.md b/README.md index 39f172f2..53130c98 100755 --- a/README.md +++ b/README.md @@ -89,6 +89,13 @@ Slow local agents can be given more room with `T3MP3ST_LOCAL_AGENT_TIMEOUT_MS` for each CLI call, `T3MP3ST_TASK_TIMEOUT_MS` for mission tasks, and `T3MP3ST_GENERAL_TIMEOUT_MS` for planning requests. Values are milliseconds. +Claude Code session reuse is disabled by default because a resumed coding-agent +session may retain provider-side context and ambient tool authority outside the +Arsenal receipt boundary. Operators who explicitly trust their local Claude +Code configuration as a separate execution authority may set +`T3MP3ST_TRUST_CLAUDE_SESSION=1`; reuse remains isolated to one task and the +choice must not be treated as an Arsenal-enforced sandbox. + Or run it **fully offline** on your own model — no key, no cloud. Defaults to Ollama; point it at any OpenAI-compatible server (LM Studio, vLLM, llama.cpp): ```bash diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 1fc8835d..423a6d8a 100755 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -4,7 +4,7 @@ * Basic test suite for core functionality. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Arsenal, BUILTIN_TOOLS, successResult, failResult, createToolContext } from '../arsenal/index.js'; import { FRONTIER_ARSENAL_MILESTONE, SAFE_COMMANDS, TOOL_ADAPTERS, adaptersForFamily, summarizeToolCatalog } from '../arsenal/catalog.js'; import { createKnowledgeBase, createEvasionEngine, CVE_DATABASE, MITRE_TECHNIQUES } from '../stubs/index.js'; @@ -12,6 +12,12 @@ import { AGENT_PROMPT_PACKS, FOREFRONT_PRESSURE_LANES, OPERATOR_RUNBOOKS, forefr import { OpGeneral } from '../general/index.js'; import { LLMBackbone } from '../llm/index.js'; +// Scoped to this file's new per-operator-isolation test (PR #149 review follow-up) — every other +// test here uses provider: 'mock', which never touches this module. +vi.mock('../agent/local-agents.js', () => ({ localAgentChat: vi.fn() })); +import { localAgentChat } from '../agent/local-agents.js'; +const localAgentCli = vi.mocked(localAgentChat); + describe('Arsenal', () => { let arsenal: Arsenal; @@ -465,6 +471,56 @@ describe('Wedged-dispatch timeout backstop', () => { else process.env.T3MP3ST_TASK_TIMEOUT_MS = prev; } }, 15000); + + // PR #149 review follow-up: spawnOperator() used to hand every operator the SAME shared + // this.llm, so LocalAgentAdapter's Claude Code session-resume state (added for --resume) leaked + // across operators — one operator's session could get resumed with a different operator's + // unrelated task content. Each operator now gets its own LLMBackbone from the same config. + it('spawnOperator() gives each operator its OWN LLMBackbone, not a shared one (no cross-operator Claude session leakage)', async () => { + const mod = await import('../index.js'); + const command = new mod.TempestCommand({ + name: 'Isolation Op', + llm: { provider: 'local-agent', model: 'claude' }, + }); + + const opA = command.spawnOperator('Recon-Iso', 'recon'); + const opB = command.spawnOperator('Scanner-Iso', 'scanner'); + + expect((opA as any).llm).not.toBe((opB as any).llm); + expect((opA as any).llm).not.toBe(command.llm); + + localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'a1', session_id: 'sess-A' })); + await (opA as any).llm.chat([{ role: 'user', content: 'hello A' }]); + + // Operator B has never talked to Claude before — its first call must NOT resume A's session. + localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'b1', session_id: 'sess-B' })); + await (opB as any).llm.chat([{ role: 'user', content: 'hello B' }]); + + expect(localAgentCli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + // PR #149 review, round 3 (jmagly): cross-OPERATOR isolation isn't enough — the SAME operator + // running a second task must not resume the first task's session either. The old session belongs + // to a task (and potentially a target) that never earned a place in the new task's context; + // PackBoard is the one sanctioned channel for carrying anything across a task boundary. + it('one operator running TWO SEQUENTIAL tasks does not resume task 1\'s session for task 2 (production-shaped)', async () => { + const mod = await import('../index.js'); + const command = new mod.TempestCommand({ + name: 'Task Boundary Op', + llm: { provider: 'local-agent', model: 'claude' }, + }); + const op = command.spawnOperator('Recon-Task-Boundary', 'recon'); + + localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'task1', session_id: 'sess-task-1' })); + await (op as any).llm.chat([{ role: 'system', content: 'TASK-1' }, { role: 'user', content: 'do task 1' }]); + expect(localAgentCli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + + // AgentLoop.run() builds a brand-new messages array per task — simulate that exactly. + localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'task2', session_id: 'sess-task-2' })); + await (op as any).llm.chat([{ role: 'system', content: 'TASK-2' }, { role: 'user', content: 'do task 2' }]); + + expect(localAgentCli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); }); describe('Codex account provider', () => { diff --git a/src/__tests__/local-agent-path-resolution.test.ts b/src/__tests__/local-agent-path-resolution.test.ts index 0ff33210..b184f637 100644 --- a/src/__tests__/local-agent-path-resolution.test.ts +++ b/src/__tests__/local-agent-path-resolution.test.ts @@ -284,3 +284,121 @@ describe('spawn call-sites use the resolved path (issue #78 — detected-but-uns })).resolves.toBe('--no-tools --model openai-codex/gpt-5|long planning prompt'); }); }); + +/** + * REGRESSION guard (Tier 2 — #139 follow-up, session resume): confirmed empirically against the + * real Claude Code CLI that an unknown/expired --resume id is a hard, fast failure (nonzero exit, + * "No conversation found with session ID: ..." on stderr, no JSON on stdout) — the CLI never falls + * back to a fresh session on its own. localAgentChat does that fallback itself, once. These tests + * drive the REAL function (not mocked) through a fake CLI script so the retry wiring is pinned, + * not just the higher-level LocalAgentAdapter call-shape covered in local-agent-tool-calling.test.ts. + */ +const FAKE_CLI_RESUME_STALE = `#!/bin/sh +cat >/dev/null 2>&1 +case "$*" in + *--resume*) echo "No conversation found with session ID: fake" >&2; exit 1 ;; + *) echo '{"result":"fresh ok","session_id":"new-session-123"}' ;; +esac +`; + +// Echoes exactly what it received on stdin back to stdout on the fresh (non-resume) path — lets a +// test prove WHICH prompt string a fresh-session retry actually received, not just that it succeeded. +const FAKE_CLI_ECHO_ON_FRESH = `#!/bin/sh +prompt=$(cat) +case "$*" in + *--resume*) echo "No conversation found with session ID: fake" >&2; exit 1 ;; + *) printf '%s' "$prompt" ;; +esac +`; + +// A --resume failure that is NOT the stale-session error. If a fallback retry incorrectly fired, +// the fresh (non-resume) branch below would resolve successfully — so a test asserting this +// REJECTS proves no retry happened, not just that the final error text matches. +const FAKE_CLI_NONSTALE_RESUME_ERROR = `#!/bin/sh +cat >/dev/null 2>&1 +case "$*" in + *--resume*) echo "connection refused" >&2; exit 1 ;; + *) echo '{"result":"should not be reached — a retry fired for a non-stale error","session_id":"x"}' ;; +esac +`; + +describe('localAgentChat — stale Claude session fallback (Tier 2)', () => { + it('retries WITHOUT --resume when the resumed session is stale, and succeeds', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const out = await localAgentChat('claude', 'ping', { sessionId: 'stale-uuid', timeoutMs: 4000 }); + expect(JSON.parse(out).result).toBe('fresh ok'); + }); + + it('never adds --resume when no sessionId is supplied (no unnecessary retry path)', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const out = await localAgentChat('claude', 'ping', { timeoutMs: 4000 }); + expect(JSON.parse(out).result).toBe('fresh ok'); + }); + + it('a genuine failure with no session in play still rejects (no unnecessary-retry regression)', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', '#!/bin/sh\ncat >/dev/null 2>&1\necho "boom" >&2\nexit 1\n'); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).rejects.toThrow(/boom/); + }); + + it('a non-stale failure with a session in play propagates without a fallback retry (narrowed trigger, PR #149 review)', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_NONSTALE_RESUME_ERROR); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + // If the (old, unnarrowed) fallback fired here, this would RESOLVE with the fresh branch's + // success text instead of rejecting — a rejection proves no retry happened. + await expect(localAgentChat('claude', 'ping', { sessionId: 'whatever', timeoutMs: 4000 })) + .rejects.toThrow(/connection refused/); + }); + + it('the specific stale-session error still triggers exactly one fallback retry', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const out = await localAgentChat('claude', 'ping', { sessionId: 'stale-uuid', timeoutMs: 4000 }); + expect(JSON.parse(out).result).toBe('fresh ok'); + }); + + // PR #149 review (jmagly): the fresh-session retry has no history at all, so it must receive the + // FULL transcript (fallbackPrompt) — never the delta sized for the (failed) resumed attempt. + it('the fresh-session retry receives fallbackPrompt, not the delta prompt of the failed resumed attempt', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_ECHO_ON_FRESH); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const out = await localAgentChat('claude', 'DELTA-ONLY-CONTENT', { + sessionId: 'stale-uuid', + fallbackPrompt: 'FULL-TRANSCRIPT-CONTENT', + timeoutMs: 4000, + }); + + expect(out).toBe('FULL-TRANSCRIPT-CONTENT'); + expect(out).not.toContain('DELTA-ONLY-CONTENT'); + }); + + it('falls back to the (delta) prompt itself when no fallbackPrompt is supplied', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_ECHO_ON_FRESH); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const out = await localAgentChat('claude', 'ONLY-PROMPT-CONTENT', { sessionId: 'stale-uuid', timeoutMs: 4000 }); + expect(out).toBe('ONLY-PROMPT-CONTENT'); + }); +}); diff --git a/src/__tests__/local-agent-tool-calling.test.ts b/src/__tests__/local-agent-tool-calling.test.ts index fcb26c85..6617045e 100644 --- a/src/__tests__/local-agent-tool-calling.test.ts +++ b/src/__tests__/local-agent-tool-calling.test.ts @@ -4,7 +4,7 @@ * turn 0 and every operator abstains without ever running the Arsenal. These tests pin the fix AND * the parser-hardening from the PR #16 audit (over-match, ReDoS, drift-abstains, string args, Codex coverage). */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; // Mock the local-agent CLI bridge (LocalAgentAdapter) and the codex spawn/file read (CodexAdapter). @@ -23,7 +23,7 @@ vi.mock('fs/promises', async (orig) => { return { ...actual, readFile: vi.fn() }; }); -import { parseTextToolCalls, LLMBackbone } from '../llm/index.js'; +import { parseTextToolCalls, LLMBackbone, type LLMMessage } from '../llm/index.js'; import { localAgentChat } from '../agent/local-agents.js'; import { readFile } from 'fs/promises'; const cli = vi.mocked(localAgentChat); @@ -195,6 +195,200 @@ describe('Claude local-agent usage accounting (#139)', () => { }); }); +describe('Claude local-agent session resume (Tier 2)', () => { + beforeEach(() => { process.env.T3MP3ST_TRUST_CLAUDE_SESSION = '1'; }); + afterEach(() => { delete process.env.T3MP3ST_TRUST_CLAUDE_SESSION; }); + + it('the first call on a fresh adapter passes no sessionId', async () => { + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await claudeBackbone().chat([{ role: 'user', content: 'hello' }]); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + it('captures session_id from the response and passes it as --resume on a LATER call within the SAME task', async () => { + const be = claudeBackbone(); + // AgentLoop's real shape: one array, grown via push() across a task's iterations. + const messages: LLMMessage[] = [{ role: 'user', content: 'hello' }]; + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat(messages); + + messages.push({ role: 'assistant', content: 'first' }, { role: 'user', content: 'again' }); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-1' })); + await be.chat(messages); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: 'sess-1' }); + }); + + it('resetLocalAgentSession() drops the tracked session so the next call starts fresh, even mid-task', async () => { + const be = claudeBackbone(); + // SAME array both calls, so this isolates the EXPLICIT reset from the automatic task-boundary + // reset (a different array would reset on its own regardless of this call). + const messages: LLMMessage[] = [{ role: 'user', content: 'hello' }]; + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat(messages); + + be.resetLocalAgentSession(); + + messages.push({ role: 'assistant', content: 'first' }, { role: 'user', content: 'again' }); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-2' })); + await be.chat(messages); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + it('a separate LLMBackbone instance never inherits another instance\'s session (per-operator isolation)', async () => { + const opA = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'a1', session_id: 'sess-a' })); + await opA.chat([{ role: 'user', content: 'hello' }]); + + const opB = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'b1', session_id: 'sess-b' })); + await opB.chat([{ role: 'user', content: 'hello' }]); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + it('a resumed session with no usable id in the envelope keeps the PRIOR session for the next call in the SAME task', async () => { + const be = claudeBackbone(); + const messages: LLMMessage[] = [{ role: 'user', content: 'hello' }]; + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat(messages); + + messages.push({ role: 'assistant', content: 'first' }, { role: 'user', content: 'again' }); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second' })); // no session_id this time + await be.chat(messages); + + messages.push({ role: 'assistant', content: 'second' }, { role: 'user', content: 'once more' }); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'third', session_id: 'sess-1' })); + await be.chat(messages); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: 'sess-1' }); + }); + + it('non-claude local agents (codex CLI via local-agent provider) never receive a sessionId', async () => { + cli.mockResolvedValueOnce('done'); + await localBackbone().chat([{ role: 'user', content: 'hello' }]); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + // PR #149 review (jmagly): formatPrompt() always serialized the WHOLE messages array, so a + // resumed call duplicated the transcript into a session that already had it — the exact resend + // this feature exists to eliminate. These pin the fix: same-array (same task) reuse sends only + // the NEW messages once a session exists; a different array (a new task) still gets everything. + it('a resumed call on the SAME growing messages array sends only the delta, not the full transcript', async () => { + const be = claudeBackbone(); + const messages: LLMMessage[] = [ + { role: 'system', content: 'SYSTEM-PROMPT-MARKER' }, + { role: 'user', content: 'USER-TASK-MARKER' }, + ]; + + cli.mockResolvedValueOnce(JSON.stringify({ + result: '{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}', + session_id: 'sess-1', + })); + await be.chatWithTools(messages, TOOLS as never); + + // AgentLoop's real growth pattern: push the assistant turn + tool result onto the SAME array. + messages.push({ role: 'assistant', content: 'reasoning', toolCalls: [{ id: 'c1', name: 'nmap_scan', arguments: { target: 'x' } }] }); + messages.push({ role: 'tool', content: 'TOOL-RESULT-MARKER', toolCallId: 'c1', name: 'nmap_scan' }); + + cli.mockResolvedValueOnce(JSON.stringify({ result: 'Final debrief.', session_id: 'sess-1' })); + await be.chatWithTools(messages, TOOLS as never); + + const secondPrompt = String(cli.mock.calls.at(-1)?.[1] || ''); + expect(secondPrompt).not.toContain('SYSTEM-PROMPT-MARKER'); + expect(secondPrompt).not.toContain('USER-TASK-MARKER'); + expect(secondPrompt).toContain('TOOL-RESULT-MARKER'); + expect(secondPrompt).toContain('ACTION CONTRACT'); // tool contract retained on the delta call + }); + + // PR #149 review (jmagly, round 3): a new task must not just switch from delta to full-prompt + // sending while still resuming the OLD session — it must start a genuinely FRESH session. The + // old session belongs to a different task (and potentially a different target); resuming it + // would carry that task's system prompt, target data, and tool output — including anything + // attacker-controlled — into a task that never earned it, bypassing PackBoard as the one + // sanctioned cross-task channel. One operator, two sequential tasks: task 2 gets no --resume. + it('a NEW task (different messages array) starts a genuinely FRESH session, not a resume of the prior task', async () => { + const be = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat([{ role: 'system', content: 'TASK-ONE-SYSTEM' }, { role: 'user', content: 'TASK-ONE-USER' }]); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + + // A brand-new task builds a brand-new array (AgentLoop.run() does this per task). + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-2' })); + await be.chat([{ role: 'system', content: 'TASK-TWO-SYSTEM' }, { role: 'user', content: 'TASK-TWO-USER' }]); + + const secondCall = cli.mock.calls.at(-1); + const secondPrompt = String(secondCall?.[1] || ''); + expect(secondPrompt).toContain('TASK-TWO-SYSTEM'); + expect(secondPrompt).toContain('TASK-TWO-USER'); + expect(secondPrompt).not.toContain('TASK-ONE-SYSTEM'); // task 1's content never enters the wire prompt either + // The core assertion: task 2's session id is NOT task 1's — no --resume across the task boundary. + expect(secondCall?.[2]).toMatchObject({ sessionId: undefined, fallbackPrompt: undefined }); + }); + + it('passes fallbackPrompt (the FULL transcript) alongside a delta send, so a stale-session retry never gets the delta', async () => { + const be = claudeBackbone(); + const messages: LLMMessage[] = [{ role: 'system', content: 'S' }, { role: 'user', content: 'U' }]; + + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat(messages); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ fallbackPrompt: undefined }); // nothing to fall back from yet + + messages.push({ role: 'assistant', content: 'ok' }); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-1' })); + await be.chat(messages); + + const call = cli.mock.calls.at(-1); + const deltaPrompt = String(call?.[1] || ''); + const fallbackPrompt = String((call?.[2] as { fallbackPrompt?: string } | undefined)?.fallbackPrompt || ''); + expect(deltaPrompt).not.toContain('### SYSTEM\nS'); // delta omits the first task's system/user content + expect(deltaPrompt).not.toContain('### USER\nU'); + expect(fallbackPrompt).toContain('### SYSTEM\nS'); // fallback carries the FULL transcript a fresh session would need + expect(fallbackPrompt).toContain('### USER\nU'); + }); + + // PR #149 review follow-up: a real envelope's promptTokens on a resumed call reflects the WHOLE + // cached conversation (input + cache_creation + cache_read), not the wire size of the delta — + // confirmed empirically. The character-estimate fallback (used only when the envelope fails to + // parse) must approximate that same full-conversation size, or it silently undercounts the + // budget check by an order of magnitude on a delta call. + it('the usage estimate fallback reflects the FULL transcript, not the delta, on a mid-session parse failure', async () => { + const be = claudeBackbone(); + const messages: LLMMessage[] = [ + { role: 'system', content: 'S'.repeat(2000) }, + { role: 'user', content: 'U'.repeat(2000) }, + ]; + + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat(messages); + + messages.push({ role: 'assistant', content: 'ok' }); + cli.mockResolvedValueOnce('not json'); // envelope parse failure -> falls to the estimate + const res = await be.chat(messages); + + // A delta-only estimate would be tiny (just "ok" plus the preamble/tool contract). The full + // ~4000 chars of original system/user content must show up in the estimate too. + expect(res.usage?.promptTokens).toBeGreaterThan(900); + }); +}); + +describe('Claude local-agent session trust boundary', () => { + afterEach(() => { delete process.env.T3MP3ST_TRUST_CLAUDE_SESSION; }); + + it('does not retain or resume an opaque Claude session by default', async () => { + const be = claudeBackbone(); + const messages: LLMMessage[] = [{ role: 'user', content: 'first' }]; + cli.mockResolvedValueOnce(JSON.stringify({ result: 'one', session_id: 'ambient-session' })); + await be.chat(messages); + messages.push({ role: 'user', content: 'second' }); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'two', session_id: 'another-session' })); + await be.chat(messages); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined, fallbackPrompt: undefined }); + }); +}); + describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => { it('returns toolCalls when codex emits the contract', async () => { fileRead.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```'); diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index b2699b65..3b4f7d69 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -489,7 +489,7 @@ export function pingLocalAgent(id: string, prompt?: string, timeoutMs?: number): * clean reply, while Hermes takes the prompt as an arg. Provider keys are stripped so each CLI uses its own * login (no API key needed). Throws on non-zero exit / timeout so the LLMBackbone retry/fallback fires. */ -export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number } = {}): Promise { +export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number; sessionId?: string; fallbackPrompt?: string } = {}): Promise { const spec = getSpec(id); if (!spec) return Promise.reject(new Error(`unknown local agent: ${id}`)); // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). @@ -498,6 +498,7 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000); let args: string[]; + let claudeArgsNoResume: string[] | null = null; let viaStdin = true; let outFile: string | null = null; let workDir: string | null = null; @@ -505,7 +506,12 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin // json (not text): the envelope carries REAL per-call token usage (input/output tokens // plus prompt-cache creation/read) that LocalAgentAdapter.chat() parses to drive // AgentLoop's token budget check. text mode reports no usage at all. - args = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])]; + claudeArgsNoResume = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])]; + // --resume continues a prior Claude Code session (LocalAgentAdapter tracks the id) so the CLI + // carries the accumulated transcript itself instead of the caller resending it every turn. + args = opts.sessionId + ? ['-p', '--output-format', 'json', '--resume', opts.sessionId, ...(model ? ['--model', model] : [])] + : claudeArgsNoResume; } else if (id === 'codex') { workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-')); outFile = join(workDir, 'reply.txt'); @@ -523,8 +529,8 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } }; const resolvedBin = resolveBin(spec.bin) || spec.bin; - return new Promise((resolve, reject) => { - const child = spawnAgent(resolvedBin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); + const runOnce = (argv: string[], promptToSend: string): Promise => new Promise((resolve, reject) => { + const child = spawnAgent(resolvedBin, argv, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); let out = ''; let errOut = ''; let done = false; @@ -543,6 +549,24 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800))); }); }); - if (viaStdin && child.stdin) { child.stdin.write(prompt); child.stdin.end(); } + if (viaStdin && child.stdin) { child.stdin.write(promptToSend); child.stdin.end(); } + }); + + const first = runOnce(args, prompt); + if (!claudeArgsNoResume || args === claudeArgsNoResume) return first; + // A resumed Claude session can go stale (CLI storage pruned, different session dir, expired). + // Confirmed empirically: an unknown/invalid --resume id is a hard, fast failure (nonzero exit, + // "No conversation found with session ID: ..." on stderr, no JSON on stdout) — the CLI never + // falls back to a fresh session on its own. Do that fallback here, once, rather than failing + // the whole task over a stale id. The fresh session has no history, so it MUST get the full + // transcript (fallbackPrompt), never the delta `prompt` that was sized for the resumed attempt. + // + // Only retry for a CONFIRMED stale session (the exact empirical error text above). Any other + // failure — a network blip, a real auth error, a timeout — propagates as-is instead of silently + // doubling latency and cost on a retry that would not fix the actual problem (PR #149 review). + return first.catch((err) => { + const message = err instanceof Error ? err.message : String(err); + if (!/no conversation found/i.test(message)) throw err; + return runOnce(claudeArgsNoResume as string[], opts.fallbackPrompt ?? prompt); }); } diff --git a/src/index.ts b/src/index.ts index b9d162dd..1999bbdf 100755 --- a/src/index.ts +++ b/src/index.ts @@ -293,6 +293,11 @@ export class TempestCommand extends EventEmitter { public readonly comms: CommsChannel; public readonly analysis: AnalysisEngine; public readonly llm: LLMBackbone; + // The config `this.llm` was built from — kept so spawnOperator() can build each operator its + // OWN LLMBackbone instead of sharing this one. LocalAgentAdapter carries per-conversation Claude + // Code session state (session id, sent-message tracking); sharing one instance across operators + // let one operator's session state leak into another's calls (PR #149 review follow-up). + private readonly llmConfig: TempestConfig['llm']; /** * Stub modules (interface-only). @@ -369,6 +374,7 @@ export class TempestCommand extends EventEmitter { this.taskTimeoutMs = TempestCommand.resolveTaskTimeoutMs(config.llm.provider); // Initialize LLM backbone + this.llmConfig = config.llm; this.llm = new LLMBackbone(config.llm); // Initialize core subsystems @@ -999,6 +1005,12 @@ export class TempestCommand extends EventEmitter { this.mission.generateNextPhaseTasks(target.address); } + // Local-agent operators are pre-spawned once and reused for the whole mission (see + // autoSpawnForPhase below), so a resumed CLI session (Claude Code --resume) would + // otherwise span every phase. Drop it here so each phase starts a fresh session instead + // of one unbounded session for the entire kill chain. + for (const op of this.cell.getAllOperators()) op.resetLLMSession(); + // Auto-spawn operators for the new phase const nextPhase = mission.currentPhase; this.autoSpawnForPhase(nextPhase); @@ -1236,16 +1248,21 @@ export class TempestCommand extends EventEmitter { callsign: string, archetype: OperatorArchetype ): OperatorAgent { - const operator = this.cell.spawnOperator(callsign, archetype); + // Each operator gets its OWN LLMBackbone (same config as the mission's) rather than sharing + // this.llm — see the llmConfig field comment. Used for BOTH this operator's AgentLoop and its + // own decompose-on-failure fallback, so the two stay consistent with each other while staying + // isolated from every other operator in the cell. + const operatorLLM = new LLMBackbone(this.llmConfig); + const operator = this.cell.spawnOperator(callsign, archetype, undefined, operatorLLM); this.setupOperatorEvents(operator); // Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools = // the curated per-operator tool allowlist). toolCategories stays as a coarse fallback. const profile = ARCHETYPE_PROFILES[archetype]; - const maxIterations = this.llm.getProvider() === 'local-agent' + const maxIterations = operatorLLM.getProvider() === 'local-agent' ? LOCAL_AGENT_MAX_ITERATIONS : DEFAULT_AGENT_MAX_ITERATIONS; - const agentLoop = new AgentLoop(this.llm, this.arsenal, { + const agentLoop = new AgentLoop(operatorLLM, this.arsenal, { maxIterations, maxTokens: 50000, toolCategories: profile.toolCategories, diff --git a/src/llm/index.ts b/src/llm/index.ts index ccb380bd..59707224 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -49,6 +49,8 @@ export interface LLMProviderAdapter { chat(messages: LLMMessage[], options?: ChatOptions): Promise; stream?(messages: LLMMessage[], options?: ChatOptions): AsyncGenerator; validateConfig(): { valid: boolean; error?: string }; + /** Drop any resumed backend session so the next chat() starts clean. No-op where not applicable. */ + resetSession?(): void; } export interface ChatOptions { @@ -1334,6 +1336,7 @@ class CodexAdapter implements LLMProviderAdapter { // see and which measured 4-25k tokens on a single trivial call in testing. interface ClaudeJsonEnvelope { result?: string; + session_id?: string; usage?: { input_tokens?: number; output_tokens?: number; @@ -1343,7 +1346,7 @@ interface ClaudeJsonEnvelope { } /** Parse the envelope; invalid usage is omitted so the caller can retain content and estimate. */ -function parseClaudeJsonEnvelope(raw: string): { content: string; usage?: LLMResponse['usage'] } | null { +function parseClaudeJsonEnvelope(raw: string): { content: string; sessionId?: string; usage?: LLMResponse['usage'] } | null { let json: ClaudeJsonEnvelope; try { json = JSON.parse(raw); @@ -1351,16 +1354,17 @@ function parseClaudeJsonEnvelope(raw: string): { content: string; usage?: LLMRes return null; } if (typeof json.result !== 'string') return null; + const sessionId = typeof json.session_id === 'string' && json.session_id ? json.session_id : undefined; const u = json.usage || {}; const tokenValues = [u.input_tokens, u.output_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens]; if (!tokenValues.some((v) => v !== undefined) || tokenValues.some((v) => v !== undefined && (!Number.isFinite(v) || v < 0))) { - return { content: json.result }; + return { content: json.result, sessionId }; } const promptTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0); const completionTokens = u.output_tokens ?? 0; - if (promptTokens + completionTokens === 0) return { content: json.result }; - return { content: json.result, usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens } }; + if (promptTokens + completionTokens === 0) return { content: json.result, sessionId }; + return { content: json.result, sessionId, usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens } }; } // Character-based fallback ONLY — used when the real envelope can't be parsed (older CLI, @@ -1379,7 +1383,30 @@ function estimateUsage(promptText: string, completionText: string): LLMResponse[ class LocalAgentAdapter implements LLMProviderAdapter { name = 'local-agent'; private config: LLMConfig; + // Claude Code session to --resume (see local-agents.ts). Lives as long as this adapter does — + // one LocalAgentAdapter is held for an operator's whole life, so this naturally spans every task + // until the caller (TempestCommand, on phase advance) calls resetSession() to start a new one. + private claudeSessionId?: string; + // The exact `messages` array object sent last call, and how many of its entries were sent. + // A resumed session already HAS everything up to that point server-side — resending it would + // duplicate the transcript into the session instead of avoiding the resend (PR #149 review). + // Reference equality is deliberate: AgentLoop grows ONE array via push() across a task's + // iterations, so `messages === lastSentMessages` means "same task, continuing" and the tail + // past `lastSentCount` is the delta. A DIFFERENT array (a new task) means the resumed session + // has never seen any of it, so it goes out in full even though the CLI session itself resumes. + private lastSentMessages?: LLMMessage[]; + private lastSentCount = 0; constructor(config: LLMConfig) { this.config = config; } + private trustedClaudeSessionReuse(): boolean { + return ['1', 'true', 'yes', 'on'].includes( + (process.env.T3MP3ST_TRUST_CLAUDE_SESSION || '').trim().toLowerCase(), + ); + } + resetSession(): void { + this.claudeSessionId = undefined; + this.lastSentMessages = undefined; + this.lastSentCount = 0; + } // Split "agentId[::model]" → { agentId, agentModel }. No separator = just the agent id (CLI default model). private parseAgentSpec(): { agentId: string; agentModel?: string } { const raw = this.config.model || 'codex'; @@ -1409,17 +1436,51 @@ class LocalAgentAdapter implements LLMProviderAdapter { } async chat(messages: LLMMessage[], options?: ChatOptions): Promise { const { agentId, agentModel } = this.parseAgentSpec(); - const prompt = this.formatPrompt(messages, options); + const reuseSession = agentId === 'claude' && this.trustedClaudeSessionReuse(); + if (!reuseSession) this.resetSession(); + // Task boundary: a DIFFERENT messages array means AgentLoop.run() started a new task (it builds + // one fresh array per task). Resuming across that boundary would carry the prior task's system + // prompt, target data, and tool output — including anything attacker-controlled — into a task + // that never earned it, outside T3MP3ST's one sanctioned cross-task channel (PackBoard). Only + // resume WITHIN one task's own growing transcript; a new task always starts a fresh session + // (PR #149 review). + if (reuseSession && this.lastSentMessages !== undefined && messages !== this.lastSentMessages) { + this.claudeSessionId = undefined; + } + const fullPrompt = this.formatPrompt(messages, options); + // Resuming AND still the same task's (same array object) growing transcript → send only the + // NEW messages since last call. A resumed session already has everything up to lastSentCount; + // resending it would duplicate the transcript into the session rather than avoiding the resend. + // A different array (a new task) or no session yet falls through to the full prompt. + const isDelta = reuseSession && this.claudeSessionId !== undefined && messages === this.lastSentMessages; + const sendPrompt = isDelta ? this.formatPrompt(messages.slice(this.lastSentCount), options) : fullPrompt; const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined; - const raw = (await localAgentChat(agentId, prompt, { model: agentModel, timeoutMs })).trim(); + const raw = (await localAgentChat(agentId, sendPrompt, { + model: agentModel, + timeoutMs, + sessionId: reuseSession ? this.claudeSessionId : undefined, + // The stale-session fallback starts a genuinely fresh session with no history — it needs + // the FULL transcript, never the delta computed for the (failed) resumed attempt. + fallbackPrompt: isDelta ? fullPrompt : undefined, + })).trim(); + if (reuseSession) { this.lastSentMessages = messages; this.lastSentCount = messages.length; } // Claude requests --output-format json (see local-agents.ts) so this parses to REAL usage. // Anything else (parse failure, or a non-claude agent) falls back to the raw text — claude // additionally gets a character-based usage ESTIMATE so its budget check is never blind again. const parsed = agentId === 'claude' ? parseClaudeJsonEnvelope(raw) : null; const content = parsed ? parsed.content : raw; + // Estimate from the FULL prompt, not sendPrompt: a real envelope's promptTokens on a delta + // call is large (input + cache_creation + cache_read for the WHOLE resumed context, confirmed + // empirically — a resumed call's real promptTokens tracks the full conversation, not the wire + // size of the delta). Estimating from the tiny delta text instead would undercount the budget + // check by an order of magnitude in the one case this estimate exists to cover (PR #149 review). const usage = agentId === 'claude' - ? (parsed?.usage ?? estimateUsage(prompt, parsed?.content ?? raw)) + ? (parsed?.usage ?? estimateUsage(fullPrompt, parsed?.content ?? raw)) : undefined; + // Carry the session forward so the NEXT call (--resume) picks up where this one left off. + // Empirically stable across --resume (confirmed against the real CLI), but re-capture anyway + // in case a future CLI version rotates it. + if (reuseSession && parsed?.sessionId) this.claudeSessionId = parsed.sessionId; // Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the // ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer. const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined; @@ -1595,6 +1656,11 @@ export class LLMBackbone extends EventEmitter { return this.config.model; } + /** Drop any resumed local-agent session (e.g. Claude Code's --resume id) so the next chat() starts fresh. */ + resetLocalAgentSession(): void { + this.adapter.resetSession?.(); + } + /** * Validate the configuration */ diff --git a/src/operators/index.ts b/src/operators/index.ts index 32abc634..4ae2a284 100755 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -413,6 +413,16 @@ export class OperatorAgent extends EventEmitter { this.agentLoop = agentLoop; } + /** + * Drop any resumed local-agent session (e.g. Claude Code's --resume id). Called at kill-chain + * phase boundaries: local-agent operators are pre-spawned once and reused for the whole + * mission (auto-spawn-per-phase is disabled for local-agent), so nothing else would naturally + * end a resumed session between phases. + */ + resetLLMSession(): void { + this.llm?.resetLocalAgentSession(); + } + /** Attach the shared pack board so this operator sees the swarm's live lead-board (Phase-2). */ attachBoard(board: PackBoard): void { this.board = board; @@ -839,7 +849,8 @@ export class OperatorCell extends EventEmitter { spawnOperator( callsign: string, archetype: OperatorArchetype, - config?: Partial + config?: Partial, + llm?: LLMBackbone ): OperatorAgent { if (this.operators.size >= this.maxOperators) { this.emit('cell:capacity_warning', { @@ -856,7 +867,7 @@ export class OperatorCell extends EventEmitter { } } - const operator = new OperatorAgent(callsign, archetype, config, this.llm); + const operator = new OperatorAgent(callsign, archetype, config, llm ?? this.llm); // Forward operator events operator.on('status:changed', ({ oldStatus }) => {