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:
75 files changed
+7788
-586
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"}]
|
||||
@@ -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"}]}
|
||||
@@ -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"}]
|
||||
@@ -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"}}]}
|
||||
@@ -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"}
|
||||
@@ -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"}}
|
||||
@@ -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"}
|
||||
@@ -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":[]}
|
||||
@@ -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"]}]}]}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user