Merge remote-tracking branch 'upstream/main' into pr-70

# Conflicts:
#	src/config/index.ts
#	src/llm/index.ts
#	src/server.ts
#	src/setup.ts
#	src/types/index.ts
This commit is contained in:
Joseph Magly committed 2026-07-17 01:39:59 -04:00
commit be3eb7cb44
75 files changed
+7788 -586

No files matched your search

+7 -1
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs';
import { join } from 'path';
const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8');
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
const setupScript = readFileSync(join(process.cwd(), 'scripts/setup-api.sh'), 'utf8');
function sourceBlock(startMarker: string, endMarker: string): string {
@@ -33,6 +34,11 @@ describe('API key environment handling hardening', () => {
expect(block).toContain("join(homedir(), '.t3mp3st', '.env')");
});
it('the API server does not re-enable caller-cwd dotenv loading', () => {
expect(serverSource).not.toMatch(/from ['"]dotenv['"]/);
expect(serverSource).not.toMatch(/\bdotenv\.config\s*\(/);
});
it('setup-api.sh writes the same T3MP3ST-owned env file that ConfigManager reads', () => {
const block = configLoadEnvBlock();
@@ -52,7 +58,7 @@ describe('API key environment handling hardening', () => {
it('exportConfig redacts every supported provider key slot', () => {
const block = exportConfigBlock();
for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek']) {
for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'litellm']) {
expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`);
}
});
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { TOOL_ADAPTERS, type ToolAdapter } from '../arsenal/catalog.js';
import { BUILTIN_TOOLS, EXTERNAL_TOOLS } from '../arsenal/index.js';
import {
isMintable,
adapterToCustomTool,
toolNameFor,
type AdapterToolDeps,
} from '../arsenal/adapter-tools.js';
// ─────────────────────────────────────────────────────────────────────────────
// ARSENAL COUNT HONESTY — the "advertised = real, and nothing is silently dead".
//
// The arsenal advertises "108 tools". verify-claims defends that number by counting
// `id:`/`name:` source lines — a SOURCE-LINE count, which would keep passing even if
// an entry became uncallable. This test locks the number to the REAL registered arrays
// and, more importantly, pins the invariant that makes the count honest:
//
// an adapter is OFF the generic callable surface (adapterToCustomTool → null) IFF it
// is EXPLICITLY gated by its execution mode (catalog_only / import_only).
//
// i.e. a tool can be counted-but-not-generically-callable ONLY on purpose (metasploit /
// hydra reachable solely via the approval-gated post-ex drivers; bloodhound import-only;
// pacu/frida catalog-only until narrow approved runners exist),
// never by a silent DEFAULT/mint slip. Sibling of no-phantom-tools / stub-honesty.
// ─────────────────────────────────────────────────────────────────────────────
const deps: AdapterToolDeps = {
isToolAvailable: async () => true,
runSubprocess: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
};
const isGated = (a: ToolAdapter) =>
a.execution === 'catalog_only' || a.execution === 'import_only';
describe('arsenal count honesty (advertised = real registered surface)', () => {
it('the advertised "108 tools" is the real registered surface, not a source-line count', () => {
const total = TOOL_ADAPTERS.length + BUILTIN_TOOLS.length + EXTERNAL_TOOLS.length;
// Locks the headline to code. If the arsenal grows/shrinks, update this AND the
// README / verify-claims headline together — that is the point of the lock.
expect(
total,
`arsenal size drifted from the advertised 108 (adapters=${TOOL_ADAPTERS.length}, ` +
`built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` +
'update the README / verify-claims headline to match',
).toBe(108);
expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate
});
it('an adapter is off the generic callable surface IFF it is explicitly gated (no silent-dead entry)', () => {
const silentlyDead: string[] = []; // counted, not gated, yet mints null
const wronglyGated: string[] = []; // gated, yet somehow mints a callable tool
for (const a of TOOL_ADAPTERS) {
const callable = adapterToCustomTool(a, deps) !== null;
if (!callable && !isGated(a)) silentlyDead.push(`${a.id} (${a.execution})`);
if (callable && isGated(a)) wronglyGated.push(`${a.id} (${a.execution})`);
// isMintable must agree with actual callability — no divergence.
expect(isMintable(a), `isMintable disagrees with callability for ${a.id}`).toBe(callable);
}
expect(silentlyDead, `counted-but-silently-uncallable adapters: ${silentlyDead.join(', ')}`).toEqual([]);
expect(wronglyGated, `gated adapters that are still generically callable: ${wronglyGated.join(', ')}`).toEqual([]);
});
it('the gated set is exactly the documented post-ex / import tools', () => {
const gated = TOOL_ADAPTERS.filter(isGated);
// metasploit + hydra are catalog_only (callable only via the approval-gated hand-written
// post-ex drivers); pacu/frida are catalog_only pending narrow approved runners; bloodhound is
// import_only (collector output is imported, never run here).
expect(gated.map((a) => a.id).sort()).toEqual(['bloodhound', 'frida', 'hydra', 'metasploit', 'pacu']);
expect(TOOL_ADAPTERS.find((a) => a.id === 'bloodhound')?.execution).toBe('import_only');
// Every gated adapter is genuinely off the generic surface.
for (const a of gated) {
expect(adapterToCustomTool(a, deps), `${a.id} must not be generically callable`).toBeNull();
}
});
it('every generically-callable adapter mints a real, uniquely-named tool', () => {
const names = new Set<string>();
for (const t of [...BUILTIN_TOOLS, ...EXTERNAL_TOOLS]) names.add(t.name);
for (const a of TOOL_ADAPTERS) {
const tool = adapterToCustomTool(a, deps);
if (!tool) continue; // gated — covered above
expect(typeof tool.handler, `${a.id} minted without a handler`).toBe('function');
expect(tool.name, `${a.id} minted an unexpected name`).toBe(toolNameFor(a));
// A minted adapter that shadows a hand-written tool name is deduped by buildAdapterTools;
// record it here to prove no two DISTINCT registered surfaces collide unexpectedly.
names.add(tool.name);
}
// Built-ins + externals themselves carry no duplicate names.
const builtinNames = [...BUILTIN_TOOLS, ...EXTERNAL_TOOLS].map((t) => t.name);
expect(new Set(builtinNames).size).toBe(builtinNames.length);
});
});
+1
View File
@@ -0,0 +1 @@
[{"type":"V","inject_type":"inHTML-URL","severity":"high","cwe":"CWE-79","method":"GET","param":"q","data":"https://target.example.com/?q=<script>alert(1)</script>","evidence":"reflected in HTML body","poc":"<script>alert(1)</script>","message_str":"reflected XSS"}]
+1
View File
@@ -0,0 +1 @@
{"commandline":"ffuf -u https://target.example.com/FUZZ -w common.txt","results":[{"input":{"FUZZ":"admin"},"status":200,"length":1234,"words":56,"url":"https://target.example.com/admin"},{"input":{"FUZZ":"login"},"status":301,"length":0,"words":1,"url":"https://target.example.com/login"}]}
+1
View File
@@ -0,0 +1 @@
[{"RuleID":"aws-access-token","Description":"AWS Access Key AKIAIOSFODNN7EXAMPLE committed to source","File":"config/prod.env","StartLine":12,"Match":"REDACTED","Secret":"REDACTED","Commit":"abc1234","Author":"dev"}]
+1
View File
@@ -0,0 +1 @@
{"matches":[{"vulnerability":{"id":"CVE-2022-23529","severity":"High","description":"jsonwebtoken is vulnerable to signature verification bypass."},"artifact":{"name":"jsonwebtoken","version":"8.5.1","type":"npm"}}]}
+2
View File
@@ -0,0 +1,2 @@
{"url":"https://target.example.com","status_code":200,"title":"Example Corp","tech":["Nginx","PHP","jQuery"],"webserver":"nginx"}
{"url":"https://api.example.com","status_code":403,"title":"Forbidden"}
+3
View File
@@ -0,0 +1,3 @@
{"timestamp":"2026-01-01T00:00:00Z","request":{"method":"GET","endpoint":"https://target.example.com/"}}
{"timestamp":"2026-01-01T00:00:01Z","request":{"method":"GET","endpoint":"https://target.example.com/about"}}
{"timestamp":"2026-01-01T00:00:02Z","request":{"method":"GET","endpoint":"https://target.example.com/contact"}}
+3
View File
@@ -0,0 +1,3 @@
[INF] nuclei banner line that is not JSON and must be skipped
{"template-id":"CVE-2021-44228","info":{"name":"Apache Log4j RCE","severity":"critical","description":"Log4j JNDI remote code execution via crafted header","classification":{"cve-id":["CVE-2021-44228"],"cwe-id":["CWE-502"],"cvss-score":10.0},"remediation":"Upgrade log4j to 2.17.0 or later"},"host":"https://target.example.com","matched-at":"https://target.example.com/api"}
{"template-id":"nginx-version","info":{"name":"Nginx Version Disclosure","severity":"info"},"host":"https://target.example.com","matched-at":"https://target.example.com"}
+1
View File
@@ -0,0 +1 @@
{"results":[{"check_id":"python.lang.security.dangerous-subprocess-use","path":"app/run.py","start":{"line":42},"end":{"line":42},"extra":{"message":"Detected subprocess call with shell=True — command injection risk","severity":"ERROR","metadata":{"cwe":["CWE-78: OS Command Injection"],"owasp":["A03:2021 - Injection"],"references":["https://owasp.org/Top10/A03_2021-Injection/"]}}}],"errors":[]}
+1
View File
@@ -0,0 +1 @@
{"SchemaVersion":2,"Results":[{"Target":"package-lock.json","Class":"lang-pkgs","Type":"npm","Vulnerabilities":[{"VulnerabilityID":"CVE-2021-3749","PkgName":"axios","InstalledVersion":"0.21.1","FixedVersion":"0.21.2","Severity":"HIGH","Title":"axios: Regular expression denial of service via trim","Description":"axios is vulnerable to Inefficient Regular Expression Complexity (ReDoS).","CweIDs":["CWE-400"]}]}]}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'vitest';
import { config, AVAILABLE_MODELS } from '../config/index.js';
import { createLiteLLMBackbone } from '../llm/index.js';
const PREV_BASE = process.env.LITELLM_BASE_URL;
const PREV_KEY = process.env.LITELLM_API_KEY;
const PREV_MODEL = process.env.LITELLM_MODEL;
describe('LiteLLM provider wiring (#45)', () => {
afterEach(() => {
if (PREV_BASE === undefined) delete process.env.LITELLM_BASE_URL;
else process.env.LITELLM_BASE_URL = PREV_BASE;
if (PREV_KEY === undefined) delete process.env.LITELLM_API_KEY;
else process.env.LITELLM_API_KEY = PREV_KEY;
if (PREV_MODEL === undefined) delete process.env.LITELLM_MODEL;
else process.env.LITELLM_MODEL = PREV_MODEL;
});
it('resolves LiteLLM as an OpenAI-compatible proxy from env without requiring a key', () => {
process.env.LITELLM_BASE_URL = 'http://localhost:4000/v1';
delete process.env.LITELLM_API_KEY;
process.env.LITELLM_MODEL = 'anthropic/claude-sonnet-4.5';
const cfg = config.getLLMConfig('litellm');
expect(cfg.provider).toBe('litellm');
expect(cfg.baseUrl).toBe('http://localhost:4000/v1');
expect(cfg.model).toBe('anthropic/claude-sonnet-4.5');
expect(cfg.apiKey).toBeUndefined();
expect(config.getConfiguredProviders()).toContain('litellm');
});
it('the LiteLLM backbone validates when only a proxy base URL is configured', () => {
process.env.LITELLM_BASE_URL = 'http://localhost:4000/v1';
const backbone = createLiteLLMBackbone();
expect(backbone.getProvider()).toBe('litellm');
expect(backbone.validateConfig().valid).toBe(true);
});
it('surfaces a static LiteLLM model catalog entry', () => {
expect(AVAILABLE_MODELS.litellm?.length ?? 0).toBeGreaterThan(0);
expect(AVAILABLE_MODELS.litellm[0].provider).toBe('LiteLLM');
});
});
@@ -0,0 +1,212 @@
/**
* REGRESSION (issue #78 — macOS/POSIX): the local-agent detector resolved the CLI binary
* against the SERVER process PATH. When T3MP3ST is launched from Finder, the desktop app, or
* any non-interactive shell, launchd hands it a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) —
* missing ~/.local/bin (Claude Code's native install), Homebrew, npm-global, and version-manager
* shims (mise/nvm/fnm). `execFile('claude', …)` then throws ENOENT and an installed, working CLI
* is reported `installed:false` (the Settings checkboxes go dead — reads like a UI bug).
*
* The fix is `resolveBin()`: pure-fs resolution over PATH + a curated set of well-known install
* dirs under the real agent home, with an executable-file check, fail-open to the bare name.
* These tests pin the resolver in isolation, the detector wiring, AND the two spawn call-sites
* (runLocalAgent / localAgentChat) the fix rewired — all through the public API.
*
* POSIX-focused, with a small Windows boundary assertion: unresolved Windows shims fail closed
* instead of returning a bare name that would bypass the safe .cmd/.bat launch path.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolveBin, detectLocalAgents, runLocalAgent, localAgentChat } from '../agent/local-agents.js';
const FAKE_CLI = '#!/bin/sh\necho "1.2.3"\n';
// For the chat path, which writes the prompt to the child's stdin: drain it before replying so the
// parent's write()/end() completes cleanly (no EPIPE races), then emit a clean, non-error reply.
const FAKE_CLI_STDIN = '#!/bin/sh\ncat >/dev/null 2>&1\necho "1.2.3"\n';
const tmpDirs: string[] = [];
const savedEnv = { PATH: process.env.PATH, AGENT_HOME: process.env.T3MP3ST_AGENT_HOME };
/** A throwaway dir; tracked for afterEach cleanup. */
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), 't3mp3st-pathres-'));
tmpDirs.push(d);
return d;
}
/** Drop an executable (0o755) fake binary at `dir/name`; returns its absolute path. */
function putExe(dir: string, name: string, content: string = FAKE_CLI): string {
mkdirSync(dir, { recursive: true });
const p = join(dir, name);
writeFileSync(p, content, { mode: 0o755 });
return p;
}
afterEach(() => {
process.env.PATH = savedEnv.PATH;
if (savedEnv.AGENT_HOME === undefined) delete process.env.T3MP3ST_AGENT_HOME; else process.env.T3MP3ST_AGENT_HOME = savedEnv.AGENT_HOME;
for (const d of tmpDirs.splice(0)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
});
describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', () => {
it('finds a CLI in ~/.local/bin even when PATH omits it (the reported bug)', () => {
const home = scratch();
const bin = putExe(join(home, '.local', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home; // real agent home for this scan
process.env.PATH = '/usr/bin:/bin'; // minimal launchd-style PATH — omits ~/.local/bin
expect(resolveBin('faketool')).toBe(bin);
});
// A representative spread of the curated dirs (not just index-0 ~/.local/bin) so a typo or
// reorder in any entry is caught. Paths are relative to the agent home.
it.each([
['.asdf/shims'],
['.bun/bin'],
['.local/share/pnpm'],
])('resolves a CLI installed under the well-known dir %s', (rel) => {
const home = scratch();
const bin = putExe(join(home, ...rel.split('/')), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('finds a CLI under an nvm per-version bin dir (dynamic enumeration)', () => {
const home = scratch();
const bin = putExe(join(home, '.nvm', 'versions', 'node', 'v20.11.0', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('finds a CLI under an fnm per-version bin dir (dynamic enumeration)', () => {
const home = scratch();
const bin = putExe(join(home, '.local', 'share', 'fnm', 'node-versions', 'v20.11.0', 'installation', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('resolves via PATH when the CLI is on it (rich-shell launch still works)', () => {
const dir = scratch();
const bin = putExe(dir, 'faketool');
process.env.T3MP3ST_AGENT_HOME = scratch(); // empty home — force the hit to come from PATH
process.env.PATH = `/usr/bin:${dir}`;
expect(resolveBin('faketool')).toBe(bin);
});
it('prefers a PATH match over a well-known-dir match when both exist (shell precedence)', () => {
const home = scratch();
const wellKnown = putExe(join(home, '.local', 'bin'), 'faketool');
const pathDir = scratch();
const onPath = putExe(pathDir, 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = `/usr/bin:${pathDir}`; // PATH scanned before well-known dirs
expect(resolveBin('faketool')).toBe(onPath);
expect(resolveBin('faketool')).not.toBe(wellKnown);
});
it('follows a symlink to an executable (asdf/nvm shims & Claude Code\'s own install are symlinks)', () => {
const home = scratch();
const real = putExe(join(home, 'tools'), 'realtool');
const linkDir = join(home, '.local', 'bin');
mkdirSync(linkDir, { recursive: true });
const link = join(linkDir, 'faketool');
symlinkSync(real, link);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(link); // the resolved candidate path, symlink followed for the file-check
});
it('skips a non-executable match (parity with execFile X_OK) and fails open to the bare name', () => {
const home = scratch();
const p = join(home, '.local', 'bin', 'faketool');
mkdirSync(join(home, '.local', 'bin'), { recursive: true });
writeFileSync(p, 'not executable', { mode: 0o644 }); // present but not +x
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe('faketool'); // not the non-exec path
});
it('does NOT match a directory named like the CLI (dirs carry the search bit; execvp would skip it)', () => {
// Regression guard: a bare `accessSync(X_OK)` passes for a directory, so resolveBin would return
// the dir → execFile fails EACCES (not ENOENT) → a false installed:true. Require a regular file.
const home = scratch();
mkdirSync(join(home, '.local', 'bin', 'faketool'), { recursive: true }); // a DIRECTORY, not a binary
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe('faketool'); // bare name, not the dir path
});
it('returns an already-qualified path unchanged (the bin.includes("/") guard — no re-resolution)', () => {
expect(resolveBin('/opt/custom/claude')).toBe('/opt/custom/claude');
expect(resolveBin('./rel/claude')).toBe('./rel/claude');
});
it('fails open to the bare name when nothing matches anywhere', () => {
process.env.T3MP3ST_AGENT_HOME = scratch();
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('definitely-not-a-real-cli-xyz')).toBe('definitely-not-a-real-cli-xyz');
});
it('does NOT scan POSIX dirs on win32 — unresolved shims fail closed', () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const orig = Object.getOwnPropertyDescriptor(process, 'platform') || { value: process.platform, configurable: true };
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
expect(resolveBin('faketool')).toBeUndefined();
} finally {
Object.defineProperty(process, 'platform', orig);
}
});
});
describe('detectLocalAgents — wiring: a well-known-dir CLI is reported installed (issue #78)', () => {
it('reports Claude Code installed + versioned when `claude` lives in ~/.local/bin and PATH omits it', async () => {
const home = scratch();
const claudePath = putExe(join(home, '.local', 'bin'), 'claude');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin'; // pre-fix: execFile('claude') → ENOENT → installed:false
delete process.env.T3MP3ST_DISABLE_LOCAL_AGENTS; // insurance: a leaked disable flag would short-circuit to []
const agents = await detectLocalAgents();
const claude = agents.find((a) => a.id === 'claude');
expect(claude?.installed).toBe(true);
expect(claude?.path).toBe(claudePath);
expect(claude?.version).toBe('1.2.3'); // parsed from the resolved binary's --version output
// codex/hermes are absent from this temp home → still not installed (no false positives).
expect(agents.find((a) => a.id === 'codex')?.installed).toBe(false);
});
});
describe('spawn call-sites use the resolved path (issue #78 — detected-but-unspawnable would be worse)', () => {
// Behavioral, not a spawn spy: if the rewiring regressed to the bare `spec.bin`, spawn('claude')
// under PATH=/usr/bin:/bin throws ENOENT → ok:false / reject. Success proves the resolved path ran.
it('runLocalAgent launches the well-known-dir CLI (not the bare name)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const res = await runLocalAgent('claude', 'ping', { timeoutMs: 4000 });
expect(res.ok).toBe(true);
expect(res.output).toContain('1.2.3');
});
it('localAgentChat launches the well-known-dir CLI (not the bare name)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_STDIN);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).resolves.toContain('1.2.3');
});
});
@@ -155,6 +155,29 @@ describe('local-model (HTTP) backbone surfaces toolCalls — the keyless-path fi
const sys = body.messages.find((m: { role: string }) => m.role === 'system');
expect(sys.content).toContain('nmap_scan');
});
it('merges caller system prompts into one leading system message for vLLM templates', async () => {
const spy = mockJson({ choices: [{ message: { content: 'ok' } }] });
await localModel('http://localhost:8000/v1').prompt('hello', 'Operator profile system prompt');
const body = JSON.parse((spy.mock.calls[0][1] as { body: string }).body);
expect(body.messages.map((m: { role: string }) => m.role)).toEqual(['system', 'user']);
expect(body.messages.filter((m: { role: string }) => m.role === 'system')).toHaveLength(1);
expect(body.messages[0].content).toContain('planning brain for T3MP3ST');
expect(body.messages[0].content).toContain('Operator profile system prompt');
});
it('keeps authorized-reframe context in the same leading system message', async () => {
const spy = mockJson({ choices: [{ message: { content: 'ok' } }] });
const be = localModel('http://localhost:8000/v1');
await be.chat([
{ role: 'system', content: 'AUTHORIZATION CONTEXT (restated): proceed with the authorized task.' },
{ role: 'user', content: 'scan' },
]);
const body = JSON.parse((spy.mock.calls[0][1] as { body: string }).body);
expect(body.messages.map((m: { role: string }) => m.role)).toEqual(['system', 'user']);
expect(body.messages[0].content).toContain('planning brain for T3MP3ST');
expect(body.messages[0].content).toContain('AUTHORIZATION CONTEXT');
});
});
describe('xai provider (Grok Build) — routes to xAI OpenAI-compatible API with the key', () => {
@@ -3,16 +3,49 @@ import { readFileSync } from 'fs';
import { join } from 'path';
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
const uiSource = readFileSync(join(process.cwd(), 'docs/index.html'), 'utf8');
function routeBlock(startMarker: string, endMarker: string): string {
function sourceBlock(startMarker: string, endMarker: string): string {
const start = serverSource.indexOf(startMarker);
expect(start, `missing route marker ${startMarker}`).toBeGreaterThanOrEqual(0);
expect(start, `missing source marker ${startMarker}`).toBeGreaterThanOrEqual(0);
const end = serverSource.indexOf(endMarker, start);
expect(end, `missing end marker ${endMarker}`).toBeGreaterThan(start);
return serverSource.slice(start, end);
}
function routeBlock(startMarker: string, endMarker: string): string {
return sourceBlock(startMarker, endMarker);
}
describe('local API authorization hardening invariants', () => {
it('server mission/general routes default to the configured LLM instead of hardcoded OpenRouter', () => {
const resolver = routeBlock('function resolveGeneralLLMConfig(', '/**\n * Bring a planned mission UP for real');
const missionRoute = routeBlock("app.post('/api/mission/start'", "app.get('/api/mission/report'");
const generalRoutes = routeBlock("app.post('/api/general/plan'", '// =============================================================================\n// THE ADMIRAL');
expect(resolver).toContain('config.getLLMConfig()');
expect(resolver).toContain('provider || defaultConfig.provider');
expect(missionRoute).toContain('resolveGeneralLLMConfig(provider, model, apiKey)');
expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/provider\s*=\s*['"]openrouter['"]/);
expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/model\s*=\s*['"]anthropic\/claude-sonnet-4['"]/);
});
it('health and LLM status count keyless local backends as configured', () => {
const health = routeBlock('function healthPayload()', '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS');
const llmStatus = routeBlock("app.get('/api/llm/status'", '// =============================================================================\n// TOOL EXECUTION ENDPOINTS');
expect(health).toContain('providerRunsKeyless(llmConfig.provider)');
expect(llmStatus).toContain('providerRunsKeyless(llmConfig.provider)');
});
it('War Room no-key/no-agent backend dispatch defers to the server-configured LLM', () => {
expect(uiSource).toContain("serverLLM: null");
expect(uiSource).toContain('this.serverLLM = data.llm');
expect(uiSource).toContain('No browser API key — using the server-configured LLM backend');
expect(uiSource).toContain('LLM backend: server default — no browser API key sent');
expect(uiSource).not.toMatch(/let provider = ['"]openrouter['"];\s*\n\s*let model = state\.settings\.selectedModel/);
});
it('/api/events does not grant wildcard CORS and rejects foreign browser origins before opening SSE', () => {
const route = routeBlock("app.get('/api/events'", '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS');
@@ -30,12 +63,65 @@ describe('local API authorization hardening invariants', () => {
expect(route).toMatch(/guardAction\(body,\s*['"]command_execution['"],\s*targetResolution\.target/);
});
it('Admiral live launch re-checks every General-produced execution target before mission bring-up', () => {
it('/api/tools/execute rejects curl flags that change the effective destination', () => {
const parser = sourceBlock('const CURL_TRANSPORT_OVERRIDE_FLAGS', 'function inferCommandTarget');
for (const flag of [
'--resolve',
'--connect-to',
'--proxy',
'--preproxy',
'--socks5',
'--socks5-hostname',
'--unix-socket',
'--interface',
'--url',
'--config',
'--next',
'-K',
]) {
expect(parser).toContain(`'${flag}'`);
}
expect(parser).toMatch(/findCurlTransportOverrideFlag\(args\)/);
expect(parser).toMatch(/countCurlUrlOperands\(args\)\s*>\s*1/);
expect(parser).toMatch(/multiple URL operands/);
expect(parser).toMatch(/changes the effective network destination/);
});
it('Admiral live launch permits expanded targets only with explicit operator approval receipts', () => {
const route = routeBlock("app.post('/api/admiral/launch'", '// =============================================================================\n// BOUNTY PLATFORM INTEGRATIONS');
expect(route).toMatch(/ensureExecTargetsWithinApprovedTarget\(execConfig\.targets, brief\.target\)/);
expect(route).toMatch(/ensureExecTargetsAuthorized\(execConfig\.targets,\s*brief\.target,\s*req\.body/);
expect(route).toMatch(/outOfScopeTargets\.length/);
expect(route.indexOf('ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target)'))
expect(route.indexOf('ensureExecTargetsAuthorized(execConfig.targets, brief.target'))
.toBeLessThan(route.indexOf('bringUpMissionFromPlan(execConfig, generalConfig)'));
expect(route).toMatch(/approvals/);
expect(route).toMatch(/approvalIds/);
});
it('approval lookup requires explicit receipt ids and wildcard hosts require opt-in', () => {
const findApproval = routeBlock('function findApproval(', 'function createApprovalRequest');
const approvalMatches = sourceBlock('function approvalMatches(', 'function ensureExecTargetsWithinApprovedTarget');
expect(findApproval).toMatch(/body\.approvalId/);
expect(findApproval).toMatch(/body\.approvalIds/);
expect(findApproval).not.toMatch(/\[\.\.\.approvalRequests\.values\(\)\]/);
expect(serverSource).toMatch(/Wildcard host approval requires explicit opt-in/);
expect(serverSource).toMatch(/body\.allowWildcard !== true/);
expect(approvalMatches.indexOf("approval.target === '*'"))
.toBeLessThan(approvalMatches.indexOf('targetUsesWildcard(approval.target) && !approval.wildcardOptIn'));
});
it('authorize-target only approves existing pending receipts and cannot mint broad active-action approvals', () => {
const route = routeBlock("app.post('/api/approvals/authorize-target'", "app.post('/api/approvals/:id/reject'");
expect(route).toMatch(/approvalId/);
expect(route).toMatch(/approvalIds/);
expect(route).toMatch(/approvalRequests\.get\(id\)/);
expect(route).toMatch(/approval\.status !== 'pending'/);
expect(route).toMatch(/Math\.min\(30/);
expect(route).not.toMatch(/createApprovalRequest/);
expect(route).not.toMatch(/allowedActions/);
expect(route).not.toMatch(/command_execution['"],\s*['"]autonomous_execution/);
});
});
@@ -43,6 +43,14 @@ describe('local OpenAI-compatible provider hardening', () => {
expect(localCase).toMatch(/ZHIPUAI_API_KEY/);
});
it('getLLMConfig treats UI local placeholders as unset model names', () => {
const localCase = block(configSource, "case 'local':", 'break;');
expect(localCase).toContain('local-model');
expect(localCase).toContain('local/ollama');
expect(localCase).toContain('TEMPEST_LOCAL_MODEL');
});
it('getApiKey resolves a local key from env without persisting it', () => {
// The local key is read in-memory from env, matching the env-key hardening
// invariant (env keys are never written to the store).
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// ─────────────────────────────────────────────────────────────────────────────
// NO PHANTOM PASS — the "scored = oracle-backed" guard for the benchmark receipts.
//
// `verify-claims` credits a solve straight from the committed `verdict` (detected /
// score===1). The launch risk is the mirror of a phantom tool: a phantom PASS — a
// committed `detected:true` that its own committed flag oracle does not actually back
// (a hand-edit, a merge slip, a fabricated placeholder flag). This test makes that fail
// the build by construction — the same discipline as no-phantom-tools, applied to the
// bench artifacts the headline numbers are re-derived from.
//
// A scored solve is ORACLE-GROUNDED iff EITHER the agent's self-reported flag matches the
// committed `expected` under the harness's case-insensitive compare (ciMatch), OR the
// harness auto-detected `expected` verbatim in tool output (auto_detected — the agent did
// not self-report, so `reported` may be "UNKNOWN"). Anything else scored as a solve is a
// phantom pass.
// ─────────────────────────────────────────────────────────────────────────────
// Same helpers verify-claims.mjs uses, re-derived here so the gate is independent of it.
const ciMatch = (a: unknown, b: unknown) =>
!!a && !!b && String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
function looksFabricated(flag: unknown): boolean {
if (!flag) return false;
const inner = (String(flag).match(/\{([\s\S]*)\}/) || ['', ''])[1]
.toLowerCase()
.replace(/4/g, 'a').replace(/3/g, 'e').replace(/0/g, 'o')
.replace(/1/g, 'i').replace(/5/g, 's').replace(/7/g, 't');
return /\b(fake|placeholder|dummy|redacted|todo|tbd)\b|fake_?flag|for_?test|_?test(ing)?_?(flag|local|stub|only)|local_?test|real_[a-z]+_proof|proof_?xyz|sample_?flag|your_?flag_?here|example_?flag|replace_?me|flag_?here/.test(inner);
}
interface Verdict {
detected?: boolean;
score?: number;
reported?: string;
expected?: string;
auto_detected?: boolean;
reason?: string;
}
const isScoredSolve = (v: Verdict) => v.detected === true || v.score === 1;
const isOracleGrounded = (v: Verdict) =>
ciMatch(v.reported, v.expected) ||
v.auto_detected === true ||
/auto-?detected/i.test(String(v.reason || ''));
// A phantom pass: credited as a solve, but not backed by its own committed oracle.
const isPhantomPass = (v: Verdict) => isScoredSolve(v) && !isOracleGrounded(v);
// A fabricated credit: a placeholder flag credited via the self-report (oracle-match) path.
const isFabricatedCredit = (v: Verdict) =>
isScoredSolve(v) && ciMatch(v.reported, v.expected) && looksFabricated(v.reported);
// ── Load the committed XBEN artifacts the headline sweeps are pinned to ──────
const REPO = process.cwd();
const XBEN = path.join(REPO, 'bench', 'xbow', 'results');
const PINNED_DIRS = [
'blackbox-golden', 'blackbox-golden-v2', 'blackbox-golden-final',
'whitebox-golden', 'whitebox-golden-v2', 'venice-whitebox',
];
function firstVerdict(file: string): Verdict {
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
return ((d.results || [{}])[0] || {}).verdict || {};
}
function committedSolves(): Verdict[] {
const out: Verdict[] = [];
for (const dir of PINNED_DIRS) {
const abs = path.join(XBEN, dir);
if (!fs.existsSync(abs)) continue;
for (const f of fs.readdirSync(abs).filter((n) => /^xben_\d+_24.*\.json$/.test(n))) {
const v = firstVerdict(path.join(abs, f));
if (isScoredSolve(v)) out.push(v);
}
}
return out;
}
describe('no phantom pass (scored = oracle-backed)', () => {
it('finds a non-trivial set of committed, scored solves to audit', () => {
// Guard against the vacuous-green failure mode: if the artifacts moved and we scan
// nothing, the "0 phantom" assertions below would pass while checking nothing.
expect(committedSolves().length).toBeGreaterThanOrEqual(100);
});
it('every committed scored solve is oracle-grounded (self-report ≡ oracle, or auto-detected)', () => {
const phantoms = committedSolves().filter(isPhantomPass);
expect(
phantoms,
`committed solves not backed by their own oracle: ${phantoms
.map((v) => `reported=${JSON.stringify(v.reported)} expected=${JSON.stringify(v.expected)}`)
.join('; ')}`,
).toEqual([]);
});
it('no fabricated placeholder flag is credited as a self-reported solve', () => {
const fabricated = committedSolves().filter(isFabricatedCredit);
expect(fabricated).toEqual([]);
});
// ── The gate must have TEETH: prove it rejects tampered artifacts, not just that
// today's clean data passes. Otherwise a future hand-edit sails through. ──
it('rejects a hand-edited detected:true whose oracle does not match (reported ≠ expected)', () => {
const tampered: Verdict = {
detected: true,
score: 1,
reported: 'FLAG{claimed_but_wrong}',
expected: 'FLAG{the_real_committed_flag}',
auto_detected: false,
reason: 'exact match',
};
expect(isPhantomPass(tampered)).toBe(true);
});
it('rejects a fabricated placeholder flag even when reported === expected', () => {
const fabricated: Verdict = {
detected: true,
score: 1,
reported: 'FLAG{local_test_flag}',
expected: 'FLAG{local_test_flag}',
reason: 'exact match',
};
expect(isPhantomPass(fabricated)).toBe(false); // reported === expected, so not "phantom"…
expect(isFabricatedCredit(fabricated)).toBe(true); // …but the fabrication filter catches it.
});
it('accepts a legitimate auto-detected solve (agent did not self-report; harness found the flag)', () => {
const autoDetected: Verdict = {
detected: false,
score: 1,
reported: 'UNKNOWN',
expected: 'FLAG{0e640864c2718bb34e01e700391986e28ecd6fbe98b4569ef4ccc4cee16a4b2c}',
auto_detected: true,
reason: 'auto-detected: expected flag verbatim in tool output (agent did not self-report)',
};
expect(isPhantomPass(autoDetected)).toBe(false);
});
});
+229
View File
@@ -0,0 +1,229 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseToolOutput, hasParser, PARSED_TOOL_IDS } from '../arsenal/parsers.js';
import { gateLiveFinding } from '../evidence/gate.js';
import { adapterToCustomTool, type AdapterToolDeps } from '../arsenal/adapter-tools.js';
import { TOOL_ADAPTERS } from '../arsenal/catalog.js';
import { KillChainPhase } from '../types/index.js';
import type { CustomTool, Finding, ToolFinding } from '../types/index.js';
// ─────────────────────────────────────────────────────────────────────────────
// STRUCTURED PARSERS — turn real scanner stdout into ToolFinding[], honestly.
//
// Two things are pinned here:
// 1) each JSON-emitting scanner's output maps to the right structured fields, and
// 2) the honesty contract holds: empty / garbled / unknown input yields [] (never a
// fabricated finding), and a parsed finding — carried through the SAME shape the
// agent loop + operator build — PASSES the live provenance gate. That last check
// is the point of the whole layer: structured output that the honesty spine accepts.
// ─────────────────────────────────────────────────────────────────────────────
const fixture = (name: string): string =>
readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8');
describe('parseToolOutput — field mapping per scanner', () => {
it('nuclei: one finding per JSONL match; skips non-JSON lines; maps cve/cwe/cvss/severity', () => {
const f = parseToolOutput('nuclei', fixture('nuclei.jsonl'));
expect(f).toHaveLength(2); // banner line skipped
const log4j = f[0];
expect(log4j.title).toBe('Apache Log4j RCE');
expect(log4j.severity).toBe('critical');
expect(log4j.cve).toEqual(['CVE-2021-44228']);
expect(log4j.cwe).toEqual(['CWE-502']);
expect(log4j.cvss).toBe(10.0);
expect(log4j.remediation).toMatch(/2\.17\.0/);
expect(log4j.details).toContain('target.example.com');
expect(f[1].severity).toBe('info'); // nginx version disclosure
});
it('httpx: one info finding per live host with url/status/tech in details', () => {
const f = parseToolOutput('httpx', fixture('httpx.jsonl'));
expect(f).toHaveLength(2);
expect(f[0].severity).toBe('info');
expect(f[0].title).toContain('target.example.com');
expect(f[0].details).toContain('status: 200');
expect(f[0].details).toContain('Nginx');
});
it('dalfox: one finding per XSS probe with severity + CWE', () => {
const f = parseToolOutput('dalfox', fixture('dalfox.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].cwe).toEqual(['CWE-79']);
expect(f[0].title).toContain('q'); // the param
expect(f[0].details).toContain('poc');
});
it('ffuf: a single aggregate finding counting discovered paths (no per-path flood)', () => {
const f = parseToolOutput('ffuf', fixture('ffuf.json'));
expect(f).toHaveLength(1);
expect(f[0].title).toBe('ffuf: 2 path(s) discovered');
expect(f[0].severity).toBe('info');
expect(f[0].details).toContain('admin');
expect(f[0].details).toContain('login');
});
it('katana: a single aggregate finding counting discovered endpoints', () => {
const f = parseToolOutput('katana', fixture('katana.jsonl'));
expect(f).toHaveLength(1);
expect(f[0].title).toBe('katana: 3 endpoint(s) discovered');
expect(f[0].details).toContain('/about');
});
});
describe('parseToolOutput — honesty contract (never fabricate, never throw)', () => {
it('returns [] for empty, whitespace, and unparseable output', () => {
for (const raw of ['', ' ', '\n\n', 'not json at all', '{bad json', '<html>error</html>']) {
expect(parseToolOutput('nuclei', raw)).toEqual([]);
expect(parseToolOutput('ffuf', raw)).toEqual([]);
expect(parseToolOutput('dalfox', raw)).toEqual([]);
}
});
it('returns [] for a tool with no parser wired', () => {
expect(parseToolOutput('sqlmap', fixture('nuclei.jsonl'))).toEqual([]);
expect(parseToolOutput('metasploit', 'anything')).toEqual([]);
expect(hasParser('sqlmap')).toBe(false);
});
it('never throws on adversarial / deeply-nested / wrong-shape input', () => {
const evil = [
JSON.stringify({ results: 'not-an-array' }),
'[1,2,3]',
'{"info":null}\n{"info":{"classification":"nope"}}',
'['.repeat(500),
JSON.stringify({ results: [{ input: null, status: {} }] }),
];
for (const id of PARSED_TOOL_IDS) {
for (const raw of evil) {
expect(() => parseToolOutput(id, raw)).not.toThrow();
expect(Array.isArray(parseToolOutput(id, raw))).toBe(true);
}
}
});
it('exposes exactly the wired parser ids', () => {
expect([...PARSED_TOOL_IDS].sort()).toEqual(
['dalfox', 'ffuf', 'gitleaks', 'grype', 'httpx', 'katana', 'nuclei', 'semgrep', 'trivy'],
);
});
});
describe('parseToolOutput — source / supply-chain scanners', () => {
it('semgrep: one finding per rule match with mapped severity + CWE token', () => {
const f = parseToolOutput('semgrep', fixture('semgrep.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high'); // ERROR → high
expect(f[0].cwe).toEqual(['CWE-78']); // extracted from "CWE-78: OS Command Injection"
expect(f[0].details).toContain('app/run.py:42');
});
it('trivy: one finding per package vuln with CVE, CWE, and an upgrade remediation', () => {
const f = parseToolOutput('trivy', fixture('trivy.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].cve).toEqual(['CVE-2021-3749']);
expect(f[0].cwe).toEqual(['CWE-400']);
expect(f[0].remediation).toContain('0.21.2');
expect(f[0].details).toContain('axios');
});
it('grype: one finding per match with CVE + package coordinates', () => {
const f = parseToolOutput('grype', fixture('grype.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high'); // "High" → high
expect(f[0].cve).toEqual(['CVE-2022-23529']);
expect(f[0].details).toContain('jsonwebtoken');
});
it('gitleaks: one high finding per secret, and the raw secret is REDACTED from details', () => {
const f = parseToolOutput('gitleaks', fixture('gitleaks.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].details).toContain('config/prod.env:12');
// Defense in depth: the AWS key present in the raw report must never survive into the finding.
expect(f[0].details).not.toContain('AKIAIOSFODNN7EXAMPLE');
expect(f[0].details).toContain('[redacted]');
});
});
describe('end-to-end: a parsed finding passes the live provenance gate', () => {
// Mirror how the agent loop stamps provenance (src/agent/index.ts) and how the operator
// materialises a ToolFinding into a Finding with output-evidence (src/operators/index.ts),
// then run the real gate. This proves parsed output is accepted by the honesty spine.
const materialise = (tf: ToolFinding, rawOutput: string, toolName: string): Finding => {
const stamped = { ...tf, provenance: 'tool' as const, toolName, toolOutput: rawOutput.slice(0, 4000) };
return {
id: 'finding-test',
title: stamped.title,
description: stamped.details,
severity: stamped.severity,
targetId: 'target-1',
operatorId: 'op-1',
phase: KillChainPhase.RECON,
cvss: stamped.cvss,
cve: stamped.cve,
cwe: stamped.cwe,
evidence: stamped.provenance === 'tool'
? [{ type: 'output', content: stamped.toolOutput || stamped.details || '', timestamp: 1, metadata: { tool: stamped.toolName } }]
: [],
remediation: stamped.remediation,
discoveredAt: 1,
};
};
it('a parsed nuclei critical finding is gated PASS with provenance=tool', () => {
const raw = fixture('nuclei.jsonl');
const tf = parseToolOutput('nuclei', raw)[0];
const gate = gateLiveFinding(materialise(tf, raw, 'nuclei_tool'));
expect(gate.passed).toBe(true);
expect(gate.provenance).toBe('tool');
});
it('every parsed finding across all scanners is gate-passable (real tool evidence)', () => {
for (const [id, file] of [
['nuclei', 'nuclei.jsonl'], ['httpx', 'httpx.jsonl'], ['dalfox', 'dalfox.json'],
['ffuf', 'ffuf.json'], ['katana', 'katana.jsonl'],
['semgrep', 'semgrep.json'], ['gitleaks', 'gitleaks.json'],
['trivy', 'trivy.json'], ['grype', 'grype.json'],
] as const) {
const raw = fixture(file);
for (const tf of parseToolOutput(id, raw)) {
const gate = gateLiveFinding(materialise(tf, raw, `${id}_tool`));
expect(gate.passed, `${id} finding "${tf.title}" should pass the gate`).toBe(true);
expect(gate.provenance).toBe('tool');
}
}
});
});
describe('factory wiring: a minted adapter returns structured findings from real stdout', () => {
// Proves the actual code path (adapter-tools.ts success return) populates ToolResult.findings —
// not just that the pure parser works. Deps are fakes; runSubprocess replays a fixture as stdout.
const depsReturning = (stdout: string): AdapterToolDeps => ({
isToolAvailable: async () => true,
runSubprocess: async () => ({ stdout, stderr: '', exitCode: 0 }),
});
const mint = (id: string, deps: AdapterToolDeps): CustomTool => {
const adapter = TOOL_ADAPTERS.find((a) => a.id === id);
if (!adapter) throw new Error(`fixture: adapter '${id}' not found`);
const tool = adapterToCustomTool(adapter, deps);
if (!tool) throw new Error(`fixture: adapter '${id}' is not mintable`);
return tool;
};
it('nuclei_tool surfaces parsed findings (with cwe/cve) AND keeps the raw stdout as evidence', async () => {
const raw = fixture('nuclei.jsonl');
const res = await mint('nuclei', depsReturning(raw)).handler({ parameters: { url: 'https://target.example.com' } });
expect(res.success).toBe(true);
expect(res.output).toBe(raw); // raw stdout preserved as the evidence of record
expect(res.findings).toHaveLength(2);
expect(res.findings?.[0].cwe).toEqual(['CWE-502']);
});
it('a tool whose scan yields no parseable output leaves findings unset (no fabrication)', async () => {
const res = await mint('nuclei', depsReturning('no findings here\n')).handler({ parameters: { url: 'https://target.example.com' } });
expect(res.success).toBe(true);
expect(res.findings).toBeUndefined();
});
});
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { Arsenal, BUILTIN_TOOLS, createToolContext } from '../arsenal/index.js';
const httpTool = BUILTIN_TOOLS.find(tool => tool.name === 'http_request');
const technologyTool = BUILTIN_TOOLS.find(tool => tool.name === 'technology_detect');
if (!httpTool || !technologyTool) throw new Error('Required HTTP tools are not registered');
const originalOrigin = process.env.TEMPEST_TARGET_ORIGIN;
const originalHeaders = process.env.TEMPEST_TARGET_HEADERS;
function response(status = 200, headers: Record<string, string> = {}): Response {
const normalized = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
return {
status,
statusText: status === 200 ? 'OK' : 'Found',
headers: {
entries: () => normalized.entries(),
get: (name: string) => normalized.get(name.toLowerCase()) ?? null,
},
text: async () => '<html></html>',
} as unknown as Response;
}
function configure(origin = 'https://target.example') {
process.env.TEMPEST_TARGET_ORIGIN = origin;
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({
Authorization: 'Bearer configured-secret',
'X-Tenant': 'acme',
});
}
beforeEach(() => configure());
afterEach(() => {
if (originalOrigin === undefined) delete process.env.TEMPEST_TARGET_ORIGIN;
else process.env.TEMPEST_TARGET_ORIGIN = originalOrigin;
if (originalHeaders === undefined) delete process.env.TEMPEST_TARGET_HEADERS;
else process.env.TEMPEST_TARGET_HEADERS = originalHeaders;
vi.unstubAllGlobals();
});
describe('target header binding', () => {
it('injects configured headers only into the exact origin', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
await httpTool.handler(createToolContext(undefined, { url: 'https://other.example/api' }));
await httpTool.handler(createToolContext(undefined, { url: 'http://target.example/api' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
expect(new Headers(mockFetch.mock.calls[1][1]?.headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[2][1]?.headers).has('authorization')).toBe(false);
});
it('requires both the origin binding and a valid non-empty string header map', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
delete process.env.TEMPEST_TARGET_ORIGIN;
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
process.env.TEMPEST_TARGET_ORIGIN = 'https://target.example';
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Authorization: 123 });
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
});
it('rejects origins with paths and transport-level headers', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
configure('https://target.example/api');
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
configure();
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Host: 'other.example' });
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('host')).toBe(false);
});
it('lets explicit tool headers override configured headers case-insensitively', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, {
url: 'https://target.example',
headers: { authorization: 'Bearer request-secret' },
}));
const sent = new Headers(mockFetch.mock.calls[0][1].headers);
expect(sent.get('authorization')).toBe('Bearer request-secret');
expect([...sent.keys()].filter(name => name === 'authorization')).toHaveLength(1);
expect(sent.get('x-tenant')).toBe('acme');
});
it('applies configured headers to built-in probes beyond http_request', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await technologyTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
});
});
describe('target header redirect safety', () => {
it('removes every configured header before a cross-origin redirect', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(response(302, { location: 'https://external.example/landing' }))
.mockResolvedValueOnce(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('x-tenant')).toBe('acme');
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('x-tenant')).toBe(false);
expect(String(mockFetch.mock.calls[1][0])).toBe('https://external.example/landing');
});
it('keeps configured headers on same-origin redirects', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(response(302, { location: '/next' }))
.mockResolvedValueOnce(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
expect(new Headers(mockFetch.mock.calls[1][1].headers).get('authorization')).toBe('Bearer configured-secret');
});
});
describe('target header secret handling', () => {
it('redacts configured values from persisted tool output and findings', async () => {
const mockFetch = vi.fn().mockResolvedValue(response(200, {
'x-reflected-value': 'Bearer configured-secret',
}));
vi.stubGlobal('fetch', mockFetch);
const arsenal = new Arsenal();
arsenal.register(httpTool);
const result = await arsenal.execute('http_request', createToolContext(undefined, {
url: 'https://target.example',
}));
expect(result.output).toContain('[REDACTED]');
expect(result.output).not.toContain('configured-secret');
expect(arsenal.getExecutions()[0].result?.output).not.toContain('configured-secret');
});
it('keeps configured curl headers out of argv and disables redirect-following flags', () => {
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
const curl = source.slice(source.indexOf("name: 'curl_request'"));
expect(curl).toContain("writeFile(configPath");
expect(curl).toContain("mode: 0o600");
expect(curl).toContain('withoutCurlRedirectFlags');
expect(curl).not.toContain("args.push('-H', `${name}: ${value}`)");
});
it('routes every built-in fetch call through the target-aware helper', () => {
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
const builtins = source.slice(source.indexOf('export const BUILTIN_TOOLS'), source.indexOf('export const EXTERNAL_TOOLS'));
expect(builtins).not.toMatch(/\bfetch\s*\(/);
expect(builtins).toContain('targetFetch(');
});
});
+10 -1
View File
@@ -14,12 +14,14 @@
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync, realpathSync } from 'fs';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync, realpathSync, readFileSync } from 'fs';
import { statSync } from 'node:fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolveContainedRepoPath, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from '../recon/whitebox.js';
const whiteboxSource = readFileSync('src/recon/whitebox.ts', 'utf8');
// Mock node:fs (what whitebox.ts imports) so we can force statSync to throw AFTER realpath
// succeeds — the TOCTOU race path. Every other export keeps its real implementation, and
// statSync itself defaults to real (vi.fn wrapping the original), overridden per-test below.
@@ -114,6 +116,13 @@ describe('resolveContainedRepoPath (B-03 path containment)', () => {
}
});
it('does not embed GitHub tokens in the git clone URL argument', () => {
expect(whiteboxSource).toContain('GIT_ASKPASS');
expect(whiteboxSource).toContain('T3MP3ST_GIT_TOKEN');
expect(whiteboxSource).not.toContain('githubCloneUrlWithToken');
expect(whiteboxSource).not.toContain('url.password = token');
});
it('normalizes a statSync failure (TOCTOU delete after realpath) to RepoPathError', () => {
// The dir realpath-resolves (exists) but statSync then throws — simulating a deletion in
// the race window between realpath and stat. The wrap must convert it to a RepoPathError
+70 -4
View File
@@ -71,8 +71,8 @@ export interface AgentResult {
export interface AgentEvents {
'agent:step': AgentStep;
'agent:tool_call': { name: string; args: Record<string, unknown> };
'agent:tool_result': { name: string; result: ToolResult };
'agent:tool_call': { name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' };
'agent:tool_result': { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
'agent:thinking': { content: string };
'agent:complete': AgentResult;
'agent:error': { error: Error; step: number };
@@ -133,6 +133,39 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
{ role: 'user', content: this.buildTaskPrompt(task, target, toolDefs, sourceContext, sharedContext) },
];
const bootstrapCall = this.getLocalReconBootstrap(task, target, toolDefs);
if (bootstrapCall) {
const toolStep = await this.executeTool(bootstrapCall, target, -1);
steps.push(toolStep);
seenCalls.set(
`${bootstrapCall.name}:${JSON.stringify(bootstrapCall.arguments || {})}`,
String(toolStep.toolResult?.output || toolStep.toolResult?.error || 'no output').replace(/\s+/g, ' ').slice(0, 160)
);
if (toolStep.toolResult?.findings) {
const out = String(toolStep.toolResult.output ?? '').slice(0, 4000);
for (const tf of toolStep.toolResult.findings) {
allFindings.push({ ...tf, provenance: 'tool', toolName: bootstrapCall.name, toolOutput: out || tf.details });
}
}
messages.push({
role: 'assistant',
content: 'Running an initial low-noise recon check before planning deeper actions.',
toolCalls: [bootstrapCall],
});
messages.push({
role: 'tool',
content: this.formatToolResult(toolStep.toolResult),
toolCallId: bootstrapCall.id,
name: bootstrapCall.name,
});
messages.push({
role: 'user',
content: 'Use the bootstrap observation above as your first tool observation. Continue through the normal ACTION CONTRACT: request another listed tool only if it will materially improve the evidence; otherwise provide the final recon debrief.',
});
}
for (let i = 0; i < this.options.maxIterations; i++) {
try {
// Ask the LLM what to do next
@@ -298,6 +331,38 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
return result;
}
private getLocalReconBootstrap(
task: Task,
target: Target | undefined,
tools: LLMToolDefinition[]
): LLMToolCall | undefined {
if (this.llm.getProvider() !== 'local-agent') return undefined;
if (!target?.address) return undefined;
if (task.phase !== 'reconnaissance' && task.operatorType !== 'recon') return undefined;
const hasTool = (name: string) => tools.some((tool) => tool.name === name);
const taskName = task.name.toLowerCase();
const id = `local-agent-bootstrap-${Date.now()}`;
if (taskName.includes('dns') && hasTool('dns_lookup')) {
return { id, name: 'dns_lookup', arguments: { domain: target.address, type: 'A' } };
}
if (taskName.includes('port') && hasTool('port_scan')) {
return { id, name: 'port_scan', arguments: { target: target.address, ports: '80,443', timeout: 2000 } };
}
if ((taskName.includes('web') || taskName.includes('fingerprint')) && hasTool('header_analysis')) {
return { id, name: 'header_analysis', arguments: { url: `https://${target.address}` } };
}
if ((taskName.includes('content') || taskName.includes('discover')) && hasTool('robots_txt_fetch')) {
return { id, name: 'robots_txt_fetch', arguments: { url: `https://${target.address}` } };
}
return undefined;
}
/**
* Execute a single tool call via the Arsenal
*/
@@ -306,7 +371,8 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
target: Target | undefined,
iteration: number
): Promise<AgentStep> {
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments });
const source = iteration < 0 ? 'backend_seeded' : 'agent';
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments, source });
let toolResult: ToolResult;
try {
@@ -330,7 +396,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
};
}
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult });
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult, source });
return {
iteration,
+175 -28
View File
@@ -13,7 +13,7 @@
// =============================================================================
import { execFile, execFileSync, spawn } from 'child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { accessSync, constants, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'fs';
import { homedir, tmpdir, userInfo } from 'os';
import { join } from 'path';
@@ -53,6 +53,16 @@ export function agentHome(): string {
}
const expand = (p: string): string => (p.startsWith('~') ? agentHome() + p.slice(1) : p);
/**
* Resolve a path under the OS-native per-user local app-data root, cross-platform:
* - Windows: %LOCALAPPDATA% (e.g. C:\Users\<u>\AppData\Local), where the Hermes desktop app
* stores its runtime auth under %LOCALAPPDATA%\hermes\ rather than ~/.hermes/.
* - POSIX: <agentHome>/AppData/Local fallback (harmless: the artifact simply won't exist there).
* PRESENCE-ONLY: callers pass the result to existsSync(); contents are never read.
*/
const localAppData = (rel: string): string =>
join(process.env.LOCALAPPDATA || join(agentHome(), 'AppData', 'Local'), ...rel.split('/'));
/**
* Env for a spawned agent CLI. Two adjustments to our own env:
* - strip the injected provider keys so the CLI falls back to its OWN native login (not t3mp3st's).
@@ -126,7 +136,17 @@ const SPECS: AgentSpec[] = [
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.codex/auth.json', '~/.config/codex/auth.json'],
oneShot: (p, m) => ['exec', ...(m ? ['-m', m] : []), p],
oneShot: (p, m) => [
'exec',
'--ephemeral',
'--skip-git-repo-check',
'--sandbox',
'read-only',
'--color',
'never',
...(m ? ['-m', m] : []),
p,
],
},
{
id: 'hermes',
@@ -137,7 +157,10 @@ const SPECS: AgentSpec[] = [
invokeHint: 'hermes -z "<prompt>" (--yolo only if T3MP3ST_HERMES_YOLO=1)',
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json"],
// Hermes desktop on Windows stores its login under %LOCALAPPDATA%\hermes\ (NOT ~/.hermes/), so
// checking only ~/.hermes/ made an authed Windows install read as NOT AUTHED. Presence-only check;
// ~/.hermes/auth.json is upstream's POSIX/mac auth artifact, kept alongside the Windows paths.
authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json", localAppData('hermes/.env'), localAppData('hermes/auth.json')],
oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])],
},
];
@@ -172,12 +195,103 @@ export interface AgentDetection {
ready: boolean; // installed && authed
}
function resolvePath(bin: string): string | undefined {
try {
return execFileSync('command', ['-v', bin], { shell: '/bin/bash', encoding: 'utf8' }).trim() || undefined;
} catch {
try { return execFileSync('which', [bin], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; }
const isWin32 = (): boolean => process.platform === 'win32';
// Kept as a constructed RegExp so the source never embeds a raw CRLF inside a regex literal.
const NEWLINE_RE = new RegExp('\\r?\\n');
/**
* Curated POSIX install dirs an agent CLI commonly lands in, under the REAL agent home (issue #78).
*
* The bug: a T3MP3ST server launched from Finder / the desktop app / any non-interactive shell
* inherits launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). That omits every place a CLI
* actually installs — so `execFile('claude', …)` throws ENOENT and a working, authed CLI reads as
* `installed:false`. We recover it by ALSO scanning these dirs (they are where Claude Code's native
* installer, Homebrew, npm-global, and the version managers put their bins). Anchored to agentHome()
* — NOT os.homedir() — so a HOME redirect can't hide them (same rationale as `agentHome`).
*/
function wellKnownBinDirs(home: string): string[] {
const dirs = [
join(home, '.local', 'bin'), // Claude Code native installer, pipx, mise
join(home, '.local', 'share', 'mise', 'shims'), // mise
join(home, '.asdf', 'shims'), // asdf
'/opt/homebrew/bin', // Homebrew (Apple Silicon)
'/usr/local/bin', // Homebrew (Intel) / manual installs
join(home, '.bun', 'bin'), // bun
join(home, '.deno', 'bin'), // deno
join(home, '.volta', 'bin'), // volta
join(home, '.npm-global', 'bin'), // npm prefix override
join(home, '.yarn', 'bin'), // yarn global
join(home, 'Library', 'pnpm'), // pnpm (macOS default)
join(home, '.local', 'share', 'pnpm'), // pnpm (XDG)
'/opt/local/bin', // MacPorts
];
// Version managers keep a per-version bin dir — enumerate the installed versions so an
// npm-global CLI (e.g. codex) under the active node still resolves under a minimal PATH.
for (const nodeVersions of [join(home, '.nvm', 'versions', 'node')]) {
try { for (const v of readdirSync(nodeVersions)) dirs.push(join(nodeVersions, v, 'bin')); } catch { /* not present */ }
}
for (const fnmDir of [join(home, '.local', 'share', 'fnm', 'node-versions'),
join(home, 'Library', 'Application Support', 'fnm', 'node-versions')]) {
try { for (const v of readdirSync(fnmDir)) dirs.push(join(fnmDir, v, 'installation', 'bin')); } catch { /* not present */ }
}
return dirs;
}
/**
* Absolute path to `bin`, resolved WITHOUT an operator-controlled shell:
* - Windows: where.exe honors PATHEXT, preferring .exe > .cmd > .bat > first hit.
* - POSIX: PATH plus well-known install dirs under agentHome() (issue #78).
*
* POSIX fail-open: an unresolved name is returned unchanged so `execFile`/`spawn` still throw the
* same ENOENT (→ installed:false) they did before. Windows fail-closed: an unresolved npm shim must
* not be spawned as a bare name because .cmd/.bat launches need explicit shell handling.
*/
export function resolveBin(bin: string): string | undefined {
if (isWin32()) {
try {
const out = execFileSync('where.exe', [bin], { encoding: 'utf8', timeout: 5000 });
const hits = out.split(NEWLINE_RE).map((h) => h.trim()).filter(Boolean);
return (
hits.find((h) => /\.exe$/i.test(h)) ??
hits.find((h) => /\.cmd$/i.test(h)) ??
hits.find((h) => /\.bat$/i.test(h)) ??
hits[0]
);
} catch { return undefined; }
}
if (bin.includes('/')) return bin;
const path = process.env.PATH || '';
for (const dir of [...path.split(':'), ...wellKnownBinDirs(agentHome())]) {
// Absolute dirs only: skips blank PATH segments (whose POSIX "= cwd" meaning is a known
// CWD-execution vector) and relative segments (which would leak a non-absolute `path`).
if (!dir.startsWith('/')) continue;
const cand = join(dir, bin);
try {
// X_OK alone also passes for a DIRECTORY named `bin` (dirs carry the search bit); the OS's own
// execvp skips such a directory and searches on, so require a regular file to match that and
// avoid returning a dir that execFile would then reject with EACCES (a false installed:true).
accessSync(cand, constants.X_OK);
if (statSync(cand).isFile()) return cand;
} catch { /* missing, not executable, or not a regular file — keep scanning */ }
}
return bin;
}
/**
* Windows-safe launcher for a resolved agent binary.
*
* A `.cmd`/`.bat` shim can't be spawned with shell:false on Windows. It must run through cmd.exe, but
* hand-rolling `spawn('cmd.exe', ['/d','/s','/c', shim, ...args])` is unsafe because cmd.exe re-parses
* the command tail with its own quoting rules. Letting Node launch the resolved shim with shell:true
* uses Node's patched argument escaping path, so adversarial prompt text stays a literal argument.
*/
function spawnAgent(resolvedBin: string, args: string[], options: import('child_process').SpawnOptions): import('child_process').ChildProcess {
if (needsShell(resolvedBin)) return spawn(resolvedBin, args, { ...options, shell: true });
return spawn(resolvedBin, args, { ...options, shell: false });
}
function needsShell(resolvedBin: string): boolean {
return isWin32() && /\.(cmd|bat)$/i.test(resolvedBin);
}
function authState(spec: AgentSpec): { authed: boolean; method?: string } {
@@ -186,7 +300,10 @@ function authState(spec: AgentSpec): { authed: boolean; method?: string } {
}
if (spec.keychainService) {
try {
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore' });
// timeout: a headless/SSH/locked-keychain session (the same non-interactive launch context
// as #78) can make `security` block on an ACL prompt; bound it so detection can't hang the
// event loop. A timeout throws → treated as "not in keychain" (fail-open, same as absence).
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore', timeout: 2000 });
return { authed: true, method: 'keychain' };
} catch { /* not in keychain */ }
}
@@ -198,25 +315,45 @@ function detectOne(spec: AgentSpec): Promise<AgentDetection> {
id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin,
blurb: spec.blurb, invokeHint: spec.invokeHint,
};
// POSIX falls back to the bare name when unresolved; Windows keeps `resolved` authoritative because
// bare npm .cmd/.bat shims require explicit shell handling after where.exe resolution.
const exe = resolveBin(spec.bin) || (isWin32() ? undefined : spec.bin);
return new Promise((resolve) => {
execFile(spec.bin, spec.versionArgs, { timeout: 8000 }, (err, stdout) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve({ ...base, installed: false, authed: false, ready: false });
return;
}
// even a non-zero exit but no ENOENT means the binary exists
const version = spec.parseVersion(String(stdout || ''));
if (!exe) {
resolve({ ...base, installed: false, authed: false, ready: false });
return;
}
const installedNoVersion = () => {
const auth = authState(spec);
resolve({
...base,
installed: true,
path: resolvePath(spec.bin),
version,
authed: auth.authed,
authMethod: auth.method,
ready: auth.authed,
...base, installed: true, path: exe !== spec.bin ? exe : undefined, version: '?',
authed: auth.authed, authMethod: auth.method, ready: auth.authed,
});
});
};
try {
execFile(exe, spec.versionArgs, { timeout: 8000, shell: needsShell(exe) }, (err, stdout) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve({ ...base, installed: false, authed: false, ready: false });
return;
}
// even a non-zero exit but no ENOENT means the binary exists
const version = spec.parseVersion(String(stdout || ''));
const auth = authState(spec);
resolve({
...base,
installed: true,
path: exe !== spec.bin ? exe : undefined,
version,
authed: auth.authed,
authMethod: auth.method,
ready: auth.authed,
});
});
} catch {
// Synchronous spawn failures (for example restricted child-process creation) should not reject
// the whole detection batch; the binary exists, but its version could not be probed.
installedNoVersion();
}
});
}
@@ -226,7 +363,15 @@ export async function detectLocalAgents(): Promise<AgentDetection[]> {
// local-agent auto-detection) so key-required / fail-closed paths can be exercised
// deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run.
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return [];
return Promise.all(SPECS.map(detectOne));
const settled = await Promise.allSettled(SPECS.map(detectOne));
return settled.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
const s = SPECS[i];
return {
id: s.id, label: s.label, vendor: s.vendor, bin: s.bin, blurb: s.blurb,
invokeHint: s.invokeHint, installed: false, authed: false, ready: false,
};
});
}
export interface AgentRunResult {
@@ -265,9 +410,10 @@ export function runLocalAgent(
const maxChars = opts.maxChars ?? 4000;
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv();
const resolvedBin = resolveBin(spec.bin) || spec.bin;
return new Promise((resolve) => {
// stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait).
const child = spawn(spec.bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawnAgent(resolvedBin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
let errOut = '';
let done = false;
@@ -322,15 +468,16 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
} else if (id === 'codex') {
workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-'));
outFile = join(workDir, 'reply.txt');
args = ['exec', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])];
args = ['exec', '--ephemeral', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])];
} else { // hermes — takes the prompt as an arg
args = ['-z', prompt, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(model ? ['-m', model] : [])];
viaStdin = false;
}
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 = spawn(spec.bin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
const child = spawnAgent(resolvedBin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
let out = '';
let errOut = '';
let done = false;
+46 -1
View File
@@ -25,6 +25,7 @@
import type { ToolAdapter } from './catalog.js';
import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
import { parseToolOutput } from './parsers.js';
// =============================================================================
// INJECTED DEPENDENCIES
@@ -355,6 +356,41 @@ const ARG_TEMPLATES: Record<string, ArgTemplate> = {
defaultTimeoutMs: 180_000,
build: (target, params) => ['--json', scanPath(target, params)],
},
// ── Recon / OSINT adapters (networked) ─────────────────────────────────────────────────────────
whatweb: {
targetParam: 'url',
defaultTimeoutMs: 60_000,
// JSON straight to stdout; --no-errors keeps a single unreachable host from aborting the run.
build: (target) => ['--log-json=-', '--no-errors', target],
},
wafw00f: {
targetParam: 'url',
defaultTimeoutMs: 60_000,
build: (target) => [target, '-a', '-f', 'json', '-o', '-'],
},
amass: {
targetParam: 'domain',
defaultTimeoutMs: 300_000,
// `enum` subcommand is required; -passive keeps it to OSINT sources (no active resolution/brute).
build: (target) => ['enum', '-passive', '-d', target],
},
dnsx: {
targetParam: 'domain',
defaultTimeoutMs: 120_000,
// dnsx takes the domain via -d (comma-sep/stdin), never a bare positional.
build: (target) => ['-d', target, '-a', '-aaaa', '-cname', '-resp', '-json', '-silent'],
},
'testssl.sh': {
targetParam: 'url',
defaultTimeoutMs: 300_000,
build: (target) => ['--quiet', '--color', '0', target],
},
waybackurls: {
targetParam: 'domain',
defaultTimeoutMs: 60_000,
build: (target) => [target],
},
};
/** Fallback for any mintable adapter without a bespoke template: pass the target as a positional arg. */
@@ -470,7 +506,16 @@ export function adapterToCustomTool(adapter: ToolAdapter, deps: AdapterToolDeps)
};
}
return { success: true, output: result.stdout };
// Populate the structured `findings` channel from the raw stdout when a parser is wired for
// this tool (parseToolOutput → [] otherwise). The raw stdout is ALWAYS kept as `output` — it
// is the evidence of record the agent loop stamps onto each finding and the live gate checks;
// the parser summarises it, never replaces it. A parse yielding nothing leaves findings unset.
const findings = parseToolOutput(adapter.id, result.stdout);
return {
success: true,
output: result.stdout,
...(findings.length ? { findings } : {}),
};
};
return {
+103 -7
View File
@@ -179,7 +179,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['jsonl', 'text'],
installHint: 'brew install projectdiscovery/tap/httpx',
commandHint: 'httpx -u https://example.com -status-code -title -tech-detect -json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'HTTP probing and technology detection for discovered assets.',
},
{
@@ -211,7 +211,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['jsonl', 'text'],
installHint: 'brew install projectdiscovery/tap/katana',
commandHint: 'katana -u https://example.com -jsonl -silent',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Crawler for endpoint discovery and attack-surface mapping.',
},
{
@@ -307,7 +307,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'text'],
installHint: 'brew install dalfox',
commandHint: 'dalfox url https://example.com?q=test -o result.json --format json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'XSS validation adapter for scoped endpoints.',
},
{
@@ -339,7 +339,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'sarif'],
installHint: 'brew install semgrep or pipx install semgrep',
commandHint: 'semgrep scan --config auto --json .',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Static analysis backbone for repository hardening.',
},
{
@@ -355,7 +355,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'sarif'],
installHint: 'brew install gitleaks',
commandHint: 'gitleaks detect --source . --report-format json --redact',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Secret detection must redact values before ledger insertion.',
},
{
@@ -387,7 +387,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'sarif'],
installHint: 'brew install aquasecurity/trivy/trivy',
commandHint: 'trivy fs --format json .',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Vulnerability and IaC scanner for repos and images.',
},
{
@@ -419,7 +419,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json'],
installHint: 'brew install grype',
commandHint: 'grype dir:. -o json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Pairs well with Syft SBOMs for dependency vulnerability triage.',
},
{
@@ -1126,6 +1126,102 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
parserStatus: 'planned',
notes: 'Local artifact classification and malware-family matching.',
},
{
id: 'whatweb',
binary: 'whatweb',
name: 'WhatWeb',
category: 'web',
families: ['web_api', 'social_osint', 'reporting_remediation'],
risk: 'active',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['technology_fingerprint', 'http_service'],
outputFormats: ['json', 'text'],
installHint: 'brew install whatweb # or: gem install whatweb',
commandHint: 'whatweb --log-json=- https://example.com',
parserStatus: 'text',
notes: 'Web stack fingerprinting (CMS, framework, server) to seed targeted follow-up.',
},
{
id: 'wafw00f',
binary: 'wafw00f',
name: 'wafw00f',
category: 'web',
families: WEB_FAMILIES,
risk: 'passive',
execution: 'safe_command',
networked: true,
evidenceKinds: ['waf_fingerprint'],
outputFormats: ['json', 'text'],
installHint: 'pipx install wafw00f',
commandHint: 'wafw00f https://example.com -f json -o -',
parserStatus: 'text',
notes: 'Detects and fingerprints the WAF in front of a target so probes can be tuned or throttled.',
},
{
id: 'amass',
binary: 'amass',
name: 'OWASP Amass',
category: 'osint',
families: ['web_api', 'cloud_infra', 'social_osint'],
risk: 'passive',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['subdomain', 'asn', 'dns_record'],
outputFormats: ['jsonl', 'text'],
installHint: 'brew install amass',
commandHint: 'amass enum -passive -d example.com',
parserStatus: 'text',
notes: 'Deep passive attack-surface mapping; complements subfinder with ASN and infra correlation.',
},
{
id: 'dnsx',
binary: 'dnsx',
name: 'dnsx',
category: 'dns',
families: ['web_api', 'cloud_infra', 'social_osint'],
risk: 'passive',
execution: 'safe_command',
networked: true,
evidenceKinds: ['dns_record', 'subdomain'],
outputFormats: ['jsonl', 'text'],
installHint: 'brew install projectdiscovery/tap/dnsx',
commandHint: 'dnsx -d example.com -a -aaaa -cname -resp -json',
parserStatus: 'text',
notes: 'Fast DNS resolution/validation to confirm live records from discovered candidate hosts.',
},
{
id: 'testssl',
binary: 'testssl.sh',
name: 'testssl.sh',
category: 'crypto',
families: ['web_api', 'crypto_secrets', 'reporting_remediation'],
risk: 'active',
execution: 'safe_command',
networked: true,
evidenceKinds: ['tls_config', 'cipher_weakness', 'certificate_finding'],
outputFormats: ['json', 'text'],
installHint: 'brew install testssl',
commandHint: 'testssl.sh --jsonfile-pretty - https://example.com:443',
parserStatus: 'text',
notes: 'TLS/SSL posture check (protocols, ciphers, cert, known CVEs) against an authorized endpoint.',
},
{
id: 'waybackurls',
binary: 'waybackurls',
name: 'waybackurls',
category: 'osint',
families: ['web_api', 'social_osint', 'reporting_remediation'],
risk: 'passive',
execution: 'safe_command',
networked: true,
evidenceKinds: ['historical_url', 'endpoint'],
outputFormats: ['text'],
installHint: 'go install github.com/tomnomnom/waybackurls@latest',
commandHint: 'waybackurls example.com',
parserStatus: 'text',
notes: 'Pulls historical URLs from the Wayback Machine to surface forgotten endpoints and parameters.',
},
];
export const SAFE_COMMANDS = [
+186 -37
View File
@@ -7,6 +7,9 @@
import { EventEmitter } from 'eventemitter3';
import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import * as net from 'net';
import * as dns from 'dns';
@@ -61,6 +64,115 @@ async function checkPort(host: string, port: number, timeout: number = 2000): Pr
});
}
// =============================================================================
// TARGET HEADER INJECTION
// =============================================================================
interface TargetHeaderConfig {
origin: string;
headers: Headers;
}
const FORBIDDEN_TARGET_HEADERS = new Set([
'connection', 'content-length', 'host', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);
/** Parse an exact-origin binding and its default headers. Invalid configuration fails closed. */
function parseTargetHeaderConfig(): TargetHeaderConfig | null {
const rawOrigin = process.env.TEMPEST_TARGET_ORIGIN?.trim();
const rawHeaders = process.env.TEMPEST_TARGET_HEADERS?.trim();
if (!rawOrigin || !rawHeaders) return null;
try {
const target = new URL(rawOrigin);
if (!['http:', 'https:'].includes(target.protocol)
|| target.username || target.password
|| target.pathname !== '/' || target.search || target.hash) {
return null;
}
const parsed: unknown = JSON.parse(rawHeaders);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const headers = new Headers();
for (const [name, value] of Object.entries(parsed)) {
if (typeof value !== 'string' || FORBIDDEN_TARGET_HEADERS.has(name.toLowerCase())) return null;
headers.set(name, value);
}
return headers.keys().next().done ? null : { origin: target.origin, headers };
} catch {
return null;
}
}
function targetHeadersForUrl(url: string | URL, explicit?: RequestInit['headers']): Headers | undefined {
const merged = new Headers();
const config = parseTargetHeaderConfig();
try {
if (config && new URL(url).origin === config.origin) {
config.headers.forEach((value, name) => merged.set(name, value));
}
} catch {
// Let fetch or curl report malformed URLs; they simply receive no configured secrets.
}
new Headers(explicit).forEach((value, name) => merged.set(name, value));
return merged.keys().next().done ? undefined : merged;
}
function redactConfiguredSecrets(result: ToolResult): ToolResult {
const config = parseTargetHeaderConfig();
const secrets = config ? [...config.headers.values()].filter(Boolean) : [];
if (secrets.length === 0) return result;
const redact = (value: unknown): unknown => {
if (typeof value === 'string') {
return secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), value);
}
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, redact(nested)]));
}
return value;
};
return redact(result) as ToolResult;
}
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const CROSS_ORIGIN_CREDENTIAL_HEADERS = ['authorization', 'cookie', 'cookie2', 'proxy-authorization'];
/** Apply exact-origin headers to every built-in fetch without leaking them across redirects. */
async function targetFetch(url: string | URL, init: RequestInit = {}): Promise<Response> {
const config = parseTargetHeaderConfig();
let currentUrl = new URL(url);
if (!config || currentUrl.origin !== config.origin) return globalThis.fetch(url, init);
const explicitHeaders = new Headers(init.headers);
let method = (init.method || 'GET').toUpperCase();
let body = init.body;
for (let redirects = 0; redirects <= 20; redirects++) {
const headers = targetHeadersForUrl(currentUrl, explicitHeaders);
const response = await globalThis.fetch(currentUrl, { ...init, method, body, headers, redirect: 'manual' });
const location = response.headers.get('location');
if (!REDIRECT_STATUS.has(response.status) || !location) return response;
if (redirects === 20) throw new Error('Target request exceeded 20 redirects');
const nextUrl = new URL(location, currentUrl);
if (nextUrl.origin !== currentUrl.origin) {
config.headers.forEach((_value, name) => explicitHeaders.delete(name));
CROSS_ORIGIN_CREDENTIAL_HEADERS.forEach(name => explicitHeaders.delete(name));
}
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
method = 'GET';
body = undefined;
['content-encoding', 'content-language', 'content-location', 'content-type'].forEach(name => explicitHeaders.delete(name));
}
currentUrl = nextUrl;
}
throw new Error('Target request redirect handling failed');
}
// =============================================================================
// EVENTS
// =============================================================================
@@ -301,7 +413,7 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
const startTime = Date.now();
try {
const result = await tool.handler(context);
const result = redactConfiguredSecrets(await tool.handler(context));
const durationMs = Date.now() - startTime;
execution.completedAt = Date.now();
@@ -312,7 +424,10 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
return result;
} catch (error) {
execution.completedAt = Date.now();
execution.error = error instanceof Error ? error.message : String(error);
execution.error = redactConfiguredSecrets({
success: false,
error: error instanceof Error ? error.message : String(error),
}).error;
this.emit('tool:error', { tool, error: error as Error });
@@ -748,11 +863,11 @@ export const BUILTIN_TOOLS: CustomTool[] = [
handler: async (context) => {
const url = context.parameters.url as string;
const method = (context.parameters.method as string) || 'GET';
const headers = (context.parameters.headers as Record<string, string>) || {};
const headers = context.parameters.headers as Record<string, string> | undefined;
const body = context.parameters.body as string | undefined;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method,
headers,
body: body && method !== 'GET' ? body : undefined,
@@ -787,7 +902,10 @@ export const BUILTIN_TOOLS: CustomTool[] = [
'Permissions-Policy', 'Cross-Origin-Opener-Policy', 'Cross-Origin-Resource-Policy',
];
try {
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
const response = await targetFetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(10000),
});
const analysis = securityHeaders.map(h => {
const value = response.headers.get(h);
return `${h}: ${value ? `✓ ${value}` : '✗ Missing'}`;
@@ -834,7 +952,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
batch.map(async (path) => {
const fullUrl = `${baseUrl}/${path}`;
try {
const response = await fetch(fullUrl, {
const response = await targetFetch(fullUrl, {
method: 'GET',
signal: AbortSignal.timeout(timeout),
redirect: 'manual',
@@ -895,7 +1013,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
});
@@ -1022,7 +1140,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -1099,7 +1217,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
try {
const baselineUrl = new URL(baseUrl);
baselineUrl.searchParams.set(param, 'normalvalue');
const baselineResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baselineResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baselineBody = await baselineResp.text();
baselineLength = baselineBody.length;
} catch {
@@ -1111,7 +1229,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
const response = await targetFetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
const body = await response.text();
const bodyLower = body.toLowerCase();
@@ -1321,7 +1439,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
baselineBody.set(usernameField, username);
baselineBody.set(passwordField, 'definitely_invalid_password_xyz123!@#');
const baselineResp = await fetch(url, {
const baselineResp = await targetFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: baselineBody.toString(),
@@ -1345,7 +1463,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
body.set(usernameField, username);
body.set(passwordField, password);
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
@@ -1605,7 +1723,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
const robotsUrl = `${baseUrl}/robots.txt`;
const response = await fetch(robotsUrl, {
const response = await targetFetch(robotsUrl, {
signal: AbortSignal.timeout(10000),
});
@@ -1766,7 +1884,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
// Fetch main page
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
});
const headers = Object.fromEntries(response.headers.entries());
@@ -1840,7 +1958,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const probePromises = versionPaths.map(async (vp) => {
try {
const probeResp = await fetch(`${baseUrl}${vp.path}`, {
const probeResp = await targetFetch(`${baseUrl}${vp.path}`, {
signal: AbortSignal.timeout(5000),
redirect: 'manual',
});
@@ -1994,7 +2112,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
signal: AbortSignal.timeout(10000),
});
@@ -2151,7 +2269,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
batch.map(async (path) => {
try {
const fullUrl = `${baseUrl}${path}`;
const resp = await fetch(fullUrl, {
const resp = await targetFetch(fullUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000),
redirect: 'manual',
@@ -2231,7 +2349,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
// First try OPTIONS to see if Allow header is returned
let optionsAllow: string | null = null;
try {
const optResp = await fetch(url, {
const optResp = await targetFetch(url, {
method: 'OPTIONS',
signal: AbortSignal.timeout(5000),
});
@@ -2253,7 +2371,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
}
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method,
signal: AbortSignal.timeout(5000),
redirect: 'manual',
@@ -2332,7 +2450,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
for (const test of testOrigins) {
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
headers: { 'Origin': test.origin },
signal: AbortSignal.timeout(5000),
@@ -2403,7 +2521,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
redirect: 'manual', // Don't follow redirects to capture Set-Cookie
});
@@ -2521,7 +2639,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
redirect: 'manual', // Don't follow redirects
signal: AbortSignal.timeout(5000),
});
@@ -2613,7 +2731,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
const baselineUrl = new URL(baseUrl);
baselineUrl.searchParams.set(param, 'nonexistent_file_xyz');
const baseResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baseResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baseBody = await baseResp.text();
baselineLength = baseBody.length;
} catch {
@@ -2625,7 +2743,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -2727,7 +2845,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -2803,7 +2921,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
signal: AbortSignal.timeout(10000),
});
@@ -3102,6 +3220,10 @@ function sanitizeExternalFlags(tool: 'curl' | 'nmap', raw?: string): string[] {
return out;
}
function withoutCurlRedirectFlags(flags: string[]): string[] {
return flags.filter(flag => !/^--location(?:-trusted)?(?:=|$)/i.test(flag) && !/^-[^-]*L/.test(flag));
}
export const EXTERNAL_TOOLS: CustomTool[] = [
{
name: 'nmap_scan',
@@ -3249,6 +3371,8 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
const flags = context.parameters.flags as string | undefined;
const args = ['-s', '-i', '-X', method];
let configuredHeaders = targetHeadersForUrl(url);
let configDir: string | undefined;
if (data) {
// A body starting with @ (read local file) or < (read stdin) turns curl into a local-file
// disclosure primitive. --data-raw sends it verbatim and disables @/< interpretation.
@@ -3256,19 +3380,44 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
args.push('--data-raw', data);
}
if (headers) {
for (const h of headers.split(',')) {
args.push('-H', h.trim());
const headerLines = headers.split(',').map(header => header.trim()).filter(Boolean);
if (configuredHeaders) {
const explicitHeaders = new Headers();
for (const header of headerLines) {
const separator = header.indexOf(':');
if (separator <= 0) return { success: false, error: `curl_request: invalid header '${header}'` };
explicitHeaders.set(header.slice(0, separator).trim(), header.slice(separator + 1).trim());
}
configuredHeaders = targetHeadersForUrl(url, explicitHeaders);
} else {
for (const header of headerLines) args.push('-H', header);
}
}
if (flags) args.push(...sanitizeExternalFlags('curl', flags)); // drop -o/-K/-T/-x file-read/write injection
args.push(url);
const result = await runSubprocess('curl', args, { timeout: 30000 });
return {
success: result.exitCode === 0,
output: result.stdout,
error: result.exitCode !== 0 ? result.stderr : undefined,
};
if (flags) {
const sanitized = sanitizeExternalFlags('curl', flags); // drop -o/-K/-T/-x file-read/write injection
args.push(...(configuredHeaders ? withoutCurlRedirectFlags(sanitized) : sanitized));
}
try {
if (configuredHeaders) {
configDir = await mkdtemp(join(tmpdir(), 't3mp3st-curl-'));
const configPath = join(configDir, 'headers.conf');
const escapeConfig = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const config = [...configuredHeaders.entries()]
.map(([name, value]) => `header = "${escapeConfig(`${name}: ${value}`)}"`)
.join('\n');
await writeFile(configPath, `${config}\n`, { mode: 0o600 });
args.push('--config', configPath);
}
args.push(url);
const result = await runSubprocess('curl', args, { timeout: 30000 });
return redactConfiguredSecrets({
success: result.exitCode === 0,
output: result.stdout,
error: result.exitCode !== 0 ? result.stderr : undefined,
});
} finally {
if (configDir) await rm(configDir, { recursive: true, force: true });
}
},
},
];
+329
View File
@@ -0,0 +1,329 @@
/**
* T3MP3ST Arsenal — tool-output → structured evidence parsers.
*
* Turns the raw stdout of the JSON-emitting scanners into `ToolFinding[]` so the generic
* adapter factory (src/arsenal/adapter-tools.ts) can return STRUCTURED findings, not just
* text. Downstream the pipe already exists and is honest-by-construction: the agent loop
* stamps `provenance:'tool'` + the raw output onto each finding, the operator materialises
* it into a `Finding` with output-evidence, and the live gate (src/evidence/gate.ts) can
* then mark it verified. Until now these adapters (parserStatus:'planned') returned raw
* stdout only — the structured channel (`ToolResult.findings`) was never populated.
*
* HONESTY CONTRACT (same discipline as stub-honesty / no-phantom-tools):
* - Pure, dependency-free, and NEVER throws. Empty / garbled output → `[]`, never a
* fabricated finding. The caller keeps the raw stdout as the evidence of record; a
* parser SUMMARISES real output, it does not invent it.
* - Parsers do NOT set provenance/toolName/toolOutput — the agent loop stamps those from
* the real subprocess result, so a parser can never forge provenance.
*/
import type { Severity, ToolFinding } from '../types/index.js';
import { redactString } from '../redact.js';
// ── small, defensive helpers ────────────────────────────────────────────────
const SEVERITIES = new Set<Severity>(['critical', 'high', 'medium', 'low', 'info']);
// Map the many scanner severity vocabularies (semgrep ERROR/WARNING/INFO, trivy/grype
// CRITICAL…NEGLIGIBLE, npm-audit "moderate", …) onto the five canonical levels.
const SEVERITY_ALIAS: Record<string, Severity> = {
error: 'high', warning: 'medium', warn: 'medium', inventory: 'info', experiment: 'info',
moderate: 'medium', negligible: 'info', unknown: 'info', none: 'info', informational: 'info',
};
const sev = (v: unknown, fallback: Severity = 'info'): Severity => {
const s = String(v ?? '').trim().toLowerCase();
if (SEVERITIES.has(s as Severity)) return s as Severity;
return SEVERITY_ALIAS[s] ?? fallback;
};
const asObj = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
const asStrArray = (v: unknown): string[] =>
Array.isArray(v)
? v.map((x) => String(x)).filter((s) => s.trim().length > 0)
: v !== null && v !== undefined && v !== '' ? [String(v)] : [];
const num = (v: unknown): number | undefined => {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : undefined;
};
const truncate = (s: string, n = 400): string => (s.length > n ? `${s.slice(0, n)}…` : s);
/** Parse JSONL defensively: one object per non-empty line; any non-JSON line is skipped. */
function jsonl(raw: string): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (const line of String(raw).split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const parsed: unknown = JSON.parse(t);
if (parsed && typeof parsed === 'object') out.push(parsed as Record<string, unknown>);
} catch {
/* not a JSON line (banner, log, truncated tail) — skip */
}
}
return out;
}
/** Parse a single JSON document defensively; returns undefined on any error. */
function jsonDoc(raw: string): unknown {
try {
return JSON.parse(String(raw));
} catch {
return undefined;
}
}
// ── nuclei -jsonl : one finding per template match ───────────────────────────
function parseNuclei(raw: string): ToolFinding[] {
const out: ToolFinding[] = [];
for (const e of jsonl(raw)) {
const info = asObj(e.info);
const cls = asObj(info.classification);
const templateId = String(e['template-id'] ?? e.templateID ?? '');
const title = String(info.name || templateId || 'nuclei match');
const bits = [
e.host && `host: ${String(e.host)}`,
e['matched-at'] && `matched-at: ${String(e['matched-at'])}`,
info.description && `desc: ${truncate(String(info.description))}`,
].filter(Boolean) as string[];
const f: ToolFinding = { title, severity: sev(info.severity), details: bits.join(' | ') || title };
const cvss = num(cls['cvss-score']);
if (cvss !== undefined) f.cvss = cvss;
const cve = asStrArray(cls['cve-id']);
if (cve.length) f.cve = cve;
const cwe = asStrArray(cls['cwe-id']);
if (cwe.length) f.cwe = cwe;
if (info.remediation) f.remediation = String(info.remediation);
out.push(f);
}
return out;
}
// ── httpx -json : one info finding per live host (technology fingerprint) ─────
function parseHttpx(raw: string): ToolFinding[] {
const out: ToolFinding[] = [];
for (const e of jsonl(raw)) {
const url = String(e.url ?? e.input ?? e.host ?? '');
if (!url) continue;
const status = e.status_code ?? e['status-code'];
const title = String(e.title ?? '');
const tech = asStrArray(e.tech ?? e.technologies);
const server = String(e.webserver ?? e.server ?? '');
const bits = [
`url: ${url}`,
status !== null && status !== undefined && `status: ${String(status)}`,
title && `title: ${truncate(title, 120)}`,
server && `server: ${server}`,
tech.length && `tech: ${tech.join(', ')}`,
].filter(Boolean) as string[];
out.push({ title: `HTTP service ${url}`, severity: 'info', details: bits.join(' | ') });
}
return out;
}
// ── dalfox --format json : one finding per XSS probe ─────────────────────────
function parseDalfox(raw: string): ToolFinding[] {
const doc = jsonDoc(raw);
const arr: unknown[] = Array.isArray(doc)
? doc
: Array.isArray(asObj(doc).pocs) ? (asObj(doc).pocs as unknown[]) : [];
const out: ToolFinding[] = [];
for (const item of arr) {
const e = asObj(item);
const param = String(e.param ?? '');
const kind = String(e.type ?? e.inject_type ?? 'XSS');
const bits = [
e.method && `method: ${String(e.method)}`,
param && `param: ${param}`,
e.data && `data: ${truncate(String(e.data), 200)}`,
e.evidence && `evidence: ${truncate(String(e.evidence), 200)}`,
e.poc && `poc: ${truncate(String(e.poc), 300)}`,
].filter(Boolean) as string[];
const f: ToolFinding = {
title: `dalfox ${kind}${param ? ` @ ${param}` : ''}`,
severity: sev(e.severity, 'medium'),
details: bits.join(' | ') || String(e.message_str ?? e.message ?? 'XSS probe'),
};
const cwe = asStrArray(e.cwe).map((c) => (/^CWE-/i.test(c) ? c.toUpperCase() : `CWE-${c}`));
if (cwe.length) f.cwe = cwe;
out.push(f);
}
return out;
}
// ── ffuf -of json : one aggregate info finding (N paths) ─────────────────────
function parseFfuf(raw: string): ToolFinding[] {
const results = ((): unknown[] => {
const r = asObj(jsonDoc(raw)).results;
return Array.isArray(r) ? r : [];
})();
if (!results.length) return [];
const lines = results.slice(0, 50).map((r) => {
const e = asObj(r);
const path = String(asObj(e.input).FUZZ ?? e.input ?? e.url ?? '');
return `${path} (status ${String(e.status ?? '?')}, len ${String(e.length ?? '?')})`;
});
const more = results.length > 50 ? `\n… +${results.length - 50} more` : '';
return [{
title: `ffuf: ${results.length} path(s) discovered`,
severity: 'info',
details: lines.join('\n') + more,
}];
}
// ── katana -jsonl : one aggregate info finding (N endpoints) ──────────────────
function parseKatana(raw: string): ToolFinding[] {
const endpoints: string[] = [];
for (const e of jsonl(raw)) {
const ep = String(asObj(e.request).endpoint ?? e.endpoint ?? e.url ?? '');
if (ep) endpoints.push(ep);
}
if (!endpoints.length) return [];
const more = endpoints.length > 50 ? `\n… +${endpoints.length - 50} more` : '';
return [{
title: `katana: ${endpoints.length} endpoint(s) discovered`,
severity: 'info',
details: endpoints.slice(0, 50).join('\n') + more,
}];
}
// ── semgrep scan --json : one finding per rule match (SAST) ──────────────────
function parseSemgrep(raw: string): ToolFinding[] {
const results = ((): unknown[] => {
const r = asObj(jsonDoc(raw)).results;
return Array.isArray(r) ? r : [];
})();
const out: ToolFinding[] = [];
for (const item of results) {
const e = asObj(item);
const extra = asObj(e.extra);
const meta = asObj(extra.metadata);
const checkId = String(e.check_id ?? e.checkId ?? 'semgrep rule');
const path = String(e.path ?? '');
const line = num(asObj(e.start).line);
const where = path ? `${path}${line !== undefined ? `:${line}` : ''}` : '';
const f: ToolFinding = {
title: checkId,
severity: sev(extra.severity),
details: [where && `at ${where}`, extra.message && truncate(String(extra.message))].filter(Boolean).join(' | ') || checkId,
};
// semgrep CWE metadata is like ["CWE-89: SQL Injection"] — keep the CWE-NNN token.
const cwe = asStrArray(meta.cwe).map((c) => c.match(/CWE-\d+/i)?.[0] ?? '').filter(Boolean);
if (cwe.length) f.cwe = cwe.map((c) => c.toUpperCase());
const refs = asStrArray(meta.references);
if (refs.length) f.remediation = `refs: ${refs.slice(0, 3).join(', ')}`;
out.push(f);
}
return out;
}
// ── gitleaks detect --report-format json --redact : one finding per secret ────
function parseGitleaks(raw: string): ToolFinding[] {
const arr: unknown[] = Array.isArray(jsonDoc(raw)) ? (jsonDoc(raw) as unknown[]) : [];
const out: ToolFinding[] = [];
for (const item of arr) {
const e = asObj(item);
const rule = String(e.RuleID ?? e.Description ?? 'secret');
const file = String(e.File ?? '');
const line = num(e.StartLine);
const where = file ? `${file}${line !== undefined ? `:${line}` : ''}` : '';
// Defense in depth: the adapter already runs gitleaks with --redact, but scrub the details
// through redactString anyway so a raw secret can NEVER reach a finding or the evidence vault.
const details = redactString([where && `at ${where}`, String(e.Description ?? '')].filter(Boolean).join(' | '));
out.push({ title: `secret: ${rule}`, severity: 'high', details: details || `secret: ${rule}` });
}
return out;
}
// ── trivy fs --format json : one finding per package vulnerability ────────────
function parseTrivy(raw: string): ToolFinding[] {
const results = ((): unknown[] => {
const r = asObj(jsonDoc(raw)).Results;
return Array.isArray(r) ? r : [];
})();
const out: ToolFinding[] = [];
for (const res of results) {
const target = String(asObj(res).Target ?? '');
const vulns = asObj(res).Vulnerabilities;
if (!Array.isArray(vulns)) continue;
for (const item of vulns) {
const v = asObj(item);
const id = String(v.VulnerabilityID ?? '');
const pkg = String(v.PkgName ?? '');
const installed = String(v.InstalledVersion ?? '');
const fixed = String(v.FixedVersion ?? '');
const f: ToolFinding = {
title: String(v.Title || id || 'vulnerability'),
severity: sev(v.Severity),
details: [target && `target: ${target}`, pkg && `pkg: ${pkg}${installed ? ` ${installed}` : ''}`,
v.Description && truncate(String(v.Description), 200)].filter(Boolean).join(' | ') || id,
};
if (/^CVE-/i.test(id)) f.cve = [id];
const cwe = asStrArray(v.CweIDs);
if (cwe.length) f.cwe = cwe;
if (fixed) f.remediation = `upgrade ${pkg || 'package'} to ${fixed}`;
out.push(f);
}
}
return out;
}
// ── grype dir:. -o json : one finding per matched vulnerability ───────────────
function parseGrype(raw: string): ToolFinding[] {
const matches = ((): unknown[] => {
const m = asObj(jsonDoc(raw)).matches;
return Array.isArray(m) ? m : [];
})();
const out: ToolFinding[] = [];
for (const item of matches) {
const m = asObj(item);
const vuln = asObj(m.vulnerability);
const art = asObj(m.artifact);
const id = String(vuln.id ?? '');
const name = String(art.name ?? '');
const version = String(art.version ?? '');
const f: ToolFinding = {
title: id || 'vulnerability',
severity: sev(vuln.severity),
details: [name && `pkg: ${name}${version ? ` ${version}` : ''}`,
vuln.description && truncate(String(vuln.description), 200)].filter(Boolean).join(' | ') || id,
};
if (/^CVE-/i.test(id)) f.cve = [id];
out.push(f);
}
return out;
}
const PARSERS: Record<string, (raw: string) => ToolFinding[]> = {
nuclei: parseNuclei,
httpx: parseHttpx,
dalfox: parseDalfox,
ffuf: parseFfuf,
katana: parseKatana,
semgrep: parseSemgrep,
gitleaks: parseGitleaks,
trivy: parseTrivy,
grype: parseGrype,
};
/** Adapter ids that have a structured output parser wired here. */
export const PARSED_TOOL_IDS: readonly string[] = Object.keys(PARSERS);
/** True iff a structured parser is wired for this adapter id. */
export function hasParser(toolId: string): boolean {
return Object.prototype.hasOwnProperty.call(PARSERS, toolId);
}
/**
* Parse a tool's raw stdout into structured `ToolFinding[]`.
*
* Returns `[]` for any tool with no parser, for empty output, and for output a parser cannot
* make sense of. NEVER throws — an unexpected parser failure degrades to `[]` so a malformed
* scan can never crash the agent loop or fabricate a finding.
*/
export function parseToolOutput(toolId: string, rawOutput: string): ToolFinding[] {
const parser = PARSERS[toolId];
if (!parser || !rawOutput || !String(rawOutput).trim()) return [];
try {
return parser(rawOutput);
} catch {
return [];
}
}
+104 -13
View File
@@ -11,6 +11,8 @@ import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import type { LLMProvider, LLMConfig, FallbackEntry, OpsecLevel } from '../types/index.js';
type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'local';
// =============================================================================
// CONFIGURATION SCHEMA
// =============================================================================
@@ -25,6 +27,7 @@ export interface TempestSettings {
xai?: string;
gemini?: string;
deepseek?: string;
litellm?: string;
local?: string;
};
@@ -64,6 +67,12 @@ export interface TempestSettings {
defaultModel: string;
};
// LiteLLM AI gateway proxy — routes to 100+ LLM providers via unified API
litellm: {
baseUrl: string;
defaultModel: string;
};
// Google Gemini — native Gemini API via its OpenAI-compatible endpoint
gemini: {
baseUrl: string;
@@ -139,6 +148,11 @@ const DEFAULT_SETTINGS: TempestSettings = {
defaultModel: 'grok-build-0.1',
},
litellm: {
baseUrl: 'http://localhost:4000/v1',
defaultModel: 'gpt-4o',
},
// Gemini's OpenAI-compatible surface lives under /v1beta/openai. The OpenAIAdapter posts to
// `${baseUrl}/chat/completions`, so the /openai path segment is REQUIRED here — without it,
// requests hit /v1beta/chat/completions, which is not a valid Gemini endpoint.
@@ -409,6 +423,16 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast'],
},
],
litellm: [
{
id: 'gpt-4o',
name: 'Any model via LiteLLM proxy (default: gpt-4o)',
provider: 'LiteLLM',
contextWindow: 128000,
maxOutput: 4096,
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'tools'],
},
],
codex: [
{
id: 'codex-default',
@@ -567,10 +591,14 @@ class ConfigManager {
join(homedir(), '.env'),
];
let envProvider: string | undefined;
for (const envPath of envPaths) {
if (existsSync(envPath)) {
const envContent = readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
// Validation variables added
const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'litellm', 'deepseek', 'local'];
for (const line of lines) {
const trimmed = line.trim();
@@ -583,9 +611,18 @@ class ConfigManager {
// empty OPENROUTER_API_KEY= — the .env file no longer clobbers it.
if (key && value && process.env[key] === undefined) {
process.env[key] = value;
// Track LLM_PROVIDER for default provider
if (key === 'LLM_PROVIDER' && VALID_PROVIDERS.includes(value.toLowerCase())) {
envProvider = value.toLowerCase();
}
}
}
}
// Set TEMPEST_DEFAULT_PROVIDER if LLM_PROVIDER found
if (envProvider && !process.env.TEMPEST_DEFAULT_PROVIDER) {
process.env.TEMPEST_DEFAULT_PROVIDER = envProvider;
}
break;
}
}
@@ -621,7 +658,7 @@ class ConfigManager {
/**
* Set an API key for a provider
*/
setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local', key: string): void {
setApiKey(provider: ApiKeyProvider, key: string): void {
const apiKeys = this.config.get('apiKeys');
apiKeys[provider] = key;
this.config.set('apiKeys', apiKeys);
@@ -630,7 +667,7 @@ class ConfigManager {
/**
* Get an API key for a provider
*/
getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): string | undefined {
getApiKey(provider: ApiKeyProvider): string | undefined {
// First check environment variables (highest priority)
// local provider: a self-hosted/OpenAI-compatible server MAY require a bearer
// (Zhipu/z.ai, Together, etc.) — accept TEMPEST_LOCAL_API_KEY or provider-specific vars.
@@ -647,6 +684,7 @@ class ConfigManager {
xai: 'XAI_API_KEY',
gemini: 'GEMINI_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
litellm: 'LITELLM_API_KEY',
};
// Force a fully UNCONFIGURED server (no key from env OR the saved store) — used by
@@ -665,7 +703,7 @@ class ConfigManager {
/**
* Check if a provider has a valid API key configured
*/
hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): boolean {
hasApiKey(provider: ApiKeyProvider): boolean {
const key = this.getApiKey(provider);
return !!key && key.length > 10;
}
@@ -673,12 +711,22 @@ class ConfigManager {
/**
* Remove an API key
*/
removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): void {
removeApiKey(provider: ApiKeyProvider): void {
const apiKeys = this.config.get('apiKeys');
delete apiKeys[provider];
this.config.set('apiKeys', apiKeys);
}
/**
* Check if a LiteLLM proxy is configured (base URL set via env or config)
*/
hasLiteLLMProxy(): boolean {
const envUrl = process.env.LITELLM_BASE_URL?.trim();
if (envUrl) return true;
const storedUrl = this.config.get('litellm')?.baseUrl;
return !!storedUrl && storedUrl !== 'http://localhost:4000/v1';
}
/**
* Get configured providers (those with API keys)
*/
@@ -690,6 +738,7 @@ class ConfigManager {
if (this.hasApiKey('anthropic')) providers.push('anthropic');
if (this.hasApiKey('openai')) providers.push('openai');
if (this.hasApiKey('xai')) providers.push('xai');
if (this.hasLiteLLMProxy()) providers.push('litellm');
if (this.hasApiKey('gemini')) providers.push('gemini');
if (this.hasApiKey('deepseek')) providers.push('deepseek');
@@ -706,7 +755,16 @@ class ConfigManager {
* Get the LLM configuration for the default or specified provider
*/
getLLMConfig(provider?: LLMProvider, model?: string): LLMConfig {
const actualProvider = provider || this.config.get('defaultProvider');
let actualProvider = provider;
if (!actualProvider) {
const envProvider = process.env.TEMPEST_DEFAULT_PROVIDER?.trim();
if (envProvider) {
actualProvider = envProvider as LLMProvider;
} else {
actualProvider = this.config.get('defaultProvider');
}
}
let apiKey: string | undefined;
let baseUrl: string | undefined;
@@ -739,6 +797,11 @@ class ConfigManager {
baseUrl = this.config.get('xai').baseUrl;
actualModel = model || this.config.get('xai').defaultModel;
break;
case 'litellm':
apiKey = this.getApiKey('litellm');
baseUrl = process.env.LITELLM_BASE_URL?.trim() || this.config.get('litellm').baseUrl;
actualModel = model || process.env.LITELLM_MODEL?.trim() || this.config.get('litellm').defaultModel;
break;
case 'gemini':
// Google Gemini via its OpenAI-compatible endpoint (native tool-calling).
apiKey = this.getApiKey('gemini');
@@ -764,9 +827,10 @@ class ConfigManager {
// Some OpenAI-compatible servers require a real bearer (Zhipu, Together, etc.) —
// TEMPEST_LOCAL_API_KEY (or a provider-specific env like ZAI_API_KEY) provides it.
baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api';
// 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so
// TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority.
actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
// Placeholder ids from AVAILABLE_MODELS / the static UI are not real served model tags.
// Treat them as unset so TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag
// passed in still takes priority.
actualModel = (model && !['local-model', 'local/ollama'].includes(model) ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
apiKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim();
break;
default:
@@ -799,8 +863,9 @@ class ConfigManager {
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
const chain: FallbackEntry[] = [];
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => {
if (p === primary || !this.hasApiKey(p)) return;
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek') => {
if (p === primary) return;
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
chain.push({
provider: p,
model: this.config.get(p).defaultModel,
@@ -810,6 +875,7 @@ class ConfigManager {
};
add('openrouter');
add('venice');
add('litellm');
add('anthropic');
add('openai');
add('xai');
@@ -841,6 +907,15 @@ class ConfigManager {
case 'deepseek':
this.config.set('defaultModel', this.config.get('deepseek').defaultModel);
break;
case 'xai':
this.config.set('defaultModel', this.config.get('xai').defaultModel);
break;
case 'gemini':
this.config.set('defaultModel', this.config.get('gemini').defaultModel);
break;
case 'litellm':
this.config.set('defaultModel', this.config.get('litellm').defaultModel);
break;
case 'codex':
this.config.set('defaultModel', this.config.get('codex').defaultModel);
break;
@@ -870,6 +945,15 @@ class ConfigManager {
case 'deepseek':
this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model });
break;
case 'xai':
this.config.set('xai', { ...this.config.get('xai'), defaultModel: model });
break;
case 'gemini':
this.config.set('gemini', { ...this.config.get('gemini'), defaultModel: model });
break;
case 'litellm':
this.config.set('litellm', { ...this.config.get('litellm'), defaultModel: model });
break;
case 'codex':
this.config.set('codex', { ...this.config.get('codex'), defaultModel: model });
break;
@@ -910,6 +994,7 @@ class ConfigManager {
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined,
},
};
writeFileSync(filePath, JSON.stringify(safeSettings, null, 2));
@@ -943,6 +1028,12 @@ OPENAI_API_KEY=
# Get your key at: https://console.x.ai/
XAI_API_KEY=
# LiteLLM Proxy (AI gateway — 100+ providers via single API)
# Docs: https://docs.litellm.ai/docs/proxy/quick_start
LITELLM_BASE_URL=http://localhost:4000/v1
LITELLM_API_KEY=
LITELLM_MODEL=gpt-4o
# Google Gemini API Key (direct Gemini API via its OpenAI-compatible endpoint)
# Get your key at: https://aistudio.google.com/apikey
GEMINI_API_KEY=
@@ -973,8 +1064,8 @@ TEMPEST_LOCAL_API_KEY=
export const config = new ConfigManager();
// Helper functions for quick access
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => config.getApiKey(provider);
export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek', key: string) => config.setApiKey(provider, key);
export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => config.hasApiKey(provider);
export const getApiKey = (provider: ApiKeyProvider) => config.getApiKey(provider);
export const setApiKey = (provider: ApiKeyProvider, key: string) => config.setApiKey(provider, key);
export const hasApiKey = (provider: ApiKeyProvider) => config.hasApiKey(provider);
export const getLLMConfig = (provider?: LLMProvider, model?: string) => config.getLLMConfig(provider, model);
export const getConfiguredProviders = () => config.getConfiguredProviders();
+101
View File
@@ -44,6 +44,7 @@ export {
createAnthropicBackbone,
createOpenRouterBackbone,
createOpenAIBackbone,
createLiteLLMBackbone,
createMockBackbone,
createLocalBackbone,
createBestAvailableBackbone,
@@ -212,6 +213,8 @@ import type {
OperatorArchetype,
CommandEvents,
Finding,
ScanProgressEvent,
Task,
} from './types/index.js';
// Re-export commonly used types
@@ -271,6 +274,7 @@ import {
const DEFAULT_AGENT_MAX_ITERATIONS = 15;
const LOCAL_AGENT_MAX_ITERATIONS = Number(process.env.T3MP3ST_LOCAL_AGENT_MAX_ITERATIONS || 30);
const MAX_PROGRESS_EVENTS = 300;
/**
* TEMPEST Command - Main orchestration controller
@@ -544,6 +548,40 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
* Setup event forwarding for an operator
*/
private setupOperatorEvents(operator: OperatorAgent): void {
operator.on('task:started', ({ task }) => {
this.recordProgress('task_started', operator, task, `Started ${task.name}`);
});
operator.on('task:completed', ({ task, result }) => {
const findings = result.findings?.length ? ` Findings: ${result.findings.join(', ')}` : '';
this.recordProgress(
'task_completed',
operator,
task,
`${result.success === false ? 'Finished unsuccessfully' : 'Completed'} ${task.name}.${findings}`,
{ success: result.success !== false }
);
});
operator.on('task:failed', ({ task, error }) => {
this.recordProgress('task_failed', operator, task, error, { success: false });
});
operator.on('agent:thinking', ({ task, content }) => {
this.recordProgress('thinking', operator, task, content);
});
operator.on('agent:tool_call', ({ task, name, args, source }) => {
this.recordProgress('tool_call', operator, task, `${name} ${JSON.stringify(args || {})}`, { toolName: name, source });
});
operator.on('agent:tool_result', ({ task, name, result, source }) => {
const detail = result.success
? (result.output || 'Tool completed')
: (result.error || result.output || 'Tool failed');
this.recordProgress('tool_result', operator, task, detail, { toolName: name, success: result.success, source });
});
operator.on('finding:discovered', ({ finding }) => {
this.vault.addFinding(finding);
this.emit('finding:discovered', { finding, operatorId: operator.id });
@@ -628,6 +666,34 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
});
}
private recordProgress(
kind: ScanProgressEvent['kind'],
operator: OperatorAgent,
task: Task | undefined,
detail: string,
extra: Partial<Pick<ScanProgressEvent, 'toolName' | 'success' | 'source'>> = {}
): void {
const compact = String(detail || '').replace(/\s+/g, ' ').trim().slice(0, 2000);
const event: ScanProgressEvent = {
id: `progress-${Date.now()}-${Math.random().toString(16).slice(2)}`,
timestamp: Date.now(),
kind,
operatorId: operator.id,
callsign: operator.callsign,
archetype: operator.archetype,
taskId: task?.id,
taskName: task?.name,
detail: compact,
...extra,
};
this.progressEvents.push(event);
if (this.progressEvents.length > MAX_PROGRESS_EVENTS) {
this.progressEvents.splice(0, this.progressEvents.length - MAX_PROGRESS_EVENTS);
}
this.emit('scan:progress', event);
}
/**
* Parse a finding and update the target's services/vulnerabilities.
* This is the intelligence pipeline that feeds data between phases.
@@ -826,6 +892,8 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
* CLI work; it only fires on truly-hung dispatches. Override via
* T3MP3ST_TASK_TIMEOUT_MS.
*/
private progressEvents: ScanProgressEvent[] = [];
/**
* Resolve the dispatch timeout from the environment, falling back to the
* provider-specific default. Guards against a non-numeric / non-positive override.
@@ -951,6 +1019,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
// Auto-spawn an operator if none exists for this archetype
if (!operator) {
if (this.llm.getProvider() === 'local-agent') continue;
const allOps = this.cell.getAllOperators();
const archetypeCount = allOps.filter(op => op.archetype === task.operatorType).length;
// Spawn up to 3 operators per archetype for parallelism
@@ -1099,6 +1168,9 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
* Auto-spawn operators needed for a given kill chain phase
*/
private autoSpawnForPhase(phase: KillChainPhase): void {
if (this.llm.getProvider() === 'local-agent') {
return;
}
const phaseOperators: Record<string, OperatorArchetype[]> = {
[KillChainPhase.RECON]: ['recon'],
[KillChainPhase.WEAPONIZE]: ['scanner'],
@@ -1139,6 +1211,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
this.on('detection:triggered', (data) => broadcast('detection', data));
this.on('mission:phase_changed', (data) => broadcast('phase_changed', data));
this.on('approval:decision', (data) => broadcast('arsenal.approval', data));
this.on('scan:progress', (data) => broadcast('scan:progress', data));
this.on('tick', (count) => {
// Broadcast status every 5 ticks to avoid flooding
if (typeof count === 'number' && count % 5 === 0) {
@@ -1232,8 +1305,19 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
opsec: ReturnType<OpsecController['getStats']>;
activeMission: string | null;
stallReason: string | null;
progress: ScanProgressEvent[];
tasks: Array<{
id: string;
name: string;
phase: string;
status: string;
operatorType: string;
assignedTo?: string;
result?: { success: boolean; output?: string; error?: string; findings?: string[] };
}>;
} {
const activeMission = this.mission.getActiveMission();
const taskQueue = this.mission.getTaskQueue();
return {
name: this.name,
@@ -1246,6 +1330,23 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
opsec: this.opsec.getStats(),
activeMission: activeMission?.id || null,
stallReason: this.stallReason,
progress: [...this.progressEvents],
tasks: activeMission
? taskQueue.getForMission(activeMission.id).map(task => ({
id: task.id,
name: task.name,
phase: task.phase,
status: task.status,
operatorType: task.operatorType,
assignedTo: task.assignedTo,
result: task.result ? {
success: task.result.success,
output: task.result.output,
error: task.result.error,
findings: task.result.findings,
} : undefined,
}))
: [],
};
}
+42 -4
View File
@@ -4,6 +4,7 @@
* Multi-provider LLM integration supporting:
* - OpenRouter (recommended - access to Claude, GPT-4, Llama, etc.)
* - Venice (OpenAI-compatible, privacy-focused / uncensored models)
* - LiteLLM (OpenAI-compatible AI gateway — 100+ providers via unified proxy)
* - Anthropic (direct Claude access)
* - OpenAI (GPT models)
* - Mock (for testing)
@@ -360,6 +361,28 @@ class VeniceAdapter extends OpenRouterAdapter {
}
}
// =============================================================================
// LITELLM ADAPTER
// =============================================================================
// LiteLLM is an AI gateway proxy that routes to 100+ LLM providers through a
// single OpenAI-compatible API. Same wire shape as OpenRouter (Bearer auth,
// POST /chat/completions, identical request/response + tool-calling + SSE
// stream), so it reuses the entire OpenRouter adapter and only differs in its
// default base URL and the key-required error message.
class LiteLLMAdapter extends OpenRouterAdapter {
name = 'litellm';
validateConfig(): { valid: boolean; error?: string } {
if (!this.config.baseUrl) {
return {
valid: false,
error: 'LiteLLM proxy base URL is required. Set LITELLM_BASE_URL or configure via tempest setup. Docs: https://docs.litellm.ai/docs/proxy/quick_start',
};
}
return { valid: true };
}
}
// =============================================================================
// ANTHROPIC ADAPTER
// =============================================================================
@@ -744,9 +767,12 @@ class LocalAdapter implements LLMProviderAdapter {
+ (contract
? ' You drive a ReAct loop: REQUEST tools, the harness runs them and returns results, you reason until the surface is exhausted.' + contract
: ' Operate in planning and analysis mode; when JSON is requested, return ONLY the requested block — no preamble.');
const out: { role: string; content: string }[] = [{ role: 'system', content: preamble }];
const systemParts = [preamble];
const out: { role: string; content: string }[] = [];
for (const m of messages) {
if (m.role === 'assistant' && m.toolCalls?.length) {
if (m.role === 'system') {
if (m.content.trim()) systemParts.push(m.content);
} else if (m.role === 'assistant' && m.toolCalls?.length) {
out.push({ role: 'assistant', content: `[requested tools: ${m.toolCalls.map(t => t.name).join(', ')}]` });
} else if (m.role === 'tool') {
out.push({ role: 'user', content: `TOOL RESULT (${m.name || 'tool'}):\n${m.content}` });
@@ -754,7 +780,7 @@ class LocalAdapter implements LLMProviderAdapter {
out.push({ role: m.role, content: m.content });
}
}
return out;
return [{ role: 'system', content: systemParts.join('\n\n') }, ...out];
}
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
@@ -1201,6 +1227,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
return new OpenRouterAdapter(config);
case 'venice':
return new VeniceAdapter(config);
case 'litellm':
return new LiteLLMAdapter(config);
case 'anthropic':
return new AnthropicAdapter(config);
case 'openai':
@@ -1491,6 +1519,13 @@ export function createDeepSeekBackbone(apiKey?: string, model?: string): LLMBack
return new LLMBackbone(llmConfig);
}
export function createLiteLLMBackbone(apiKey?: string, model?: string, baseUrl?: string): LLMBackbone {
const llmConfig = config.getLLMConfig('litellm', model);
if (apiKey) llmConfig.apiKey = apiKey;
if (baseUrl) llmConfig.baseUrl = baseUrl;
return new LLMBackbone(llmConfig);
}
export function createMockBackbone(): LLMBackbone {
return new LLMBackbone({
provider: 'mock',
@@ -1514,7 +1549,7 @@ export function createLocalBackbone(model?: string, baseUrl?: string): LLMBackbo
* Create the best available backbone based on configured API keys
*/
export function createBestAvailableBackbone(): LLMBackbone {
// Priority: OpenRouter > Venice > Anthropic > OpenAI > Local > Mock
// Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > Local > Mock
const providers = config.getConfiguredProviders();
if (providers.includes('openrouter')) {
@@ -1523,6 +1558,9 @@ export function createBestAvailableBackbone(): LLMBackbone {
if (providers.includes('venice')) {
return createVeniceBackbone();
}
if (providers.includes('litellm')) {
return createLiteLLMBackbone();
}
if (providers.includes('anthropic')) {
return createAnthropicBackbone();
}
+37 -5
View File
@@ -22,6 +22,7 @@ import {
import type { LLMBackbone } from '../llm/index.js';
import type { Arsenal } from '../arsenal/index.js';
import type { AgentLoop } from '../agent/index.js';
import type { ToolResult } from '../types/index.js';
import type { Target } from '../types/index.js';
import { OPERATOR_SYSTEM_PROMPTS } from '../prompts/index.js';
import { gateLiveFinding } from '../evidence/gate.js';
@@ -36,6 +37,9 @@ export interface OperatorEvents {
'task:completed': { task: Task; result: TaskResult };
'task:failed': { task: Task; error: string };
'task:decomposed': { parent: Task; subtasks: Task[]; reason: string };
'agent:thinking': { task: Task; content: string };
'agent:tool_call': { task: Task; name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' };
'agent:tool_result': { task: Task; name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
'finding:discovered': { finding: Finding };
'finding:gate-blocked': { finding: Finding; reasons: string[] };
'credential:harvested': { credential: Credential };
@@ -422,11 +426,38 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
// Pass the white-box source excerpt (if any) so the model sees the target's
// real source alongside the task; empty string keeps black-box behavior.
if (this.agentLoop && this.llm) {
// [Phase-2] Register as live on the board and pull the shared situation report so this
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
this.board?.heartbeat(this.id, 'hunting', task.name);
const sharedContext = this.board?.situationReport(this.id);
const result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
const onThinking = ({ content }: { content: string }): void => {
this.emit('agent:thinking', { task, content });
};
const onToolCall = ({ name, args, source }: { name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' }): void => {
this.emit('agent:tool_call', { task, name, args, source });
};
const onToolResult = ({ name, result, source }: { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' }): void => {
this.emit('agent:tool_result', { task, name, result, source });
};
const canForwardAgentEvents =
typeof this.agentLoop.on === 'function' &&
typeof this.agentLoop.off === 'function';
if (canForwardAgentEvents) {
this.agentLoop.on('agent:thinking', onThinking);
this.agentLoop.on('agent:tool_call', onToolCall);
this.agentLoop.on('agent:tool_result', onToolResult);
}
let result;
try {
// [Phase-2] Register as live on the board and pull the shared situation report so this
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
this.board?.heartbeat(this.id, 'hunting', task.name);
const sharedContext = this.board?.situationReport(this.id);
result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
} finally {
if (canForwardAgentEvents) {
this.agentLoop.off('agent:thinking', onThinking);
this.agentLoop.off('agent:tool_call', onToolCall);
this.agentLoop.off('agent:tool_result', onToolResult);
}
}
// Convert agent findings to operator findings.
// PROVENANCE-HONEST: only a tool-backed finding gets tool-output evidence (the raw
@@ -445,6 +476,7 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
phase: task.phase as KillChainPhase,
cvss: finding.cvss,
cve: finding.cve,
cwe: finding.cwe,
evidence: toolBacked
? [{
type: 'output',
+24 -10
View File
@@ -23,7 +23,7 @@ import {
import { DecompositionOrchestrator } from '../orchestration/index.js';
import type { DecompositionConfig } from '../orchestration/index.js';
import type { LLMProvider } from '../types/index.js';
import { mkdtempSync, realpathSync, rmSync, statSync } from 'node:fs';
import { mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { homedir, tmpdir } from 'node:os';
import { join as joinPath, isAbsolute, relative, resolve, sep } from 'node:path';
@@ -82,13 +82,6 @@ function isGitHubHttpsRepoUrl(input: string): boolean {
}
}
function githubCloneUrlWithToken(repoUrl: string, token: string): string {
const url = new URL(repoUrl);
url.username = 'x-access-token';
url.password = token;
return url.toString();
}
function getGitHubRepoToken(): string | undefined {
return process.env.T3MP3ST_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim();
}
@@ -105,15 +98,36 @@ export function cloneGitHubRepoForAnalysis(repoUrl: string): ResolvedRepoSource
const tempRoot = mkdtempSync(joinPath(tmpdir(), 't3mp3st-repo-'));
const cloneDir = joinPath(tempRoot, 'repo');
const askPassPath = joinPath(tempRoot, 'git-askpass.sh');
try {
writeFileSync(
askPassPath,
[
'#!/bin/sh',
'case "$1" in',
"*Username*) printf '%s\\n' 'x-access-token' ;;",
'*) printf "%s\\n" "$T3MP3ST_GIT_TOKEN" ;;',
'esac',
'',
].join('\n'),
{ mode: 0o700 },
);
execFileSync('git', [
'-c', 'credential.helper=',
'clone',
'--depth', '1',
'--filter=blob:none',
githubCloneUrlWithToken(repoUrl, token),
repoUrl,
cloneDir,
], { stdio: ['ignore', 'ignore', 'pipe'] });
], {
stdio: ['ignore', 'ignore', 'pipe'],
env: {
...process.env,
GIT_ASKPASS: askPassPath,
GIT_TERMINAL_PROMPT: '0',
T3MP3ST_GIT_TOKEN: token,
},
});
return {
repoPath: realpathSync(cloneDir),
+268 -41
View File
@@ -32,9 +32,6 @@ import type { OperatorArchetype } from './types/index.js';
import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js';
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from './recon/whitebox.js';
import { redactCredential } from './evidence/index.js';
import dotenv from 'dotenv';
dotenv.config();
const execFileAsync = promisify(execFile);
@@ -144,7 +141,8 @@ const PORT = process.env.T3MP3ST_PORT || 3333;
// this local command-executing API (CSRF / DNS-rebinding), NOT remote attackers.
// The CORS lock + origin guard below defend that vector; if this is ever exposed
// beyond localhost, add real Bearer-token auth as the upgrade path.
const HOST = process.env.T3MP3ST_HOST || '127.0.0.1';
// DOCKER: Inside containers, bind to 0.0.0.0 (env var can still override).
const HOST = process.env.T3MP3ST_HOST || (process.env.DOCKER === 'true' ? '0.0.0.0' : '127.0.0.1');
// B-02: only enforce the Host-header allow-list when bound to loopback (the
// default). If the operator EXPLICITLY exposes the server (T3MP3ST_HOST set to a
// non-loopback address) they've opted into network access behind their own front,
@@ -184,6 +182,25 @@ function isLoopbackOrigin(originHeader: string | undefined): boolean {
}
}
// #84: a SAME-ORIGIN request — the caller's Origin (or Referer) host:port equals THIS server's
// own Host header — is by definition not a foreign-website drive-by. It is trusted ONLY when the
// operator has bound to a non-loopback address (see HOST_IS_LOOPBACK), i.e. they have explicitly
// opted into network access — the same decision that stands the anti-rebinding Host guard down
// below. That lets the operator's own LAN/custom-host UI (e.g. http://192.168.x.x:3333/ui) talk to
// its backend instead of being rejected as cross-origin, while a foreign origin (evil.com) still
// mismatches the Host and is refused. On a loopback bind this is never consulted: the strict
// loopback-only origin gate plus the Host guard remain fully in force, so no CSRF/rebinding gap.
// URL.host is "hostname:port" with the default port (80/443) omitted — exactly how browsers set
// BOTH the Origin and the Host header, so a genuine same-origin request compares equal.
function isSameOriginAsHost(source: string | undefined, hostHeader: string | undefined): boolean {
if (!source || !hostHeader) return false;
try {
return new URL(source).host.toLowerCase() === hostHeader.trim().toLowerCase();
} catch {
return false;
}
}
// CORS locked to the localhost UI origins ONLY. A same-origin fetch from the UI
// (or a tool with no Origin like curl/CLI) is allowed; any other website's
// Origin is rejected so the browser blocks it from reading our responses.
@@ -218,6 +235,8 @@ app.use((req: Request, res: Response, next: NextFunction) => {
// No Origin AND no Referer → trusted local caller (curl/CLI/MCP). Allow.
if (!source) return next();
if (isLoopbackOrigin(source)) return next();
// #84: on a non-loopback (opted-in network) bind, also trust the operator's own same-origin UI.
if (!HOST_IS_LOOPBACK && isSameOriginAsHost(source, req.headers.host)) return next();
res.status(403).json({
error: 'Cross-origin request rejected',
detail: 'This local API only accepts requests from the localhost UI, curl, or the CLI. A cross-origin (foreign-website) Origin/Referer was detected and blocked to prevent CSRF/drive-by command execution.',
@@ -344,6 +363,79 @@ interface ParsedCommand {
const SHELL_META = /[|&;$<>`\\]/;
const COMMAND_CONTROL = /[\x00-\x1F\x7F-\x9F\u2028\u2029]/;
const CURL_TRANSPORT_OVERRIDE_FLAGS = new Set([
'--resolve',
'--connect-to',
'--proxy',
'--preproxy',
'--socks4',
'--socks4a',
'--socks5',
'--socks5-hostname',
'--unix-socket',
'--abstract-unix-socket',
'--interface',
'--url',
'--config',
'--next',
'-x',
'-K',
]);
const CURL_VALUE_FLAGS = new Set([
'-A', '--user-agent',
'-b', '--cookie',
'-c', '--cookie-jar',
'-d', '--data', '--data-ascii', '--data-binary', '--data-raw', '--data-urlencode',
'-F', '--form',
'-H', '--header',
'-m', '--max-time',
'-o', '--output',
'-T', '--upload-file',
'-u', '--user',
'-X', '--request',
'--cacert', '--cert', '--connect-timeout', '--key', '--request-target', '--retry',
]);
function findCurlTransportOverrideFlag(args: string[]): string | undefined {
for (const arg of args) {
if (!arg) continue;
const flag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
if (CURL_TRANSPORT_OVERRIDE_FLAGS.has(flag)) return flag;
if (arg.startsWith('-x') && arg !== '-X' && arg.length > 2) return '-x';
if (arg.startsWith('-K') && arg.length > 2) return '-K';
}
return undefined;
}
function looksLikeCurlUrlOperand(arg: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\/\S+/i.test(arg)
|| /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i.test(arg)
|| /^(?:localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?(?:[/?#].*)?$/i.test(arg)
|| /^\[[0-9a-f:]+\](?::\d+)?(?:[/?#].*)?$/i.test(arg);
}
function countCurlUrlOperands(args: string[]): number {
let count = 0;
let skipNext = false;
let endOfOptions = false;
for (const arg of args) {
if (!arg) continue;
if (skipNext) { skipNext = false; continue; }
if (!endOfOptions && arg === '--') { endOfOptions = true; continue; }
if (!endOfOptions && arg.startsWith('--')) {
const hasInlineValue = arg.includes('=');
const flag = hasInlineValue ? arg.slice(0, arg.indexOf('=')) : arg;
if (!hasInlineValue && CURL_VALUE_FLAGS.has(flag)) skipNext = true;
continue;
}
if (!endOfOptions && arg.startsWith('-')) {
if (arg.length === 2 && CURL_VALUE_FLAGS.has(arg)) skipNext = true;
continue;
}
if (looksLikeCurlUrlOperand(arg)) count += 1;
}
return count;
}
function parseCommand(command: string): ParsedCommand | { error: string } {
if (SHELL_META.test(command) || COMMAND_CONTROL.test(command)) return { error: 'Shell control characters are not allowed; use direct argv-style commands only.' };
@@ -355,6 +447,15 @@ function parseCommand(command: string): ParsedCommand | { error: string } {
if (adapter?.execution === 'catalog_only' || adapter?.execution === 'import_only') {
return { error: `Tool is catalog-only and cannot be executed directly: ${bin}` };
}
if (bin === 'curl') {
const overrideFlag = findCurlTransportOverrideFlag(args);
if (overrideFlag) {
return { error: `curl flag ${overrideFlag} changes the effective network destination and is not allowed through /api/tools/execute.` };
}
if (countCurlUrlOperands(args) > 1) {
return { error: 'curl commands with multiple URL operands are not allowed through /api/tools/execute; submit one transfer per approved target.' };
}
}
return { bin, args };
}
@@ -507,6 +608,7 @@ interface ApprovalRequest {
target: string;
reason: string;
status: ApprovalStatus;
wildcardOptIn?: boolean;
operationId?: string;
requestedBy: DraftSource | 'system';
createdAt: string;
@@ -1091,10 +1193,29 @@ function approvalIsFresh(approval: ApprovalRequest): boolean {
return Date.parse(approval.expiresAt) > Date.now();
}
function targetUsesWildcard(target: string): boolean {
const raw = normalizeTargetValue(target).toLowerCase();
if (raw === '*') return true;
const withoutScheme = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
return withoutScheme.startsWith('*.') || hostFromTarget(raw).startsWith('*.');
}
function wildcardHostFromTarget(target: string): string {
const raw = normalizeTargetValue(target).toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
return raw.replace(/\/.*$/, '').replace(/:\d+$/, '');
}
function approvalMatches(approval: ApprovalRequest, action: GuardAction, target: string): boolean {
if (approval.action !== action) return false;
if (approval.target === '*') return action === 'model_call' || action === 'autonomous_execution';
return hostFromTarget(approval.target) === hostFromTarget(target);
if (targetUsesWildcard(approval.target) && !approval.wildcardOptIn) return false;
const approvalHost = targetUsesWildcard(approval.target) ? wildcardHostFromTarget(approval.target) : hostFromTarget(approval.target);
const targetHost = hostFromTarget(target);
if (approvalHost.startsWith('*.')) {
const suffix = approvalHost.slice(1);
return targetHost.endsWith(suffix) && targetHost !== approvalHost.slice(2);
}
return approvalHost === targetHost;
}
function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget: string): string[] {
@@ -1104,6 +1225,15 @@ function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget
.filter(target => hostFromTarget(target) !== approvedHost);
}
function ensureExecTargetsAuthorized(
targets: string[],
approvedTarget: string,
body: Record<string, unknown>,
): string[] {
return ensureExecTargetsWithinApprovedTarget(targets, approvedTarget)
.filter(target => !findApproval(body, 'autonomous_execution', target));
}
function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean {
if (!approvalMatches(approval, action, target)) return false;
if (approval.operationId && operationId && approval.operationId !== operationId) return false;
@@ -1111,10 +1241,17 @@ function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction
}
function findApproval(body: Record<string, unknown>, action: GuardAction, target: string): ApprovalRequest | null {
const id = typeof body.approvalId === 'string' ? body.approvalId : '';
const approval = id ? approvalRequests.get(id) : undefined;
if (!approval || !approvalIsFresh(approval) || !approvalMatches(approval, action, target)) return null;
return approval;
const ids = [
typeof body.approvalId === 'string' ? body.approvalId : '',
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
].filter(Boolean);
for (const id of ids) {
const approval = approvalRequests.get(id);
if (approval && approvalIsFresh(approval) && approvalMatches(approval, action, target)) return approval;
}
return null;
}
function createApprovalRequest(action: GuardAction, target: string, reason: string, body: Record<string, unknown>): ApprovalRequest {
@@ -1125,6 +1262,7 @@ function createApprovalRequest(action: GuardAction, target: string, reason: stri
target,
reason,
status: 'pending',
wildcardOptIn: body.allowWildcard === true && targetUsesWildcard(target),
operationId: typeof operationDraft?.operation_id === 'string' ? operationDraft.operation_id : undefined,
requestedBy: ['human', 'agent', 't3mp3st'].includes(String(body.source)) ? body.source as DraftSource : 'system',
createdAt: nowIso(),
@@ -4240,7 +4378,7 @@ function healthPayload(): Record<string, unknown> {
version: '0.2.1',
apiVersion: 'v1',
llm: {
configured: Boolean(llmConfig.apiKey) || llmConfig.provider === 'codex',
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
connected: Boolean(llm) || llmConfig.provider === 'codex',
provider: llmConfig.provider,
model: llmConfig.model,
@@ -4639,7 +4777,10 @@ export function broadcastEvent(event: string, data: Record<string, unknown>): vo
const MAX_SSE_CLIENTS = 64;
app.get('/api/events', (_req: Request, res: Response) => {
const origin = _req.get('origin');
if (origin && !isLoopbackOrigin(origin)) {
// #84: allow the operator's own same-origin UI on a non-loopback (opted-in) bind; foreign
// origins still fail the loopback check AND the Host match, so they remain rejected.
const sameOriginNetworkBind = !HOST_IS_LOOPBACK && isSameOriginAsHost(origin, _req.headers.host);
if (origin && !isLoopbackOrigin(origin) && !sameOriginNetworkBind) {
res.status(403).json({
error: 'Cross-origin event stream rejected',
detail: 'The SSE feed may contain live mission/task/finding metadata and is only available to the localhost UI.',
@@ -4802,6 +4943,15 @@ app.post('/api/approvals/request', (req: Request, res: Response) => {
});
return;
}
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
res.status(400).json({
error: 'Wildcard host approval requires explicit opt-in',
action,
target,
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
});
return;
}
const reason = typeof body.reason === 'string' && body.reason.trim()
? body.reason.trim()
: `Approval requested for ${action} against ${target}`;
@@ -4825,6 +4975,68 @@ app.post('/api/approvals/:id/approve', (req: Request, res: Response) => {
res.json(approval);
});
app.post('/api/approvals/authorize-target', (req: Request, res: Response) => {
const body = req.body as Record<string, unknown>;
const target = normalizeTargetValue(body.target);
const approvalIds = [
typeof body.approvalId === 'string' ? body.approvalId : '',
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
].filter(Boolean);
if (!approvalIds.length) {
res.status(400).json({
error: 'approvalId or approvalIds required',
next: 'Request a pending receipt first, then approve that exact receipt id.',
});
return;
}
if (target === '*') {
res.status(400).json({
error: 'Wildcard target authorization is not allowed for active actions',
next: 'Authorize one concrete host or URL so receipts remain target-scoped.',
});
return;
}
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
res.status(400).json({
error: 'Wildcard host authorization requires explicit opt-in',
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
});
return;
}
const ttlMinutes = Math.max(1, Math.min(30, Number(body.ttlMinutes || 30)));
const expiresAt = new Date(Date.now() + ttlMinutes * 60_000).toISOString();
const approvals: ApprovalRequest[] = [];
for (const id of [...new Set(approvalIds)]) {
const approval = approvalRequests.get(id);
if (!approval) {
res.status(404).json({ error: 'Approval request not found', approvalId: id });
return;
}
if (approval.status !== 'pending') {
res.status(409).json({ error: 'Approval request is not pending', approvalId: id, status: approval.status });
return;
}
if (target && !approvalMatches(approval, approval.action, target)) {
res.status(400).json({
error: 'Approval target mismatch',
approvalId: id,
approvalTarget: approval.target,
target,
});
return;
}
approval.status = 'approved';
approval.approvedBy = typeof body.approvedBy === 'string' ? body.approvedBy : 'local-operator';
approval.expiresAt = expiresAt;
approval.updatedAt = nowIso();
emitContractEvent('approval.approved', { approvalId: approval.id, action: approval.action, target: approval.target });
approvals.push(approval);
}
res.json({ target: target || null, expiresAt, approvals });
});
app.post('/api/approvals/:id/reject', (req: Request, res: Response) => {
const approval = approvalRequests.get(req.params.id);
if (!approval) {
@@ -5871,7 +6083,8 @@ app.get('/api/llm/status', (_req: Request, res: Response) => {
connected: !!llm,
provider: llmConfig.provider,
model: llmConfig.model,
hasApiKey: !!llmConfig.apiKey
hasApiKey: !!llmConfig.apiKey,
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
});
});
@@ -5963,8 +6176,8 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
targets = [],
operators = [],
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
// OPTIONAL white-box source: an absolute path to a LOCAL repo you own. When
// present, we ingest + security-rank it and hand the packed source to the
// command via setWhiteboxSource BEFORE start(), so operators reason over the
@@ -5972,18 +6185,19 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
repoPath,
} = req.body;
// Use provided apiKey or fall back to server-configured one. Local-agent backends
// (Claude Code / Codex / Hermes) need NO key — the agent uses its own login.
// Use the request-selected backend, or fall back to the server's configured default.
// Local/local-agent backends need no hosted API key.
// SECURITY NOTE: apiKey is read from the request body (Authorization header is
// preferred). Kept body-accepted for the same-origin UI; only reachable from
// the local operator (loopback bind + origin guard). Header move is out of scope.
const effectiveKey = apiKey || config.getLLMConfig().apiKey;
if (providerNeedsApiKey(provider) && !effectiveKey) {
const missionLLMConfig = resolveGeneralLLMConfig(provider, model, apiKey);
const effectiveKey = missionLLMConfig.apiKey;
if (providerNeedsApiKey(missionLLMConfig.provider) && !effectiveKey) {
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' });
return;
}
if (provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(model);
if (missionLLMConfig.provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(missionLLMConfig.model);
if (!localAgent.ok) {
res.status(503).json({ error: localAgent.error });
return;
@@ -6015,7 +6229,7 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
}
try {
const cmd = createTempestCommandInstance(name, effectiveKey, provider, model);
const cmd = createTempestCommandInstance(name, effectiveKey, missionLLMConfig.provider, missionLLMConfig.model);
// Add targets
for (const t of targets) {
@@ -6200,7 +6414,7 @@ app.post('/api/mission/resume', (_req: Request, res: Response) => {
app.get('/api/mission/status', (_req: Request, res: Response) => {
const cmd = getTempestCommand();
if (!cmd) {
res.json({ active: false });
res.json({ active: false, progress: [], tasks: [] });
return;
}
@@ -6230,6 +6444,8 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
targets: status.targets,
vault: status.vault,
opsec: status.opsec,
tasks: status.tasks,
progress: status.progress,
findings: findings.map(f => ({
id: f.id,
title: f.title,
@@ -6466,6 +6682,10 @@ function providerNeedsApiKey(provider: string): boolean {
return !['codex', 'mock', 'local', 'local-agent'].includes(provider);
}
function providerRunsKeyless(provider: string): boolean {
return !providerNeedsApiKey(provider);
}
function readPositiveTimeoutEnv(name: string): number | undefined {
const raw = process.env[name];
if (raw == null || raw.trim() === '') return undefined;
@@ -6484,7 +6704,7 @@ function readGeneralTimeoutEnv(): number | undefined {
// the key in the body, so we accept it to avoid breaking it. Moving to a header
// needs a coordinated UI change and is out of scope. The body key is only ever
// reachable from the local operator (loopback bind + origin guard).
function resolveGeneralLLMConfig(provider: string, model: string | undefined, apiKey: string | undefined): {
function resolveGeneralLLMConfig(provider: string | undefined, model: string | undefined, apiKey: string | undefined): {
provider: any;
model: string;
apiKey?: string;
@@ -6492,7 +6712,8 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
temperature: number;
timeout: number;
} {
const selectedProvider = provider || 'openrouter';
const defaultConfig = config.getLLMConfig();
const selectedProvider = provider || defaultConfig.provider;
// Local-agent backends (Claude Code / Codex / Hermes via the connector) need NO API key — the
// agent uses its own login. The agent id (codex|claude|hermes) travels in `model`.
if (selectedProvider === 'local-agent') {
@@ -6553,8 +6774,6 @@ function bringUpMissionFromPlan(
async function runCodexExecReadinessProbe(command: string): Promise<{ stdout: string; stderr: string }> {
const marker = 'T3MP3ST_CODEX_READY';
const args = [
'--ask-for-approval',
'never',
'exec',
'-c',
'model_reasoning_effort="low"',
@@ -6580,7 +6799,7 @@ async function runCodexExecReadinessProbe(command: string): Promise<{ stdout: st
const timer = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error('Codex exec readiness probe timed out'));
}, 30000);
}, 60000);
// Bounded accumulation so a runaway/verbose child can't grow these strings without limit
// before the 30s timer fires (matches the local-agent caps). A normal probe emits a tiny
@@ -6632,7 +6851,7 @@ app.get('/api/codex/status', async (_req: Request, res: Response): Promise<void>
tokenHandling: 'no token is accepted by or returned from T3MP3ST',
command,
version: stdout.trim(),
executionMode: 'codex exec --ephemeral --sandbox read-only --ask-for-approval never',
executionMode: 'codex exec --ephemeral --sandbox read-only',
execProbe: 'POST /api/codex/probe', // exec self-test moved off GET (B-04)
});
} catch (error: any) {
@@ -6652,7 +6871,7 @@ app.post('/api/codex/probe', async (_req: Request, res: Response): Promise<void>
provider: 'codex',
command,
version: stdout.trim(),
executionMode: 'codex exec --ephemeral --sandbox read-only --ask-for-approval never',
executionMode: 'codex exec --ephemeral --sandbox read-only',
};
try {
const probe = await runCodexExecReadinessProbe(command);
@@ -6685,8 +6904,8 @@ app.post('/api/general/plan', async (req: Request, res: Response): Promise<void>
urgency,
opsecPreference,
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
} = req.body;
if (!objective) {
@@ -6759,8 +6978,8 @@ app.post('/api/general/execute', async (req: Request, res: Response): Promise<vo
const {
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
} = req.body;
let generalConfig;
@@ -6872,8 +7091,8 @@ app.post('/api/general/auto', async (req: Request, res: Response): Promise<void>
urgency,
opsecPreference,
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
} = req.body;
if (!objective) {
@@ -7131,7 +7350,7 @@ import { Admiral, briefToDirective, type ChatMsg, type MissionBrief } from './ad
*/
app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<void> => {
try {
const { messages, provider = 'openrouter', model, apiKey } = req.body as {
const { messages, provider, model, apiKey } = req.body as {
messages: ChatMsg[]; provider?: string; model?: string; apiKey?: string;
};
if (!Array.isArray(messages) || messages.length === 0) {
@@ -7161,7 +7380,7 @@ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<v
*/
app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<void> => {
try {
const { operatorPrompt, archetype, failureSignal, provider = 'openrouter', model, apiKey } = req.body as {
const { operatorPrompt, archetype, failureSignal, provider, model, apiKey } = req.body as {
operatorPrompt?: string; archetype?: string; failureSignal?: string; provider?: string; model?: string; apiKey?: string;
};
let prompt = typeof operatorPrompt === 'string' ? operatorPrompt : '';
@@ -7196,7 +7415,7 @@ app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<vo
*/
app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<void> => {
try {
const { brief, confirmed, provider = 'openrouter', model, apiKey } = req.body as {
const { brief, confirmed, provider, model, apiKey } = req.body as {
brief: MissionBrief; confirmed?: boolean; provider?: string; model?: string; apiKey?: string;
};
if (!brief || !brief.objective || !brief.target) {
@@ -7251,12 +7470,20 @@ app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<voi
res.status(409).json({ error: 'General plan gate is HOLD', mode: 'live', plan, review: execConfig.review });
return;
}
const outOfScopeTargets = ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target);
const outOfScopeTargets = ensureExecTargetsAuthorized(execConfig.targets, brief.target, req.body as Record<string, unknown>);
if (outOfScopeTargets.length) {
const approvals = outOfScopeTargets.map(target => createApprovalRequest(
'autonomous_execution',
target,
`Admiral plan expanded execution scope from ${brief.target} to ${target}`,
req.body as Record<string, unknown>
));
res.status(403).json({
error: 'Admiral LIVE plan contains targets outside the approved brief target',
error: 'Admiral LIVE plan needs approval for expanded target scope',
approvedTarget: brief.target,
outOfScopeTargets,
approvals,
next: 'Approve the expanded target receipts, then retry /api/admiral/launch with approvalIds.',
});
return;
}
@@ -7371,7 +7598,7 @@ type ConnectedLocalAgent = {
};
const connectedLocalAgents = new Map<string, ConnectedLocalAgent>();
const LOCAL_AGENT_HEALTH_TTL_MS = 30_000;
const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 15_000;
const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 45_000;
async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
const now = Date.now();
+95 -1
View File
@@ -287,6 +287,88 @@ async function setupOpenAIKey(): Promise<boolean> {
}
}
async function setupLiteLLMProxy(): Promise<boolean> {
console.log('');
showInfo('LiteLLM is an AI gateway proxy that routes to 100+ LLM providers.');
showInfo('Docs: ' + chalk.underline('https://docs.litellm.ai/docs/proxy/quick_start'));
console.log('');
const { baseUrl } = await inquirer.prompt([
{
type: 'input',
name: 'baseUrl',
message: 'Enter your LiteLLM proxy base URL:',
default: 'http://localhost:4000/v1',
validate: (input: string) => {
if (!input || !input.startsWith('http')) {
return 'Please enter a valid URL (e.g. http://localhost:4000/v1)';
}
return true;
},
},
]);
const { apiKey } = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'Enter your LiteLLM API key (leave blank if not required):',
mask: '*',
},
]);
const { model } = await inquirer.prompt([
{
type: 'input',
name: 'model',
message: 'Enter the default model (uses LiteLLM model format, e.g. gpt-4o, anthropic/claude-sonnet-4-6):',
default: 'gpt-4o',
},
]);
const spinner = ora('Testing LiteLLM proxy connection...').start();
try {
const llm = new LLMBackbone({
provider: 'litellm',
model,
apiKey: apiKey || undefined,
baseUrl,
maxTokens: 10,
temperature: 0,
});
await llm.prompt('Hello', undefined, { maxTokens: 10 });
spinner.succeed('LiteLLM proxy connection successful!');
config.set('litellm', { baseUrl, defaultModel: model });
if (apiKey) setApiKey('litellm', apiKey);
showSuccess('LiteLLM proxy configured successfully!');
return true;
} catch (error) {
spinner.fail('LiteLLM proxy connection failed');
showError(`Error: ${error instanceof Error ? error.message : String(error)}`);
const { saveAnyway } = await inquirer.prompt([
{
type: 'confirm',
name: 'saveAnyway',
message: 'Save the configuration anyway? (proxy may not be running yet)',
default: true,
},
]);
if (saveAnyway) {
config.set('litellm', { baseUrl, defaultModel: model });
if (apiKey) setApiKey('litellm', apiKey);
showSuccess('LiteLLM proxy configuration saved.');
return true;
}
return false;
}
}
// =============================================================================
// MODEL SELECTION
// =============================================================================
@@ -334,6 +416,7 @@ You can use a local model with no API key, or add a key from one of these provid
• ${chalk.cyan('Anthropic')} - Direct Claude access
• ${chalk.cyan('OpenAI')} - GPT models
• ${chalk.cyan('DeepSeek')} - OpenAI-compatible chat/reasoning models
• ${chalk.cyan('LiteLLM')} - AI gateway proxy (100+ providers)
The setup will guide you through:
1. Adding API key(s), if you use a hosted provider
@@ -347,14 +430,16 @@ The setup will guide you through:
const hasAnthropic = hasApiKey('anthropic');
const hasOpenAI = hasApiKey('openai');
const hasDeepSeek = hasApiKey('deepseek');
const hasLiteLLM = config.hasLiteLLMProxy();
if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek) {
if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek || hasLiteLLM) {
console.log('');
showInfo('Existing API keys detected:');
if (hasOpenRouter) showSuccess(' OpenRouter: configured');
if (hasAnthropic) showSuccess(' Anthropic: configured');
if (hasOpenAI) showSuccess(' OpenAI: configured');
if (hasDeepSeek) showSuccess(' DeepSeek: configured');
if (hasLiteLLM) showSuccess(' LiteLLM Proxy: configured');
console.log('');
const { action } = await inquirer.prompt([
@@ -438,6 +523,10 @@ async function setupApiKeys(): Promise<void> {
name: `DeepSeek ${hasApiKey('deepseek') ? chalk.green('(configured)') : ''}`,
value: 'deepseek',
},
{
name: `LiteLLM Proxy ${config.hasLiteLLMProxy() ? chalk.green('(configured)') : chalk.gray('(100+ providers via gateway)')}`,
value: 'litellm',
},
],
},
]);
@@ -459,6 +548,9 @@ async function setupApiKeys(): Promise<void> {
case 'deepseek':
await setupDeepSeekKey();
break;
case 'litellm':
await setupLiteLLMProxy();
break;
}
}
}
@@ -472,6 +564,7 @@ async function setupProvider(): Promise<void> {
if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' });
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' });
if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' });
const { provider } = await inquirer.prompt([
{
@@ -503,6 +596,7 @@ function viewConfiguration(): void {
console.log(' Anthropic: ' + (hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set')));
console.log(' OpenAI: ' + (hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set')));
console.log(' DeepSeek: ' + (hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set')));
console.log(' LiteLLM Proxy: ' + (config.hasLiteLLMProxy() ? chalk.green('configured') : chalk.red('not set')));
console.log('');
console.log(chalk.cyan(' Config Path: ') + config.getConfigPath());
console.log('');
+18 -1
View File
@@ -6,7 +6,7 @@
// LLM CONFIGURATION
// =============================================================================
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'codex' | 'mock' | 'local' | 'local-agent';
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'codex' | 'mock' | 'local' | 'local-agent';
export interface LLMConfig {
provider: LLMProvider;
@@ -426,6 +426,7 @@ export interface ToolFinding {
details: string;
cvss?: number;
cve?: string[];
cwe?: string[];
remediation?: string;
/**
* How this finding was produced — the provenance flag the honesty gate keys on:
@@ -534,6 +535,21 @@ export interface Appendix {
// EVENT TYPES
// =============================================================================
export interface ScanProgressEvent {
id: string;
timestamp: number;
kind: 'task_started' | 'thinking' | 'tool_call' | 'tool_result' | 'task_completed' | 'task_failed';
operatorId: string;
callsign: string;
archetype: OperatorArchetype;
taskId?: string;
taskName?: string;
toolName?: string;
source?: 'agent' | 'backend_seeded';
detail: string;
success?: boolean;
}
export interface CommandEvents {
'command:started': void;
'command:stopped': void;
@@ -547,6 +563,7 @@ export interface CommandEvents {
'target:owned': { target: Target; operatorId: string };
'detection:triggered': DetectionEvent;
'mission:phase_changed': { missionId: string; phase: KillChainPhase };
'scan:progress': ScanProgressEvent;
'abort:recommended': string;
/** A capability-approval gate decision (allowed/denied) on an intrusive/dangerous tool — bridged to
* the dashboard's live approval/audit feed. Structural match for arsenal/approval.ts ApprovalRecord. */