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

# Conflicts:
#	src/config/index.ts
#	src/types/index.ts
This commit is contained in:
Joseph Magly committed 2026-07-16 12:11:16 -04:00
commit 5a12593baa
93 files changed
+9379 -649

No files matched your search

+176
View File
@@ -14,6 +14,8 @@ import {
adapterToCustomTool,
buildAdapterTools,
toolNameFor,
isMintable,
hasArgTemplate,
type AdapterToolDeps,
type SubprocessResult,
} from '../arsenal/adapter-tools.js';
@@ -56,6 +58,8 @@ describe('adapterToCustomTool — mint gate', () => {
expect(adapterToCustomTool(adapter('metasploit'), deps)).toBeNull(); // catalog_only
expect(adapterToCustomTool(adapter('hydra'), deps)).toBeNull(); // catalog_only
expect(adapterToCustomTool(adapter('bloodhound'), deps)).toBeNull(); // import_only
expect(adapterToCustomTool(adapter('pacu'), deps)).toBeNull(); // catalog_only (AWS exploitation framework)
expect(adapterToCustomTool(adapter('frida'), deps)).toBeNull(); // catalog_only (runtime code injection)
});
it('mints a command-ready adapter with a working name + category', () => {
@@ -67,6 +71,30 @@ describe('adapterToCustomTool — mint gate', () => {
});
});
describe('cloud/mobile category loadouts — presence + risk gating', () => {
const deps = makeDeps();
const execOf = (id: string) => adapter(id).execution;
it('cloud: assessment/recon tools are receipt-gated; the exploitation framework is catalog-only', () => {
expect(execOf('scoutsuite')).toBe('receipt_required');
expect(execOf('cloudfox')).toBe('receipt_required');
expect(execOf('pmapper')).toBe('receipt_required');
expect(execOf('pacu')).toBe('catalog_only');
expect(adapter('scoutsuite').category).toBe('cloud');
expect(adapterToCustomTool(adapter('pacu'), deps)).toBeNull(); // never callable
});
it('mobile: static scanner is safe; dynamic/runtime tools are gated', () => {
expect(execOf('apkleaks')).toBe('safe_command');
expect(execOf('mobsfscan')).toBe('safe_command'); // static source scanner — safe
expect(execOf('objection')).toBe('receipt_required');
expect(execOf('frida')).toBe('catalog_only');
expect(adapter('apkleaks').category).toBe('mobile');
expect(adapterToCustomTool(adapter('frida'), deps)).toBeNull(); // never callable
expect(adapterToCustomTool(adapter('apkleaks'), deps)).not.toBeNull(); // safe, mintable
});
});
describe('adapterToCustomTool — degrade / scope / execution', () => {
it('a missing binary DEGRADES (does not throw) and surfaces the installHint', async () => {
const deps = makeDeps({ isToolAvailable: async () => false });
@@ -191,3 +219,151 @@ describe('buildAdapterTools', () => {
expect(tools.map(t => t.name)).not.toContain(toolNameFor(adapter('nmap')));
});
});
describe('source / supply-chain scanners run a real invocation (not `<binary> <target>`)', () => {
// Each entry: [adapter id, argv for `path: 'src'`]. Without bespoke templates these all fell
// through to DEFAULT_TEMPLATE and spawned `['src']` (or `['']` with no path) — a broken scan.
const CASES: Array<[string, string[]]> = [
['semgrep', ['scan', '--config', 'auto', '--json', 'src']],
['gitleaks', ['detect', '--source', 'src', '--report-format', 'json', '--redact', '--no-banner']],
['trufflehog', ['filesystem', 'src', '--json', '--no-update']],
['trivy', ['fs', '--format', 'json', 'src']],
['syft', ['dir:src', '-o', 'cyclonedx-json']],
['grype', ['dir:src', '-o', 'json']],
['checkov', ['-d', 'src', '-o', 'json']],
];
it.each(CASES)('%s builds its real scanner argv for an explicit path', async (id, expected) => {
const deps = makeDeps();
await mint(id, deps).handler(ctx({ path: 'src' }));
expect(deps.spawns[0]).toEqual(expected);
});
it.each(CASES)('%s defaults to the working dir "." when no path is given', async (id) => {
const deps = makeDeps();
await mint(id, deps).handler(ctx({}));
// The path slot resolves to "." — assert "." is present and the argv is more than a bare target.
expect(deps.spawns[0].some(a => a === '.' || a === 'dir:.')).toBe(true);
expect(deps.spawns[0].length).toBeGreaterThan(1);
});
it('never inherits an http(s) mission target as a scan path (falls back to ".")', async () => {
const deps = makeDeps();
// A networked mission address must not become `semgrep scan --config auto --json https://x` —
// that is not a filesystem path. scanPath rejects it and falls back to ".".
await mint('semgrep', deps).handler(ctx({ target: 'https://victim.example' }));
expect(deps.spawns[0]).toEqual(['scan', '--config', 'auto', '--json', '.']);
});
it('refuses an option-looking path (leading "-" would be a scanner flag) — no spawn', async () => {
const deps = makeDeps();
// The factory's option-looking-target guard fires before argv is built, so the scanner never runs.
const result = await mint('trivy', deps).handler(ctx({ path: '--output=/etc/x' }));
expect(result.success).toBe(false);
expect(deps.spawns.length).toBe(0);
});
});
describe('reverse / mobile / smart-contract analysers run a real invocation (not `<binary> <file>`)', () => {
// Each entry: [adapter id, params, expected argv]. Without bespoke templates these all fell through
// to DEFAULT_TEMPLATE and spawned `['<file>']` — a broken invocation for a subcommand/flag-driven
// analyser (and, for r2, one that drops into an interactive shell and hangs the loop).
const CASES: Array<[string, Record<string, unknown>, string[]]> = [
['objdump', { file: 'a.out' }, ['-d', '-M', 'intel', 'a.out']],
['readelf', { file: 'a.out' }, ['-a', 'a.out']],
['checksec', { file: 'a.out' }, ['--file=a.out']],
['radare2', { file: 'a.out' }, ['-q', '-e', 'scr.color=0', '-c', 'ij', 'a.out']],
['exiftool', { file: 'a.out' }, ['-json', 'a.out']],
['mythril', { file: 'Vault.sol' }, ['analyze', 'Vault.sol', '-o', 'json']],
['apkleaks', { file: 'app.apk' }, ['-f', 'app.apk']],
['slither', { path: 'contracts' }, ['contracts', '--json', '-']],
['mobsfscan', { path: 'app-src' }, ['--json', 'app-src']],
];
it.each(CASES)('%s builds its real analyser argv', async (id, params, expected) => {
const deps = makeDeps();
await mint(id, deps).handler(ctx(params));
expect(deps.spawns[0]).toEqual(expected);
});
it('radare2 never drops into an interactive shell (batch `-q -c`, never a bare file)', async () => {
const deps = makeDeps();
await mint('radare2', deps).handler(ctx({ file: 'a.out' }));
const argv = deps.spawns[0];
expect(argv).toContain('-q'); // quit after the command
expect(argv).toContain('-c'); // run one command, non-interactive
expect(argv).not.toEqual(['a.out']); // the old broken/hanging positional default
});
it('a file-oriented analyser with no path degrades (clean failure, no spawn)', async () => {
const deps = makeDeps();
const res = await mint('objdump', deps).handler(ctx({}));
expect(res.success).toBe(false);
expect(res.error).toMatch(/file\/artifact path/);
expect(deps.spawns.length).toBe(0); // never spawned `objdump ''`
});
it('a file-oriented analyser refuses an http(s) URL as a local artifact (no spawn)', async () => {
const deps = makeDeps();
// A networked mission address inherited via context must not become `objdump https://x`.
const res = await mint('readelf', deps).handler(ctx({ file: 'https://victim.example/firmware.bin' }));
expect(res.success).toBe(false);
expect(deps.spawns.length).toBe(0);
});
});
describe('invocation-honesty guard — every mintable adapter is classified, none silently falls through', () => {
// Every adapter that CAN be minted either carries a bespoke ARG_TEMPLATE (a real invocation) or is
// explicitly filed under exactly one reason for legitimately falling through to `<binary> <target>`.
// This is the invocation-correctness sibling of the tool-count honesty test: a newly-catalogued
// subcommand tool (e.g. a future `zzuf`/`radare2`-style binary) FAILS this test until someone
// decides whether it needs a template — it can never silently ship a broken positional invocation.
// `<binary> <target>` is genuinely a correct, useful invocation for these.
const POSITIONAL_TARGET_OK = new Set([
'file', 'strings', 'binwalk', 'radamsa', 'jadx', 'class-dump', 'solhint', 'echidna', 'john',
]);
// No single safe default exists: the operator supplies the full command (cloud CLIs, model-config
// red-team harnesses, device-runtime tools, project-scaffolded RE, or a target-executing debugger).
const OPERATOR_DRIVEN = new Set([
'prowler', 'scoutsuite', 'cloudfox', 'pmapper', 'aws-cli', 'az-cli', 'gcloud-cli',
'garak', 'promptfoo', 'foundry-forge', 'foundry-cast', 'openssl', 'afl-fuzz', 'ghidra',
'gdb', 'objection', 'drozer',
]);
// KNOWN DEBT: the positional default is broken/degraded and these SHOULD get a template later.
// Tracked honestly here rather than hidden — a good follow-up PR shrinks this set.
const KNOWN_DEBT = new Set([
'feroxbuster', 'osv-scanner', 'hashcat', 'apktool', 'yara',
]);
const mintable = TOOL_ADAPTERS.filter(isMintable);
const classified = [POSITIONAL_TARGET_OK, OPERATOR_DRIVEN, KNOWN_DEBT];
it('every mintable adapter is either templated or explicitly filed under one fall-through reason', () => {
const unaccounted = mintable
.filter(a => !hasArgTemplate(a))
.map(a => a.id)
.filter(id => !classified.some(s => s.has(id)));
expect(unaccounted, 'these mintable adapters silently fall through to `<binary> <target>`').toEqual([]);
});
it('the fall-through allow-lists are disjoint and never overlap a templated adapter', () => {
const seen = new Set<string>();
for (const set of classified) {
for (const id of set) {
expect(seen.has(id), `${id} is listed in more than one fall-through set`).toBe(false);
seen.add(id);
const a = TOOL_ADAPTERS.find(x => x.id === id);
expect(a, `allow-listed id '${id}' is not in the catalog`).toBeTruthy();
expect(isMintable(a as ToolAdapter), `${id} is not mintable — remove it`).toBe(true);
expect(hasArgTemplate(a as ToolAdapter), `${id} IS templated — remove it from the fall-through lists`).toBe(false);
}
}
});
it('the reverse/mobile/smart-contract loadout is actually templated (regression pin)', () => {
for (const id of ['objdump', 'readelf', 'checksec', 'radare2', 'exiftool', 'mythril', 'apkleaks', 'slither', 'mobsfscan']) {
expect(hasArgTemplate(adapter(id)), `${id} lost its ARG_TEMPLATE`).toBe(true);
}
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { AnalysisEngine } from '../analysis/index.js';
import type { Report } from '../types/index.js';
import { KillChainPhase } from '../types/index.js';
function makeReport(): Report {
return {
id: 'report-1',
missionId: 'mission-1',
type: 'full_report',
generatedAt: 123,
summary: {
overview: 'Summary includes Bearer summaryabcdefghijklmnop',
riskRating: 'critical',
criticalFindings: 1,
highFindings: 0,
mediumFindings: 0,
lowFindings: 0,
infoFindings: 0,
successfulExploits: 1,
credentialsHarvested: 1,
systemsCompromised: 1,
},
findings: [
{
id: 'finding-1',
title: 'Leaked token',
description: 'Captured Anthropic key sk-ant-api03-ABCDEFGHIJKLMNOP in config',
severity: 'critical',
targetId: 'target-1',
operatorId: 'operator-1',
phase: KillChainPhase.EXPLOIT,
evidence: [
{
type: 'output',
content: 'curl -H "Authorization: Bearer evidenceabcdefghijklmnop" https://svc.local',
timestamp: 123,
},
],
remediation: 'Rotate token=rawsecretvalue123456 immediately',
discoveredAt: 123,
},
],
attackPaths: [
{
id: 'path-1',
name: 'Use https://admin:hunter2@example.test/login',
description: 'Pivot with Bearer attackpathabcdefghijklmnop',
steps: ['Replay sk-ant-api03-QRSTUVWXABCDEFGHIJKLMNOP against API'],
findings: ['finding-1'],
impactLevel: 'critical',
},
],
recommendations: [
{
id: 'rec-1',
findingId: 'finding-1',
priority: 'immediate',
title: 'Remove API key',
description: 'Delete api_key=anotherrawsecretvalue from docs',
effort: 'low',
impact: 'high',
},
],
};
}
describe('AnalysisEngine report redaction', () => {
it('redacts secrets before exporting Markdown reports', () => {
const engine = Object.create(AnalysisEngine.prototype) as AnalysisEngine;
const markdown = engine.exportToMarkdown(makeReport());
for (const raw of [
'sk-ant-api03-ABCDEFGHIJKLMNOP',
'Bearer summaryabcdefghijklmnop',
'Bearer evidenceabcdefghijklmnop',
'rawsecretvalue123456',
'hunter2',
'attackpathabcdefghijklmnop',
'sk-ant-api03-QRSTUVWXABCDEFGHIJKLMNOP',
'anotherrawsecretvalue',
]) {
expect(markdown).not.toContain(raw);
}
expect(markdown).toContain('[redacted]');
});
});
+6
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs';
import { join } from 'path';
const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8');
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
const setupScript = readFileSync(join(process.cwd(), 'scripts/setup-api.sh'), 'utf8');
function sourceBlock(startMarker: string, endMarker: string): string {
@@ -33,6 +34,11 @@ describe('API key environment handling hardening', () => {
expect(block).toContain("join(homedir(), '.t3mp3st', '.env')");
});
it('the API server does not re-enable caller-cwd dotenv loading', () => {
expect(serverSource).not.toMatch(/from ['"]dotenv['"]/);
expect(serverSource).not.toMatch(/\bdotenv\.config\s*\(/);
});
it('setup-api.sh writes the same T3MP3ST-owned env file that ConfigManager reads', () => {
const block = configLoadEnvBlock();
@@ -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 "102 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 "102 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 102 (adapters=${TOOL_ADAPTERS.length}, ` +
`built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` +
'update the README / verify-claims headline to match',
).toBe(102);
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);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Locks the Binary/RE loadout's decompiled-vuln detector + benchmark into CI: the ruleset must catch
* every seeded memory-safety / injection sink in the corpus and stay clean on the bounded-function
* controls (strncpy/snprintf/fgets, literal printf). Deterministic + self-contained.
*/
import { describe, it, expect } from 'vitest';
import { loadCorpus, detect, scoreCorpus, RULES } from '../../scripts/binary-vuln-bench.mjs';
describe('binary/RE decompiled-vuln benchmark', () => {
const corpus = loadCorpus();
const score = scoreCorpus(corpus);
it('corpus has seeded sinks + safe controls with ground truth', () => {
expect(corpus.fixtures.length).toBeGreaterThanOrEqual(6);
expect(corpus.fixtures.some((f) => f.clean)).toBe(true);
expect(corpus.fixtures.every((f) => f.clean || f.expect.length > 0)).toBe(true);
});
it('detects every seeded sink', () => {
expect(score.detection.hits).toBe(score.detection.total);
expect(score.detection.total).toBeGreaterThanOrEqual(6);
});
it('does not false-positive on bounded-function controls', () => {
expect(score.discrimination.falsePositives).toBe(0);
// the bounded equivalents must NOT trip the unbounded rules
expect(detect('strncpy(buf, src, sizeof(buf) - 1);').size).toBe(0);
expect(detect('snprintf(out, sizeof(out), "%s", x);').size).toBe(0);
expect(detect('fgets(name, sizeof(name), stdin);').size).toBe(0);
// ...but the unbounded ones must
expect(detect('strcpy(buf, src);').has('B-STRCPY')).toBe(true);
expect(detect('printf(user);').has('B-FORMAT-STRING')).toBe(true);
});
it('ruleset spans memory-safety + injection + integer-overflow', () => {
expect(RULES.length).toBeGreaterThanOrEqual(6);
expect(RULES.some((r) => r.id === 'B-CMD-INJECTION')).toBe(true);
expect(RULES.some((r) => r.id === 'B-INT-OVERFLOW')).toBe(true);
});
});
@@ -0,0 +1,37 @@
/**
* Locks the Cloud loadout's IaC-misconfig benchmark scorer into CI: the parser must map checkov
* findings to fixtures, every seeded misconfig must be detected, and the locked-down control must
* produce zero misconfig false-positives — validated against the committed sample (no checkov needed).
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
// The benchmark logic is authored in the bench script (ESM .mjs); import its pure functions directly.
import { loadCorpus, parseCheckov, scoreCorpus } from '../../scripts/cloud-misconfig-bench.mjs';
const sample = JSON.parse(readFileSync(join(process.cwd(), 'bench/cloud-misconfig/sample-checkov-output.json'), 'utf8'));
describe('cloud IaC-misconfig benchmark scorer', () => {
const corpus = loadCorpus();
const score = scoreCorpus(corpus, parseCheckov(sample));
it('the corpus has misconfigured fixtures + a clean control, all with ground truth', () => {
expect(corpus.fixtures.length).toBeGreaterThanOrEqual(5);
expect(corpus.fixtures.some((f: { clean?: boolean }) => f.clean)).toBe(true);
expect((corpus.target_checks || []).length).toBeGreaterThan(0);
});
it('detects every seeded misconfiguration on the committed sample', () => {
expect(score.detection.hits).toBe(score.detection.total);
expect(score.detection.total).toBeGreaterThanOrEqual(5);
});
it('does not false-positive on the locked-down control', () => {
expect(score.discrimination.falsePositives).toBe(0);
});
it('parser is tolerant of checkov multi-framework (array) output', () => {
const asArray = parseCheckov([sample]);
expect(Object.keys(asArray).length).toBe(Object.keys(parseCheckov(sample)).length);
});
});
+1
View File
@@ -0,0 +1 @@
[{"type":"V","inject_type":"inHTML-URL","severity":"high","cwe":"CWE-79","method":"GET","param":"q","data":"https://target.example.com/?q=<script>alert(1)</script>","evidence":"reflected in HTML body","poc":"<script>alert(1)</script>","message_str":"reflected XSS"}]
+1
View File
@@ -0,0 +1 @@
{"commandline":"ffuf -u https://target.example.com/FUZZ -w common.txt","results":[{"input":{"FUZZ":"admin"},"status":200,"length":1234,"words":56,"url":"https://target.example.com/admin"},{"input":{"FUZZ":"login"},"status":301,"length":0,"words":1,"url":"https://target.example.com/login"}]}
+1
View File
@@ -0,0 +1 @@
[{"RuleID":"aws-access-token","Description":"AWS Access Key AKIAIOSFODNN7EXAMPLE committed to source","File":"config/prod.env","StartLine":12,"Match":"REDACTED","Secret":"REDACTED","Commit":"abc1234","Author":"dev"}]
+1
View File
@@ -0,0 +1 @@
{"matches":[{"vulnerability":{"id":"CVE-2022-23529","severity":"High","description":"jsonwebtoken is vulnerable to signature verification bypass."},"artifact":{"name":"jsonwebtoken","version":"8.5.1","type":"npm"}}]}
+2
View File
@@ -0,0 +1,2 @@
{"url":"https://target.example.com","status_code":200,"title":"Example Corp","tech":["Nginx","PHP","jQuery"],"webserver":"nginx"}
{"url":"https://api.example.com","status_code":403,"title":"Forbidden"}
+3
View File
@@ -0,0 +1,3 @@
{"timestamp":"2026-01-01T00:00:00Z","request":{"method":"GET","endpoint":"https://target.example.com/"}}
{"timestamp":"2026-01-01T00:00:01Z","request":{"method":"GET","endpoint":"https://target.example.com/about"}}
{"timestamp":"2026-01-01T00:00:02Z","request":{"method":"GET","endpoint":"https://target.example.com/contact"}}
+3
View File
@@ -0,0 +1,3 @@
[INF] nuclei banner line that is not JSON and must be skipped
{"template-id":"CVE-2021-44228","info":{"name":"Apache Log4j RCE","severity":"critical","description":"Log4j JNDI remote code execution via crafted header","classification":{"cve-id":["CVE-2021-44228"],"cwe-id":["CWE-502"],"cvss-score":10.0},"remediation":"Upgrade log4j to 2.17.0 or later"},"host":"https://target.example.com","matched-at":"https://target.example.com/api"}
{"template-id":"nginx-version","info":{"name":"Nginx Version Disclosure","severity":"info"},"host":"https://target.example.com","matched-at":"https://target.example.com"}
+1
View File
@@ -0,0 +1 @@
{"results":[{"check_id":"python.lang.security.dangerous-subprocess-use","path":"app/run.py","start":{"line":42},"end":{"line":42},"extra":{"message":"Detected subprocess call with shell=True — command injection risk","severity":"ERROR","metadata":{"cwe":["CWE-78: OS Command Injection"],"owasp":["A03:2021 - Injection"],"references":["https://owasp.org/Top10/A03_2021-Injection/"]}}}],"errors":[]}
+1
View File
@@ -0,0 +1 @@
{"SchemaVersion":2,"Results":[{"Target":"package-lock.json","Class":"lang-pkgs","Type":"npm","Vulnerabilities":[{"VulnerabilityID":"CVE-2021-3749","PkgName":"axios","InstalledVersion":"0.21.1","FixedVersion":"0.21.2","Severity":"HIGH","Title":"axios: Regular expression denial of service via trim","Description":"axios is vulnerable to Inefficient Regular Expression Complexity (ReDoS).","CweIDs":["CWE-400"]}]}]}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { config, AVAILABLE_MODELS } from '../config/index.js';
// Regression coverage for #52: native Google Gemini support.
// The engine dispatches Gemini through OpenAIAdapter, which posts to
// `${baseUrl}/chat/completions`. Gemini's OpenAI-compatible surface lives under
// /v1beta/openai, so the base URL MUST carry that path segment — the single most
// important thing to get right, and the exact detail an earlier attempt missed.
describe('Gemini native provider wiring (#52)', () => {
it('routes gemini through the OpenAI-compatible endpoint including the /openai segment', () => {
const cfg = config.getLLMConfig('gemini');
expect(cfg.provider).toBe('gemini');
expect(cfg.baseUrl).toBe('https://generativelanguage.googleapis.com/v1beta/openai');
// Guard the regression directly: `${baseUrl}/chat/completions` must resolve under /v1beta/openai.
expect(`${cfg.baseUrl}/chat/completions`).toBe(
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
);
});
it('defaults to a bare native Gemini model id (no google/ prefix)', () => {
const cfg = config.getLLMConfig('gemini');
expect(cfg.model).toBe('gemini-2.5-flash');
expect(cfg.model.startsWith('google/')).toBe(false);
});
it('publishes a non-empty native Gemini model catalog with bare ids', () => {
expect(AVAILABLE_MODELS.gemini?.length ?? 0).toBeGreaterThan(0);
for (const m of AVAILABLE_MODELS.gemini) {
expect(m.id.startsWith('google/')).toBe(false);
}
});
it('maps the gemini provider to the GEMINI_API_KEY environment variable', () => {
const prev = process.env.GEMINI_API_KEY;
process.env.GEMINI_API_KEY = 'test-gemini-key-0123456789';
try {
expect(config.hasApiKey('gemini')).toBe(true);
} finally {
if (prev === undefined) delete process.env.GEMINI_API_KEY;
else process.env.GEMINI_API_KEY = prev;
}
});
});
+8 -4
View File
@@ -419,7 +419,7 @@ describe('Wedged-dispatch timeout backstop', () => {
expect(stopped).toBe(true);
});
it('a wedged mission reaches phase advancement once the per-dispatch backstop fires', async () => {
it('a wedged mission stalls instead of advancing after required recon dispatches time out', async () => {
// Tiny backstop so the test is fast and deterministic.
const prev = process.env.T3MP3ST_TASK_TIMEOUT_MS;
process.env.T3MP3ST_TASK_TIMEOUT_MS = '30';
@@ -442,14 +442,18 @@ describe('Wedged-dispatch timeout backstop', () => {
command.start();
// Poll (real timers): the 1s tick loop must dispatch, wedge, then reap the
// dispatch via the backstop and advance past RECON.
// dispatch via the backstop. Failed required recon work should stall instead
// of advancing the mission with no successful backend/model work.
const deadline = Date.now() + 8000;
while (Date.now() < deadline && !phaseAdvanced) {
while (Date.now() < deadline && !command.getStatus().paused) {
await new Promise(r => setTimeout(r, 100));
}
const status = command.getStatus();
command.stop();
expect(phaseAdvanced).toBe(true);
expect(phaseAdvanced).toBe(false);
expect(status.paused).toBe(true);
expect(status.stallReason).toContain('stalled in reconnaissance');
// The wedged operator was reset back to idle (or re-tasked in a later phase),
// never left stuck in 'executing' with no current task.
const stuck = command.cell.getAllOperators().some(
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'vitest';
import { config, AVAILABLE_MODELS } from '../config/index.js';
import { createLiteLLMBackbone } from '../llm/index.js';
const PREV_BASE = process.env.LITELLM_BASE_URL;
const PREV_KEY = process.env.LITELLM_API_KEY;
const PREV_MODEL = process.env.LITELLM_MODEL;
describe('LiteLLM provider wiring (#45)', () => {
afterEach(() => {
if (PREV_BASE === undefined) delete process.env.LITELLM_BASE_URL;
else process.env.LITELLM_BASE_URL = PREV_BASE;
if (PREV_KEY === undefined) delete process.env.LITELLM_API_KEY;
else process.env.LITELLM_API_KEY = PREV_KEY;
if (PREV_MODEL === undefined) delete process.env.LITELLM_MODEL;
else process.env.LITELLM_MODEL = PREV_MODEL;
});
it('resolves LiteLLM as an OpenAI-compatible proxy from env without requiring a key', () => {
process.env.LITELLM_BASE_URL = 'http://localhost:4000/v1';
delete process.env.LITELLM_API_KEY;
process.env.LITELLM_MODEL = 'anthropic/claude-sonnet-4.5';
const cfg = config.getLLMConfig('litellm');
expect(cfg.provider).toBe('litellm');
expect(cfg.baseUrl).toBe('http://localhost:4000/v1');
expect(cfg.model).toBe('anthropic/claude-sonnet-4.5');
expect(cfg.apiKey).toBeUndefined();
expect(config.getConfiguredProviders()).toContain('litellm');
});
it('the LiteLLM backbone validates when only a proxy base URL is configured', () => {
process.env.LITELLM_BASE_URL = 'http://localhost:4000/v1';
const backbone = createLiteLLMBackbone();
expect(backbone.getProvider()).toBe('litellm');
expect(backbone.validateConfig().valid).toBe(true);
});
it('surfaces a static LiteLLM model catalog entry', () => {
expect(AVAILABLE_MODELS.litellm?.length ?? 0).toBeGreaterThan(0);
expect(AVAILABLE_MODELS.litellm[0].provider).toBe('LiteLLM');
});
});
@@ -0,0 +1,212 @@
/**
* REGRESSION (issue #78 — macOS/POSIX): the local-agent detector resolved the CLI binary
* against the SERVER process PATH. When T3MP3ST is launched from Finder, the desktop app, or
* any non-interactive shell, launchd hands it a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) —
* missing ~/.local/bin (Claude Code's native install), Homebrew, npm-global, and version-manager
* shims (mise/nvm/fnm). `execFile('claude', …)` then throws ENOENT and an installed, working CLI
* is reported `installed:false` (the Settings checkboxes go dead — reads like a UI bug).
*
* The fix is `resolveBin()`: pure-fs resolution over PATH + a curated set of well-known install
* dirs under the real agent home, with an executable-file check, fail-open to the bare name.
* These tests pin the resolver in isolation, the detector wiring, AND the two spawn call-sites
* (runLocalAgent / localAgentChat) the fix rewired — all through the public API.
*
* POSIX-focused, with a small Windows boundary assertion: unresolved Windows shims fail closed
* instead of returning a bare name that would bypass the safe .cmd/.bat launch path.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolveBin, detectLocalAgents, runLocalAgent, localAgentChat } from '../agent/local-agents.js';
const FAKE_CLI = '#!/bin/sh\necho "1.2.3"\n';
// For the chat path, which writes the prompt to the child's stdin: drain it before replying so the
// parent's write()/end() completes cleanly (no EPIPE races), then emit a clean, non-error reply.
const FAKE_CLI_STDIN = '#!/bin/sh\ncat >/dev/null 2>&1\necho "1.2.3"\n';
const tmpDirs: string[] = [];
const savedEnv = { PATH: process.env.PATH, AGENT_HOME: process.env.T3MP3ST_AGENT_HOME };
/** A throwaway dir; tracked for afterEach cleanup. */
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), 't3mp3st-pathres-'));
tmpDirs.push(d);
return d;
}
/** Drop an executable (0o755) fake binary at `dir/name`; returns its absolute path. */
function putExe(dir: string, name: string, content: string = FAKE_CLI): string {
mkdirSync(dir, { recursive: true });
const p = join(dir, name);
writeFileSync(p, content, { mode: 0o755 });
return p;
}
afterEach(() => {
process.env.PATH = savedEnv.PATH;
if (savedEnv.AGENT_HOME === undefined) delete process.env.T3MP3ST_AGENT_HOME; else process.env.T3MP3ST_AGENT_HOME = savedEnv.AGENT_HOME;
for (const d of tmpDirs.splice(0)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
});
describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', () => {
it('finds a CLI in ~/.local/bin even when PATH omits it (the reported bug)', () => {
const home = scratch();
const bin = putExe(join(home, '.local', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home; // real agent home for this scan
process.env.PATH = '/usr/bin:/bin'; // minimal launchd-style PATH — omits ~/.local/bin
expect(resolveBin('faketool')).toBe(bin);
});
// A representative spread of the curated dirs (not just index-0 ~/.local/bin) so a typo or
// reorder in any entry is caught. Paths are relative to the agent home.
it.each([
['.asdf/shims'],
['.bun/bin'],
['.local/share/pnpm'],
])('resolves a CLI installed under the well-known dir %s', (rel) => {
const home = scratch();
const bin = putExe(join(home, ...rel.split('/')), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('finds a CLI under an nvm per-version bin dir (dynamic enumeration)', () => {
const home = scratch();
const bin = putExe(join(home, '.nvm', 'versions', 'node', 'v20.11.0', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('finds a CLI under an fnm per-version bin dir (dynamic enumeration)', () => {
const home = scratch();
const bin = putExe(join(home, '.local', 'share', 'fnm', 'node-versions', 'v20.11.0', 'installation', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('resolves via PATH when the CLI is on it (rich-shell launch still works)', () => {
const dir = scratch();
const bin = putExe(dir, 'faketool');
process.env.T3MP3ST_AGENT_HOME = scratch(); // empty home — force the hit to come from PATH
process.env.PATH = `/usr/bin:${dir}`;
expect(resolveBin('faketool')).toBe(bin);
});
it('prefers a PATH match over a well-known-dir match when both exist (shell precedence)', () => {
const home = scratch();
const wellKnown = putExe(join(home, '.local', 'bin'), 'faketool');
const pathDir = scratch();
const onPath = putExe(pathDir, 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = `/usr/bin:${pathDir}`; // PATH scanned before well-known dirs
expect(resolveBin('faketool')).toBe(onPath);
expect(resolveBin('faketool')).not.toBe(wellKnown);
});
it('follows a symlink to an executable (asdf/nvm shims & Claude Code\'s own install are symlinks)', () => {
const home = scratch();
const real = putExe(join(home, 'tools'), 'realtool');
const linkDir = join(home, '.local', 'bin');
mkdirSync(linkDir, { recursive: true });
const link = join(linkDir, 'faketool');
symlinkSync(real, link);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(link); // the resolved candidate path, symlink followed for the file-check
});
it('skips a non-executable match (parity with execFile X_OK) and fails open to the bare name', () => {
const home = scratch();
const p = join(home, '.local', 'bin', 'faketool');
mkdirSync(join(home, '.local', 'bin'), { recursive: true });
writeFileSync(p, 'not executable', { mode: 0o644 }); // present but not +x
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe('faketool'); // not the non-exec path
});
it('does NOT match a directory named like the CLI (dirs carry the search bit; execvp would skip it)', () => {
// Regression guard: a bare `accessSync(X_OK)` passes for a directory, so resolveBin would return
// the dir → execFile fails EACCES (not ENOENT) → a false installed:true. Require a regular file.
const home = scratch();
mkdirSync(join(home, '.local', 'bin', 'faketool'), { recursive: true }); // a DIRECTORY, not a binary
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe('faketool'); // bare name, not the dir path
});
it('returns an already-qualified path unchanged (the bin.includes("/") guard — no re-resolution)', () => {
expect(resolveBin('/opt/custom/claude')).toBe('/opt/custom/claude');
expect(resolveBin('./rel/claude')).toBe('./rel/claude');
});
it('fails open to the bare name when nothing matches anywhere', () => {
process.env.T3MP3ST_AGENT_HOME = scratch();
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('definitely-not-a-real-cli-xyz')).toBe('definitely-not-a-real-cli-xyz');
});
it('does NOT scan POSIX dirs on win32 — unresolved shims fail closed', () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const orig = Object.getOwnPropertyDescriptor(process, 'platform') || { value: process.platform, configurable: true };
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
expect(resolveBin('faketool')).toBeUndefined();
} finally {
Object.defineProperty(process, 'platform', orig);
}
});
});
describe('detectLocalAgents — wiring: a well-known-dir CLI is reported installed (issue #78)', () => {
it('reports Claude Code installed + versioned when `claude` lives in ~/.local/bin and PATH omits it', async () => {
const home = scratch();
const claudePath = putExe(join(home, '.local', 'bin'), 'claude');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin'; // pre-fix: execFile('claude') → ENOENT → installed:false
delete process.env.T3MP3ST_DISABLE_LOCAL_AGENTS; // insurance: a leaked disable flag would short-circuit to []
const agents = await detectLocalAgents();
const claude = agents.find((a) => a.id === 'claude');
expect(claude?.installed).toBe(true);
expect(claude?.path).toBe(claudePath);
expect(claude?.version).toBe('1.2.3'); // parsed from the resolved binary's --version output
// codex/hermes are absent from this temp home → still not installed (no false positives).
expect(agents.find((a) => a.id === 'codex')?.installed).toBe(false);
});
});
describe('spawn call-sites use the resolved path (issue #78 — detected-but-unspawnable would be worse)', () => {
// Behavioral, not a spawn spy: if the rewiring regressed to the bare `spec.bin`, spawn('claude')
// under PATH=/usr/bin:/bin throws ENOENT → ok:false / reject. Success proves the resolved path ran.
it('runLocalAgent launches the well-known-dir CLI (not the bare name)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const res = await runLocalAgent('claude', 'ping', { timeoutMs: 4000 });
expect(res.ok).toBe(true);
expect(res.output).toContain('1.2.3');
});
it('localAgentChat launches the well-known-dir CLI (not the bare name)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_STDIN);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).resolves.toContain('1.2.3');
});
});
@@ -71,6 +71,15 @@ describe('parseTextToolCalls — happy path + drift tolerance', () => {
});
describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
it('describes the current ACTION CONTRACT to the local agent', async () => {
cli.mockResolvedValueOnce('Final debrief.');
await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
const prompt = String(cli.mock.calls.at(-1)?.[1] || '');
expect(prompt).toContain('ACTION CONTRACT');
expect(prompt).toContain('nmap_scan');
expect(prompt).toContain('port scan a host');
});
it('returns toolCalls + finishReason=tool_calls when the agent requests a tool', async () => {
cli.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```');
const res = await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never);
@@ -83,6 +92,19 @@ describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
expect(res.toolCalls).toBeUndefined();
expect(res.finishReason).toBe('stop');
});
it('does not force the old short timeout when no local-agent timeout is configured', async () => {
cli.mockResolvedValueOnce('done');
await localBackbone().chat([{ role: 'user', content: 'hello' }]);
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: undefined });
});
it('passes an explicit local-agent timeout through to the CLI runner', async () => {
cli.mockResolvedValueOnce('done');
await new LLMBackbone({ provider: 'local-agent', model: 'codex', timeout: 900000 } as never)
.chat([{ role: 'user', content: 'hello' }]);
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: 900000 });
});
});
describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => {
@@ -133,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/);
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
// Static invariants for the `local` provider. Two real OpenAI-compatible servers
// broke against the old code: (1) the wire detector only matched a literal /v1, so
// versioned paths like Zhipu/z.ai's /api/paas/v4 silently fell through to the
// Ollama native format and failed; (2) the provider was treated as always-keyless,
// so servers that require a bearer (Zhipu, Together, …) could never authenticate.
// These tests pin the fixes in place and document the intent.
const llmSource = readFileSync(join(process.cwd(), 'src/llm/index.ts'), 'utf8');
const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8');
function block(source: string, startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker);
expect(start, `missing marker "${startMarker}"`).toBeGreaterThanOrEqual(0);
const end = source.indexOf(endMarker, start);
expect(end, `missing end marker "${endMarker}"`).toBeGreaterThan(start);
return source.slice(start, end);
}
describe('local OpenAI-compatible provider hardening', () => {
it('isOpenAIWire detects ANY versioned path (/v1, /v4, …), not only /v1', () => {
const fn = block(
llmSource,
'private isOpenAIWire(baseUrl: string): boolean {',
'\n }',
);
// Must be version-agnostic: a digit class, not a literal `1`.
expect(fn).toMatch(/v\\d\+/);
// No literal-/v1-only form must remain.
expect(fn).not.toMatch(/\/v1\(/);
});
it('getLLMConfig forwards an optional bearer for local servers that require auth', () => {
const localCase = block(configSource, "case 'local':", 'break;');
expect(localCase).toContain('TEMPEST_LOCAL_API_KEY');
// Provider-specific aliases are accepted as a convenience.
expect(localCase).toMatch(/ZAI_API_KEY/);
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).
const getApiKey = block(configSource, 'getApiKey(provider:', 'envVarMap');
expect(getApiKey).toContain("provider === 'local'");
expect(getApiKey).toContain('TEMPEST_LOCAL_API_KEY');
});
it('the local key slot is part of the typed apiKeys store', () => {
expect(configSource).toMatch(/local\?:\s*string;/);
});
it('the env template documents the local provider options', () => {
const template = block(configSource, 'createEnvTemplate(', '`;');
expect(template).toContain('TEMPEST_LOCAL_BASE_URL');
expect(template).toContain('TEMPEST_LOCAL_MODEL');
expect(template).toContain('TEMPEST_LOCAL_API_KEY');
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Locks the Mobile loadout's static-analysis detector + benchmark into CI: the built-in ruleset must
* catch every seeded manifest-misconfig / hardcoded-secret in the corpus and stay clean on the hardened
* controls. Deterministic + self-contained (no external tool).
*/
import { describe, it, expect } from 'vitest';
import { loadCorpus, detect, scoreCorpus, RULES } from '../../scripts/mobile-static-bench.mjs';
describe('mobile static-analysis benchmark', () => {
const corpus = loadCorpus();
const score = scoreCorpus(corpus);
it('corpus has seeded fixtures + hardened controls with ground truth', () => {
expect(corpus.fixtures.length).toBeGreaterThanOrEqual(6);
expect(corpus.fixtures.some((f) => f.clean)).toBe(true);
expect(corpus.fixtures.every((f) => f.clean || f.expect.length > 0)).toBe(true);
});
it('detects every seeded manifest/secret issue', () => {
expect(score.detection.hits).toBe(score.detection.total);
expect(score.detection.total).toBeGreaterThanOrEqual(6);
});
it('does not false-positive on hardened controls', () => {
expect(score.discrimination.falsePositives).toBe(0);
});
it('ruleset spans manifest misconfig + hardcoded secrets, kind-scoped', () => {
expect(detect('android:debuggable="true"', 'manifest').has('M-DEBUGGABLE')).toBe(true);
expect(detect('String k = "AKIAIOSFODNN7EXAMPLE";', 'code').has('S-AWS-KEY')).toBe(true);
// a manifest xmlns http:// must NOT trip the code-only cleartext-endpoint rule
expect(detect('xmlns:android="http://schemas.android.com/apk/res/android"', 'manifest').has('E-HTTP-URL')).toBe(false);
expect(RULES.length).toBeGreaterThanOrEqual(6);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// ─────────────────────────────────────────────────────────────────────────────
// NO PHANTOM PASS — the "scored = oracle-backed" guard for the benchmark receipts.
//
// `verify-claims` credits a solve straight from the committed `verdict` (detected /
// score===1). The launch risk is the mirror of a phantom tool: a phantom PASS — a
// committed `detected:true` that its own committed flag oracle does not actually back
// (a hand-edit, a merge slip, a fabricated placeholder flag). This test makes that fail
// the build by construction — the same discipline as no-phantom-tools, applied to the
// bench artifacts the headline numbers are re-derived from.
//
// A scored solve is ORACLE-GROUNDED iff EITHER the agent's self-reported flag matches the
// committed `expected` under the harness's case-insensitive compare (ciMatch), OR the
// harness auto-detected `expected` verbatim in tool output (auto_detected — the agent did
// not self-report, so `reported` may be "UNKNOWN"). Anything else scored as a solve is a
// phantom pass.
// ─────────────────────────────────────────────────────────────────────────────
// Same helpers verify-claims.mjs uses, re-derived here so the gate is independent of it.
const ciMatch = (a: unknown, b: unknown) =>
!!a && !!b && String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
function looksFabricated(flag: unknown): boolean {
if (!flag) return false;
const inner = (String(flag).match(/\{([\s\S]*)\}/) || ['', ''])[1]
.toLowerCase()
.replace(/4/g, 'a').replace(/3/g, 'e').replace(/0/g, 'o')
.replace(/1/g, 'i').replace(/5/g, 's').replace(/7/g, 't');
return /\b(fake|placeholder|dummy|redacted|todo|tbd)\b|fake_?flag|for_?test|_?test(ing)?_?(flag|local|stub|only)|local_?test|real_[a-z]+_proof|proof_?xyz|sample_?flag|your_?flag_?here|example_?flag|replace_?me|flag_?here/.test(inner);
}
interface Verdict {
detected?: boolean;
score?: number;
reported?: string;
expected?: string;
auto_detected?: boolean;
reason?: string;
}
const isScoredSolve = (v: Verdict) => v.detected === true || v.score === 1;
const isOracleGrounded = (v: Verdict) =>
ciMatch(v.reported, v.expected) ||
v.auto_detected === true ||
/auto-?detected/i.test(String(v.reason || ''));
// A phantom pass: credited as a solve, but not backed by its own committed oracle.
const isPhantomPass = (v: Verdict) => isScoredSolve(v) && !isOracleGrounded(v);
// A fabricated credit: a placeholder flag credited via the self-report (oracle-match) path.
const isFabricatedCredit = (v: Verdict) =>
isScoredSolve(v) && ciMatch(v.reported, v.expected) && looksFabricated(v.reported);
// ── Load the committed XBEN artifacts the headline sweeps are pinned to ──────
const REPO = process.cwd();
const XBEN = path.join(REPO, 'bench', 'xbow', 'results');
const PINNED_DIRS = [
'blackbox-golden', 'blackbox-golden-v2', 'blackbox-golden-final',
'whitebox-golden', 'whitebox-golden-v2', 'venice-whitebox',
];
function firstVerdict(file: string): Verdict {
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
return ((d.results || [{}])[0] || {}).verdict || {};
}
function committedSolves(): Verdict[] {
const out: Verdict[] = [];
for (const dir of PINNED_DIRS) {
const abs = path.join(XBEN, dir);
if (!fs.existsSync(abs)) continue;
for (const f of fs.readdirSync(abs).filter((n) => /^xben_\d+_24.*\.json$/.test(n))) {
const v = firstVerdict(path.join(abs, f));
if (isScoredSolve(v)) out.push(v);
}
}
return out;
}
describe('no phantom pass (scored = oracle-backed)', () => {
it('finds a non-trivial set of committed, scored solves to audit', () => {
// Guard against the vacuous-green failure mode: if the artifacts moved and we scan
// nothing, the "0 phantom" assertions below would pass while checking nothing.
expect(committedSolves().length).toBeGreaterThanOrEqual(100);
});
it('every committed scored solve is oracle-grounded (self-report ≡ oracle, or auto-detected)', () => {
const phantoms = committedSolves().filter(isPhantomPass);
expect(
phantoms,
`committed solves not backed by their own oracle: ${phantoms
.map((v) => `reported=${JSON.stringify(v.reported)} expected=${JSON.stringify(v.expected)}`)
.join('; ')}`,
).toEqual([]);
});
it('no fabricated placeholder flag is credited as a self-reported solve', () => {
const fabricated = committedSolves().filter(isFabricatedCredit);
expect(fabricated).toEqual([]);
});
// ── The gate must have TEETH: prove it rejects tampered artifacts, not just that
// today's clean data passes. Otherwise a future hand-edit sails through. ──
it('rejects a hand-edited detected:true whose oracle does not match (reported ≠ expected)', () => {
const tampered: Verdict = {
detected: true,
score: 1,
reported: 'FLAG{claimed_but_wrong}',
expected: 'FLAG{the_real_committed_flag}',
auto_detected: false,
reason: 'exact match',
};
expect(isPhantomPass(tampered)).toBe(true);
});
it('rejects a fabricated placeholder flag even when reported === expected', () => {
const fabricated: Verdict = {
detected: true,
score: 1,
reported: 'FLAG{local_test_flag}',
expected: 'FLAG{local_test_flag}',
reason: 'exact match',
};
expect(isPhantomPass(fabricated)).toBe(false); // reported === expected, so not "phantom"…
expect(isFabricatedCredit(fabricated)).toBe(true); // …but the fabrication filter catches it.
});
it('accepts a legitimate auto-detected solve (agent did not self-report; harness found the flag)', () => {
const autoDetected: Verdict = {
detected: false,
score: 1,
reported: 'UNKNOWN',
expected: 'FLAG{0e640864c2718bb34e01e700391986e28ecd6fbe98b4569ef4ccc4cee16a4b2c}',
auto_detected: true,
reason: 'auto-detected: expected flag verbatim in tool output (agent did not self-report)',
};
expect(isPhantomPass(autoDetected)).toBe(false);
});
});
+229
View File
@@ -0,0 +1,229 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseToolOutput, hasParser, PARSED_TOOL_IDS } from '../arsenal/parsers.js';
import { gateLiveFinding } from '../evidence/gate.js';
import { adapterToCustomTool, type AdapterToolDeps } from '../arsenal/adapter-tools.js';
import { TOOL_ADAPTERS } from '../arsenal/catalog.js';
import { KillChainPhase } from '../types/index.js';
import type { CustomTool, Finding, ToolFinding } from '../types/index.js';
// ─────────────────────────────────────────────────────────────────────────────
// STRUCTURED PARSERS — turn real scanner stdout into ToolFinding[], honestly.
//
// Two things are pinned here:
// 1) each JSON-emitting scanner's output maps to the right structured fields, and
// 2) the honesty contract holds: empty / garbled / unknown input yields [] (never a
// fabricated finding), and a parsed finding — carried through the SAME shape the
// agent loop + operator build — PASSES the live provenance gate. That last check
// is the point of the whole layer: structured output that the honesty spine accepts.
// ─────────────────────────────────────────────────────────────────────────────
const fixture = (name: string): string =>
readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8');
describe('parseToolOutput — field mapping per scanner', () => {
it('nuclei: one finding per JSONL match; skips non-JSON lines; maps cve/cwe/cvss/severity', () => {
const f = parseToolOutput('nuclei', fixture('nuclei.jsonl'));
expect(f).toHaveLength(2); // banner line skipped
const log4j = f[0];
expect(log4j.title).toBe('Apache Log4j RCE');
expect(log4j.severity).toBe('critical');
expect(log4j.cve).toEqual(['CVE-2021-44228']);
expect(log4j.cwe).toEqual(['CWE-502']);
expect(log4j.cvss).toBe(10.0);
expect(log4j.remediation).toMatch(/2\.17\.0/);
expect(log4j.details).toContain('target.example.com');
expect(f[1].severity).toBe('info'); // nginx version disclosure
});
it('httpx: one info finding per live host with url/status/tech in details', () => {
const f = parseToolOutput('httpx', fixture('httpx.jsonl'));
expect(f).toHaveLength(2);
expect(f[0].severity).toBe('info');
expect(f[0].title).toContain('target.example.com');
expect(f[0].details).toContain('status: 200');
expect(f[0].details).toContain('Nginx');
});
it('dalfox: one finding per XSS probe with severity + CWE', () => {
const f = parseToolOutput('dalfox', fixture('dalfox.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].cwe).toEqual(['CWE-79']);
expect(f[0].title).toContain('q'); // the param
expect(f[0].details).toContain('poc');
});
it('ffuf: a single aggregate finding counting discovered paths (no per-path flood)', () => {
const f = parseToolOutput('ffuf', fixture('ffuf.json'));
expect(f).toHaveLength(1);
expect(f[0].title).toBe('ffuf: 2 path(s) discovered');
expect(f[0].severity).toBe('info');
expect(f[0].details).toContain('admin');
expect(f[0].details).toContain('login');
});
it('katana: a single aggregate finding counting discovered endpoints', () => {
const f = parseToolOutput('katana', fixture('katana.jsonl'));
expect(f).toHaveLength(1);
expect(f[0].title).toBe('katana: 3 endpoint(s) discovered');
expect(f[0].details).toContain('/about');
});
});
describe('parseToolOutput — honesty contract (never fabricate, never throw)', () => {
it('returns [] for empty, whitespace, and unparseable output', () => {
for (const raw of ['', ' ', '\n\n', 'not json at all', '{bad json', '<html>error</html>']) {
expect(parseToolOutput('nuclei', raw)).toEqual([]);
expect(parseToolOutput('ffuf', raw)).toEqual([]);
expect(parseToolOutput('dalfox', raw)).toEqual([]);
}
});
it('returns [] for a tool with no parser wired', () => {
expect(parseToolOutput('sqlmap', fixture('nuclei.jsonl'))).toEqual([]);
expect(parseToolOutput('metasploit', 'anything')).toEqual([]);
expect(hasParser('sqlmap')).toBe(false);
});
it('never throws on adversarial / deeply-nested / wrong-shape input', () => {
const evil = [
JSON.stringify({ results: 'not-an-array' }),
'[1,2,3]',
'{"info":null}\n{"info":{"classification":"nope"}}',
'['.repeat(500),
JSON.stringify({ results: [{ input: null, status: {} }] }),
];
for (const id of PARSED_TOOL_IDS) {
for (const raw of evil) {
expect(() => parseToolOutput(id, raw)).not.toThrow();
expect(Array.isArray(parseToolOutput(id, raw))).toBe(true);
}
}
});
it('exposes exactly the wired parser ids', () => {
expect([...PARSED_TOOL_IDS].sort()).toEqual(
['dalfox', 'ffuf', 'gitleaks', 'grype', 'httpx', 'katana', 'nuclei', 'semgrep', 'trivy'],
);
});
});
describe('parseToolOutput — source / supply-chain scanners', () => {
it('semgrep: one finding per rule match with mapped severity + CWE token', () => {
const f = parseToolOutput('semgrep', fixture('semgrep.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high'); // ERROR → high
expect(f[0].cwe).toEqual(['CWE-78']); // extracted from "CWE-78: OS Command Injection"
expect(f[0].details).toContain('app/run.py:42');
});
it('trivy: one finding per package vuln with CVE, CWE, and an upgrade remediation', () => {
const f = parseToolOutput('trivy', fixture('trivy.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].cve).toEqual(['CVE-2021-3749']);
expect(f[0].cwe).toEqual(['CWE-400']);
expect(f[0].remediation).toContain('0.21.2');
expect(f[0].details).toContain('axios');
});
it('grype: one finding per match with CVE + package coordinates', () => {
const f = parseToolOutput('grype', fixture('grype.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high'); // "High" → high
expect(f[0].cve).toEqual(['CVE-2022-23529']);
expect(f[0].details).toContain('jsonwebtoken');
});
it('gitleaks: one high finding per secret, and the raw secret is REDACTED from details', () => {
const f = parseToolOutput('gitleaks', fixture('gitleaks.json'));
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].details).toContain('config/prod.env:12');
// Defense in depth: the AWS key present in the raw report must never survive into the finding.
expect(f[0].details).not.toContain('AKIAIOSFODNN7EXAMPLE');
expect(f[0].details).toContain('[redacted]');
});
});
describe('end-to-end: a parsed finding passes the live provenance gate', () => {
// Mirror how the agent loop stamps provenance (src/agent/index.ts) and how the operator
// materialises a ToolFinding into a Finding with output-evidence (src/operators/index.ts),
// then run the real gate. This proves parsed output is accepted by the honesty spine.
const materialise = (tf: ToolFinding, rawOutput: string, toolName: string): Finding => {
const stamped = { ...tf, provenance: 'tool' as const, toolName, toolOutput: rawOutput.slice(0, 4000) };
return {
id: 'finding-test',
title: stamped.title,
description: stamped.details,
severity: stamped.severity,
targetId: 'target-1',
operatorId: 'op-1',
phase: KillChainPhase.RECON,
cvss: stamped.cvss,
cve: stamped.cve,
cwe: stamped.cwe,
evidence: stamped.provenance === 'tool'
? [{ type: 'output', content: stamped.toolOutput || stamped.details || '', timestamp: 1, metadata: { tool: stamped.toolName } }]
: [],
remediation: stamped.remediation,
discoveredAt: 1,
};
};
it('a parsed nuclei critical finding is gated PASS with provenance=tool', () => {
const raw = fixture('nuclei.jsonl');
const tf = parseToolOutput('nuclei', raw)[0];
const gate = gateLiveFinding(materialise(tf, raw, 'nuclei_tool'));
expect(gate.passed).toBe(true);
expect(gate.provenance).toBe('tool');
});
it('every parsed finding across all scanners is gate-passable (real tool evidence)', () => {
for (const [id, file] of [
['nuclei', 'nuclei.jsonl'], ['httpx', 'httpx.jsonl'], ['dalfox', 'dalfox.json'],
['ffuf', 'ffuf.json'], ['katana', 'katana.jsonl'],
['semgrep', 'semgrep.json'], ['gitleaks', 'gitleaks.json'],
['trivy', 'trivy.json'], ['grype', 'grype.json'],
] as const) {
const raw = fixture(file);
for (const tf of parseToolOutput(id, raw)) {
const gate = gateLiveFinding(materialise(tf, raw, `${id}_tool`));
expect(gate.passed, `${id} finding "${tf.title}" should pass the gate`).toBe(true);
expect(gate.provenance).toBe('tool');
}
}
});
});
describe('factory wiring: a minted adapter returns structured findings from real stdout', () => {
// Proves the actual code path (adapter-tools.ts success return) populates ToolResult.findings —
// not just that the pure parser works. Deps are fakes; runSubprocess replays a fixture as stdout.
const depsReturning = (stdout: string): AdapterToolDeps => ({
isToolAvailable: async () => true,
runSubprocess: async () => ({ stdout, stderr: '', exitCode: 0 }),
});
const mint = (id: string, deps: AdapterToolDeps): CustomTool => {
const adapter = TOOL_ADAPTERS.find((a) => a.id === id);
if (!adapter) throw new Error(`fixture: adapter '${id}' not found`);
const tool = adapterToCustomTool(adapter, deps);
if (!tool) throw new Error(`fixture: adapter '${id}' is not mintable`);
return tool;
};
it('nuclei_tool surfaces parsed findings (with cwe/cve) AND keeps the raw stdout as evidence', async () => {
const raw = fixture('nuclei.jsonl');
const res = await mint('nuclei', depsReturning(raw)).handler({ parameters: { url: 'https://target.example.com' } });
expect(res.success).toBe(true);
expect(res.output).toBe(raw); // raw stdout preserved as the evidence of record
expect(res.findings).toHaveLength(2);
expect(res.findings?.[0].cwe).toEqual(['CWE-502']);
});
it('a tool whose scan yields no parseable output leaves findings unset (no fabrication)', async () => {
const res = await mint('nuclei', depsReturning('no findings here\n')).handler({ parameters: { url: 'https://target.example.com' } });
expect(res.success).toBe(true);
expect(res.findings).toBeUndefined();
});
});
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { Arsenal, BUILTIN_TOOLS, createToolContext } from '../arsenal/index.js';
const httpTool = BUILTIN_TOOLS.find(tool => tool.name === 'http_request');
const technologyTool = BUILTIN_TOOLS.find(tool => tool.name === 'technology_detect');
if (!httpTool || !technologyTool) throw new Error('Required HTTP tools are not registered');
const originalOrigin = process.env.TEMPEST_TARGET_ORIGIN;
const originalHeaders = process.env.TEMPEST_TARGET_HEADERS;
function response(status = 200, headers: Record<string, string> = {}): Response {
const normalized = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
return {
status,
statusText: status === 200 ? 'OK' : 'Found',
headers: {
entries: () => normalized.entries(),
get: (name: string) => normalized.get(name.toLowerCase()) ?? null,
},
text: async () => '<html></html>',
} as unknown as Response;
}
function configure(origin = 'https://target.example') {
process.env.TEMPEST_TARGET_ORIGIN = origin;
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({
Authorization: 'Bearer configured-secret',
'X-Tenant': 'acme',
});
}
beforeEach(() => configure());
afterEach(() => {
if (originalOrigin === undefined) delete process.env.TEMPEST_TARGET_ORIGIN;
else process.env.TEMPEST_TARGET_ORIGIN = originalOrigin;
if (originalHeaders === undefined) delete process.env.TEMPEST_TARGET_HEADERS;
else process.env.TEMPEST_TARGET_HEADERS = originalHeaders;
vi.unstubAllGlobals();
});
describe('target header binding', () => {
it('injects configured headers only into the exact origin', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
await httpTool.handler(createToolContext(undefined, { url: 'https://other.example/api' }));
await httpTool.handler(createToolContext(undefined, { url: 'http://target.example/api' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
expect(new Headers(mockFetch.mock.calls[1][1]?.headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[2][1]?.headers).has('authorization')).toBe(false);
});
it('requires both the origin binding and a valid non-empty string header map', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
delete process.env.TEMPEST_TARGET_ORIGIN;
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
process.env.TEMPEST_TARGET_ORIGIN = 'https://target.example';
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Authorization: 123 });
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
});
it('rejects origins with paths and transport-level headers', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
configure('https://target.example/api');
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
configure();
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Host: 'other.example' });
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('host')).toBe(false);
});
it('lets explicit tool headers override configured headers case-insensitively', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, {
url: 'https://target.example',
headers: { authorization: 'Bearer request-secret' },
}));
const sent = new Headers(mockFetch.mock.calls[0][1].headers);
expect(sent.get('authorization')).toBe('Bearer request-secret');
expect([...sent.keys()].filter(name => name === 'authorization')).toHaveLength(1);
expect(sent.get('x-tenant')).toBe('acme');
});
it('applies configured headers to built-in probes beyond http_request', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await technologyTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
});
});
describe('target header redirect safety', () => {
it('removes every configured header before a cross-origin redirect', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(response(302, { location: 'https://external.example/landing' }))
.mockResolvedValueOnce(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('x-tenant')).toBe('acme');
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('x-tenant')).toBe(false);
expect(String(mockFetch.mock.calls[1][0])).toBe('https://external.example/landing');
});
it('keeps configured headers on same-origin redirects', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(response(302, { location: '/next' }))
.mockResolvedValueOnce(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
expect(new Headers(mockFetch.mock.calls[1][1].headers).get('authorization')).toBe('Bearer configured-secret');
});
});
describe('target header secret handling', () => {
it('redacts configured values from persisted tool output and findings', async () => {
const mockFetch = vi.fn().mockResolvedValue(response(200, {
'x-reflected-value': 'Bearer configured-secret',
}));
vi.stubGlobal('fetch', mockFetch);
const arsenal = new Arsenal();
arsenal.register(httpTool);
const result = await arsenal.execute('http_request', createToolContext(undefined, {
url: 'https://target.example',
}));
expect(result.output).toContain('[REDACTED]');
expect(result.output).not.toContain('configured-secret');
expect(arsenal.getExecutions()[0].result?.output).not.toContain('configured-secret');
});
it('keeps configured curl headers out of argv and disables redirect-following flags', () => {
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
const curl = source.slice(source.indexOf("name: 'curl_request'"));
expect(curl).toContain("writeFile(configPath");
expect(curl).toContain("mode: 0o600");
expect(curl).toContain('withoutCurlRedirectFlags');
expect(curl).not.toContain("args.push('-H', `${name}: ${value}`)");
});
it('routes every built-in fetch call through the target-aware helper', () => {
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
const builtins = source.slice(source.indexOf('export const BUILTIN_TOOLS'), source.indexOf('export const EXTERNAL_TOOLS'));
expect(builtins).not.toMatch(/\bfetch\s*\(/);
expect(builtins).toContain('targetFetch(');
});
});
+76 -6
View File
@@ -65,12 +65,14 @@ export interface AgentResult {
durationMs: number;
/** Whether the agent hit the iteration limit */
hitLimit: boolean;
/** Error from the forced final-summary call after hitting limits, if any */
finalSummaryError?: string;
}
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 };
@@ -131,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
@@ -267,6 +302,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
});
let summary = 'Agent reached iteration limit without producing a final summary.';
let finalSummaryError: string | undefined;
try {
const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 });
summary = finalResponse.content;
@@ -275,8 +311,8 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
for (const f of this.parseFinalFindings(summary)) {
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
}
} catch {
// Use what we have
} catch (error) {
finalSummaryError = error instanceof Error ? error.message : String(error);
}
const result: AgentResult = {
@@ -288,12 +324,45 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
tokensUsed,
durationMs: Date.now() - startTime,
hitLimit,
finalSummaryError,
};
this.emit('agent:complete', result);
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
*/
@@ -302,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 {
@@ -326,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,
+195 -32
View File
@@ -13,7 +13,7 @@
// =============================================================================
import { execFile, execFileSync, spawn } from 'child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { accessSync, constants, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'fs';
import { homedir, tmpdir, userInfo } from 'os';
import { join } from 'path';
@@ -53,6 +53,16 @@ export function agentHome(): string {
}
const expand = (p: string): string => (p.startsWith('~') ? agentHome() + p.slice(1) : p);
/**
* Resolve a path under the OS-native per-user local app-data root, cross-platform:
* - Windows: %LOCALAPPDATA% (e.g. C:\Users\<u>\AppData\Local), where the Hermes desktop app
* stores its runtime auth under %LOCALAPPDATA%\hermes\ rather than ~/.hermes/.
* - POSIX: <agentHome>/AppData/Local fallback (harmless: the artifact simply won't exist there).
* PRESENCE-ONLY: callers pass the result to existsSync(); contents are never read.
*/
const localAppData = (rel: string): string =>
join(process.env.LOCALAPPDATA || join(agentHome(), 'AppData', 'Local'), ...rel.split('/'));
/**
* Env for a spawned agent CLI. Two adjustments to our own env:
* - strip the injected provider keys so the CLI falls back to its OWN native login (not t3mp3st's).
@@ -126,7 +136,17 @@ const SPECS: AgentSpec[] = [
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.codex/auth.json', '~/.config/codex/auth.json'],
oneShot: (p, m) => ['exec', ...(m ? ['-m', m] : []), p],
oneShot: (p, m) => [
'exec',
'--ephemeral',
'--skip-git-repo-check',
'--sandbox',
'read-only',
'--color',
'never',
...(m ? ['-m', m] : []),
p,
],
},
{
id: 'hermes',
@@ -137,7 +157,10 @@ const SPECS: AgentSpec[] = [
invokeHint: 'hermes -z "<prompt>" (--yolo only if T3MP3ST_HERMES_YOLO=1)',
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.hermes/.env'],
// Hermes 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 {
@@ -236,6 +381,18 @@ export interface AgentRunResult {
error?: string;
}
function agentFailureOutput(output: string): string | null {
const text = output.trim();
if (/^API call failed\b/i.test(text)) return text.slice(0, 300);
if (/connection error/i.test(text) && /failed/i.test(text)) return text.slice(0, 300);
return null;
}
function envTimeoutMs(name: string, fallback: number): number {
const parsed = Number(process.env[name]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
/**
* Drive a connected agent with a one-shot headless prompt (real round-trip — SPENDS the agent's quota).
* Used by /ping (liveness proof) and /dispatch (actually using the operator).
@@ -249,13 +406,14 @@ export function runLocalAgent(
const t0 = Date.now();
if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` });
const args = spec.oneShot(prompt, opts.model);
const timeoutMs = opts.timeoutMs ?? 120000;
const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
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;
@@ -270,7 +428,9 @@ export function runLocalAgent(
child.on('close', (code) => {
const latencyMs = Date.now() - t0;
const output = out.trim().slice(0, maxChars);
if (code === 0) finish({ ok: true, latencyMs, output });
const semanticError = agentFailureOutput(output);
if (code === 0 && !semanticError) finish({ ok: true, latencyMs, output });
else if (semanticError) finish({ ok: false, latencyMs, output, error: semanticError });
else finish({ ok: false, latencyMs, output, error: (errOut.trim() || `exited with code ${code}`).slice(0, 300) });
});
});
@@ -297,7 +457,7 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv();
const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined;
const timeoutMs = opts.timeoutMs ?? 240000;
const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
let args: string[];
let viaStdin = true;
@@ -308,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;
@@ -329,7 +490,9 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
let content = out.trim();
if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } }
finish(() => {
if (code === 0 && content) resolve(content);
const semanticError = agentFailureOutput(content);
if (code === 0 && content && !semanticError) resolve(content);
else if (semanticError) reject(new Error(semanticError));
else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800)));
});
});
+16 -14
View File
@@ -18,6 +18,7 @@ import type { MissionControl } from '../mission/index.js';
import type { OpsecController } from '../opsec/index.js';
import { randomUUID } from 'crypto';
import { SEVERITY_SCORES } from '../evidence/index.js';
import { redactString } from '../redact.js';
// =============================================================================
// ANALYSIS ENGINE
@@ -251,7 +252,7 @@ export class AnalysisEngine {
// Executive Summary
lines.push('## Executive Summary');
lines.push('');
lines.push(report.summary.overview);
lines.push(redactString(report.summary.overview));
lines.push('');
lines.push('### Risk Overview');
lines.push('');
@@ -282,26 +283,27 @@ export class AnalysisEngine {
);
for (const finding of sortedFindings) {
lines.push(`### ${finding.title}`);
lines.push(`### ${redactString(finding.title)}`);
lines.push('');
lines.push(`**Severity:** ${finding.severity.toUpperCase()}`);
if (finding.cvss) lines.push(`**CVSS:** ${finding.cvss}`);
if (finding.cve?.length) lines.push(`**CVE:** ${finding.cve.join(', ')}`);
lines.push('');
lines.push('**Description:**');
lines.push(finding.description);
lines.push(redactString(finding.description));
lines.push('');
if (finding.remediation) {
lines.push('**Remediation:**');
lines.push(finding.remediation);
lines.push(redactString(finding.remediation));
lines.push('');
}
if (finding.evidence.length > 0) {
lines.push('**Evidence:**');
for (const evidence of finding.evidence) {
lines.push(`- ${evidence.type}: \`${evidence.content.substring(0, 100)}${evidence.content.length > 100 ? '...' : ''}\``);
const content = redactString(evidence.content);
lines.push(`- ${evidence.type}: \`${content.substring(0, 100)}${content.length > 100 ? '...' : ''}\``);
}
lines.push('');
}
@@ -313,15 +315,15 @@ export class AnalysisEngine {
lines.push('');
for (const path of report.attackPaths) {
lines.push(`### ${path.name}`);
lines.push(`### ${redactString(path.name)}`);
lines.push('');
lines.push(`**Impact Level:** ${path.impactLevel.toUpperCase()}`);
lines.push('');
lines.push(path.description);
lines.push(redactString(path.description));
lines.push('');
lines.push('**Steps:**');
for (let i = 0; i < path.steps.length; i++) {
lines.push(`${i + 1}. ${path.steps[i]}`);
lines.push(`${i + 1}. ${redactString(path.steps[i])}`);
}
lines.push('');
}
@@ -342,8 +344,8 @@ export class AnalysisEngine {
lines.push('### Immediate Priority');
lines.push('');
for (const rec of byPriority.immediate) {
lines.push(`- **${rec.title}** (Effort: ${rec.effort})`);
lines.push(` ${rec.description}`);
lines.push(`- **${redactString(rec.title)}** (Effort: ${rec.effort})`);
lines.push(` ${redactString(rec.description)}`);
}
lines.push('');
}
@@ -352,8 +354,8 @@ export class AnalysisEngine {
lines.push('### Short-Term Priority');
lines.push('');
for (const rec of byPriority.short_term) {
lines.push(`- **${rec.title}** (Effort: ${rec.effort})`);
lines.push(` ${rec.description}`);
lines.push(`- **${redactString(rec.title)}** (Effort: ${rec.effort})`);
lines.push(` ${redactString(rec.description)}`);
}
lines.push('');
}
@@ -362,8 +364,8 @@ export class AnalysisEngine {
lines.push('### Long-Term Priority');
lines.push('');
for (const rec of byPriority.long_term) {
lines.push(`- **${rec.title}** (Effort: ${rec.effort})`);
lines.push(` ${rec.description}`);
lines.push(`- **${redactString(rec.title)}** (Effort: ${rec.effort})`);
lines.push(` ${redactString(rec.description)}`);
}
lines.push('');
}
+159 -1
View File
@@ -25,6 +25,7 @@
import type { ToolAdapter } from './catalog.js';
import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
import { parseToolOutput } from './parsers.js';
// =============================================================================
// INJECTED DEPENDENCIES
@@ -78,6 +79,39 @@ interface ArgTemplate {
const str = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() ? v.trim() : undefined;
/**
* Resolve the filesystem PATH a source/supply-chain scanner should run against (semgrep, gitleaks,
* trivy, …). These adapters are non-networked and operate on a directory, defaulting to the working
* dir (`.`). The subcommand + output flags in each template are HARDCODED — only this path is
* tunable, and it is sanitised so it cannot smuggle a flag or an inherited URL past the gate:
* - a leading `-` would be reparsed as a scanner flag (arg injection) → fall back to `.`,
* - an http(s) URL (e.g. a networked mission target inherited via context.target.address) is not a
* scan path → fall back to `.`.
*/
const scanPath = (target: string, params: Record<string, unknown>): string => {
const p = str(params.path) ?? str(params.target) ?? (target || undefined);
if (!p || /^-/.test(p) || /^https?:\/\//i.test(p)) return '.';
return p;
};
/**
* Resolve the local FILE/artifact a reverse-engineering or mobile analyser runs against (objdump,
* readelf, r2, myth, apkleaks, …). Unlike a directory scanner, a binary tool has no sensible cwd
* default — a bare `objdump .` is meaningless — so instead of silently degrading this REFUSES an
* absent or non-local path by throwing, which the factory handler turns into a clean
* `{ success:false }` result (never an unhandled rejection). The two rejected shapes:
* - no path at all → the tool would spawn `<binary> ''` / `<binary>` and produce nothing,
* - an http(s) URL (e.g. a networked mission target inherited via context.target.address) → not a
* local artifact. (A leading `-` is already refused upstream by the factory's option-looking-
* target guard, since these templates read the artifact from their `targetParam`.)
*/
const artifactPath = (target: string, params: Record<string, unknown>): string => {
const p = str(params.file) ?? str(params.path) ?? str(params.artifact) ?? (target || undefined);
if (!p) throw new Error('requires a file/artifact path (none was provided).');
if (/^https?:\/\//i.test(p)) throw new Error(`'${p}' is a URL, not a local artifact path.`);
return p;
};
/**
* Per-binary arg templates for the common command-ready adapters. Keyed by `adapter.binary` (the
* process name), with `adapter.id` also accepted as a fallback key so callers can template by either.
@@ -217,6 +251,111 @@ const ARG_TEMPLATES: Record<string, ArgTemplate> = {
return args;
},
},
// ── Source / supply-chain scanners (non-networked, operate on a PATH) ───────────────────────────
// Without these, each falls through to DEFAULT_TEMPLATE and spawns `<binary> <target>` — which for
// a subcommand-driven scanner is a broken invocation (e.g. `semgrep .` never runs a scan, and with
// no path it is `semgrep ''`). Templates mirror the catalog `commandHint` and hardcode the
// subcommand + machine-readable output flags; the only tunable is the scan path (see scanPath).
semgrep: {
targetParam: 'path',
defaultTimeoutMs: 300_000,
build: (target, params) => ['scan', '--config', 'auto', '--json', scanPath(target, params)],
},
gitleaks: {
targetParam: 'path',
defaultTimeoutMs: 180_000,
build: (target, params) => ['detect', '--source', scanPath(target, params), '--report-format', 'json', '--redact', '--no-banner'],
},
trufflehog: {
targetParam: 'path',
defaultTimeoutMs: 300_000,
build: (target, params) => ['filesystem', scanPath(target, params), '--json', '--no-update'],
},
trivy: {
targetParam: 'path',
defaultTimeoutMs: 300_000,
build: (target, params) => ['fs', '--format', 'json', scanPath(target, params)],
},
syft: {
targetParam: 'path',
defaultTimeoutMs: 180_000,
build: (target, params) => ['dir:' + scanPath(target, params), '-o', 'cyclonedx-json'],
},
grype: {
targetParam: 'path',
defaultTimeoutMs: 180_000,
build: (target, params) => ['dir:' + scanPath(target, params), '-o', 'json'],
},
checkov: {
targetParam: 'path',
defaultTimeoutMs: 180_000,
build: (target, params) => ['-d', scanPath(target, params), '-o', 'json'],
},
// ── Reverse-engineering / mobile / smart-contract static analysis (local_read, operate on a FILE) ─
// Without these each falls through to DEFAULT_TEMPLATE and spawns `<binary> <file>` — which for a
// subcommand- or flag-driven analyser is a broken (or, for r2, an INTERACTIVE-shell-hanging)
// invocation. Templates mirror the catalog `commandHint` and hardcode the READ-ONLY subcommand +
// machine-readable output flags; the only tunable is the artifact/scan path (see artifactPath /
// scanPath). None add an intrusive or code-executing flag — risk stays where the catalog put it.
objdump: {
// `objdump <file>` errors ("no options given"); disassembly needs an action flag.
targetParam: 'file',
defaultTimeoutMs: 60_000,
build: (target, params) => ['-d', '-M', 'intel', artifactPath(target, params)],
},
readelf: {
// `readelf <file>` errors ("Warning: Nothing to do"); `-a` dumps all ELF headers.
targetParam: 'file',
defaultTimeoutMs: 60_000,
build: (target, params) => ['-a', artifactPath(target, params)],
},
checksec: {
// checksec takes its subject as `--file=<path>`, not a bare positional.
targetParam: 'file',
defaultTimeoutMs: 30_000,
build: (target, params) => ['--file=' + artifactPath(target, params)],
},
r2: {
// A bare `r2 <file>` drops into an INTERACTIVE prompt and blocks the agent loop until the
// per-adapter timeout burns out. `-q -c ij` runs ONE read-only info command as JSON, then quits;
// `scr.color=0` keeps the JSON free of ANSI escapes. No `w`/`!`/analysis-write commands are used.
targetParam: 'file',
defaultTimeoutMs: 60_000,
build: (target, params) => ['-q', '-e', 'scr.color=0', '-c', 'ij', artifactPath(target, params)],
},
exiftool: {
// `exiftool <file>` works but prints human text; `-json` gives a structured, parseable channel.
targetParam: 'file',
defaultTimeoutMs: 30_000,
build: (target, params) => ['-json', artifactPath(target, params)],
},
myth: {
// `myth <file>` errors; Mythril needs the `analyze` subcommand. `-o json` emits machine output.
targetParam: 'file',
defaultTimeoutMs: 300_000,
build: (target, params) => ['analyze', artifactPath(target, params), '-o', 'json'],
},
apkleaks: {
// `apkleaks <file>` errors; the APK is supplied via `-f`. Results print to stdout.
targetParam: 'file',
defaultTimeoutMs: 180_000,
build: (target, params) => ['-f', artifactPath(target, params)],
},
slither: {
// Slither accepts a positional target, but a bare run prints human text; `--json -` streams the
// findings as JSON to stdout. Directory-oriented, so it may default to the working dir.
targetParam: 'path',
defaultTimeoutMs: 300_000,
build: (target, params) => [scanPath(target, params), '--json', '-'],
},
mobsfscan: {
// Directory-oriented static scanner; `--json` gives a structured channel over the default text.
targetParam: 'path',
defaultTimeoutMs: 180_000,
build: (target, params) => ['--json', scanPath(target, params)],
},
};
/** Fallback for any mintable adapter without a bespoke template: pass the target as a positional arg. */
@@ -233,6 +372,16 @@ function resolveTemplate(adapter: ToolAdapter): ArgTemplate {
return ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id] ?? DEFAULT_TEMPLATE;
}
/**
* True when the adapter has a bespoke `ARG_TEMPLATE` (a real, hardcoded invocation) rather than
* falling through to `DEFAULT_TEMPLATE`'s bare `<binary> <target>`. Exported so the invocation-honesty
* guard test can assert that every mintable adapter's invocation correctness is explicitly classified
* — and that a newly-catalogued subcommand tool cannot silently ship a broken positional invocation.
*/
export function hasArgTemplate(adapter: ToolAdapter): boolean {
return Boolean(ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id]);
}
/**
* Resolve the target string for an adapter call: the template's preferred param first, then the other
* common target keys, then `context.target.address`. Returns undefined if none is present.
@@ -322,7 +471,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 {
+311 -7
View File
@@ -179,7 +179,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['jsonl', 'text'],
installHint: 'brew install projectdiscovery/tap/httpx',
commandHint: 'httpx -u https://example.com -status-code -title -tech-detect -json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'HTTP probing and technology detection for discovered assets.',
},
{
@@ -211,7 +211,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['jsonl', 'text'],
installHint: 'brew install projectdiscovery/tap/katana',
commandHint: 'katana -u https://example.com -jsonl -silent',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Crawler for endpoint discovery and attack-surface mapping.',
},
{
@@ -307,7 +307,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'text'],
installHint: 'brew install dalfox',
commandHint: 'dalfox url https://example.com?q=test -o result.json --format json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'XSS validation adapter for scoped endpoints.',
},
{
@@ -339,7 +339,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'sarif'],
installHint: 'brew install semgrep or pipx install semgrep',
commandHint: 'semgrep scan --config auto --json .',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Static analysis backbone for repository hardening.',
},
{
@@ -355,7 +355,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'sarif'],
installHint: 'brew install gitleaks',
commandHint: 'gitleaks detect --source . --report-format json --redact',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Secret detection must redact values before ledger insertion.',
},
{
@@ -387,7 +387,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json', 'sarif'],
installHint: 'brew install aquasecurity/trivy/trivy',
commandHint: 'trivy fs --format json .',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Vulnerability and IaC scanner for repos and images.',
},
{
@@ -419,7 +419,7 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
outputFormats: ['json'],
installHint: 'brew install grype',
commandHint: 'grype dir:. -o json',
parserStatus: 'planned',
parserStatus: 'structured',
notes: 'Pairs well with Syft SBOMs for dependency vulnerability triage.',
},
{
@@ -470,6 +470,118 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
parserStatus: 'planned',
notes: 'Cloud account assessment requires explicit account scope and credentials handling.',
},
{
id: 'scoutsuite',
binary: 'scout',
name: 'ScoutSuite',
category: 'cloud',
families: CLOUD_FAMILIES,
risk: 'active',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['cloud_control_finding', 'iam_misconfig'],
outputFormats: ['json', 'html'],
installHint: 'pipx install scoutsuite',
commandHint: 'scout aws --report-dir ./scout (read-only posture; needs account creds + explicit scope)',
parserStatus: 'planned',
notes: 'Multi-cloud (AWS/Azure/GCP) posture assessment; read-only API reads under authorized credentials + account scope.',
},
{
id: 'cloudfox',
binary: 'cloudfox',
name: 'CloudFox',
category: 'cloud',
families: CLOUD_FAMILIES,
risk: 'active',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['cloud_attack_path', 'iam_misconfig'],
outputFormats: ['json', 'text'],
installHint: 'brew install cloudfox',
commandHint: 'cloudfox aws all-checks (enumerates exploitable paths; needs creds + scope)',
parserStatus: 'planned',
notes: 'Offensive cloud enumeration — surfaces IAM/privesc/attack paths across AWS/Azure/GCP via read-only API calls under authorized credentials.',
},
{
id: 'pmapper',
binary: 'pmapper',
name: 'PMapper',
category: 'cloud',
families: CLOUD_FAMILIES,
risk: 'active',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['iam_privesc_path'],
outputFormats: ['json', 'dot'],
installHint: 'pipx install principalmapper',
commandHint: 'pmapper graph create && pmapper query "preset privesc *" (read-only IAM introspection; needs AWS creds + scope)',
parserStatus: 'planned',
notes: 'Maps AWS IAM privilege-escalation paths from read-only IAM introspection under authorized credentials.',
},
{
id: 'pacu',
binary: 'pacu',
name: 'Pacu',
category: 'cloud',
families: CLOUD_FAMILIES,
risk: 'dangerous',
execution: 'catalog_only',
networked: true,
evidenceKinds: ['exploit_validation'],
outputFormats: ['session_export'],
installHint: 'pipx install pacu',
commandHint: 'Catalog-only until a narrow per-module runner and receipt gate exist.',
parserStatus: 'planned',
notes: 'AWS exploitation framework (privesc, persistence, exfil). Do not expose through generic command execution; needs per-module adapters + explicit approval — same gating as metasploit.',
},
{
id: 'aws-cli',
binary: 'aws',
name: 'AWS CLI',
category: 'cloud',
families: CLOUD_FAMILIES,
risk: 'active',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['cloud_enumeration', 'iam_state', 'resource_inventory'],
outputFormats: ['json', 'text'],
installHint: 'brew install awscli',
commandHint: 'aws sts get-caller-identity / aws iam list-users / aws s3 ls (read enum; needs creds + scope)',
parserStatus: 'planned',
notes: 'Base AWS enumeration primitive. Read-only list/describe API calls under authorized credentials + account scope.',
},
{
id: 'az-cli',
binary: 'az',
name: 'Azure CLI',
category: 'cloud',
families: CLOUD_FAMILIES,
risk: 'active',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['cloud_enumeration', 'iam_state'],
outputFormats: ['json'],
installHint: 'brew install azure-cli',
commandHint: 'az account show / az role assignment list / az storage account list (read enum; needs creds + scope)',
parserStatus: 'planned',
notes: 'Base Azure enumeration primitive. Read-only under authorized credentials + subscription scope.',
},
{
id: 'gcloud-cli',
binary: 'gcloud',
name: 'gcloud CLI',
category: 'cloud',
families: CLOUD_FAMILIES,
risk: 'active',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['cloud_enumeration', 'iam_state'],
outputFormats: ['json', 'text'],
installHint: 'brew install --cask google-cloud-sdk',
commandHint: 'gcloud projects list / gcloud iam roles list / gcloud storage ls (read enum; needs auth + scope)',
parserStatus: 'planned',
notes: 'Base GCP enumeration primitive. Read-only under authorized credentials + project scope.',
},
{
id: 'garak',
binary: 'garak',
@@ -694,6 +806,102 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
parserStatus: 'planned',
notes: 'Scriptable local reverse-engineering adapter for artifacts and firmware.',
},
{
id: 'ghidra',
binary: 'analyzeHeadless',
name: 'Ghidra (headless)',
category: 'reverse',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['decompiled_pseudocode', 'function_graph', 'binary_analysis'],
outputFormats: ['text', 'xml'],
installHint: 'brew install ghidra # provides analyzeHeadless',
commandHint: 'analyzeHeadless <proj> tmp -import artifact.bin -postScript DecompileScript.java',
parserStatus: 'planned',
notes: 'Headless decompiler — static pseudocode reconstruction from an owned/lab binary. Read-only, no execution.',
},
{
id: 'objdump',
binary: 'objdump',
name: 'objdump',
category: 'reverse',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['disassembly', 'symbol_table', 'section_headers'],
outputFormats: ['text'],
installHint: 'binutils (brew install binutils)',
commandHint: 'objdump -d -M intel artifact.bin',
parserStatus: 'text',
notes: 'Static disassembly + section/symbol inspection of an owned binary. Read-only.',
},
{
id: 'gdb',
binary: 'gdb',
name: 'GDB',
category: 'reverse',
families: REVERSE_FAMILIES,
risk: 'active',
execution: 'receipt_required',
networked: false,
evidenceKinds: ['runtime_state', 'crash_analysis'],
outputFormats: ['text'],
installHint: 'brew install gdb',
commandHint: 'gdb -batch -ex run -ex bt --args ./artifact (EXECUTES the target — owned/lab binary + approval)',
parserStatus: 'text',
notes: 'Debugger — runs/attaches to a target binary. Executes code, so gated: owned/lab binary + explicit approval.',
},
{
id: 'checksec',
binary: 'checksec',
name: 'checksec',
category: 'reverse',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['binary_protections'],
outputFormats: ['json', 'text'],
installHint: 'brew install checksec',
commandHint: 'checksec --file=./artifact',
parserStatus: 'text',
notes: 'Static triage of binary hardening (NX, PIE, stack canary, RELRO, Fortify). Read-only — the first RE step.',
},
{
id: 'strings',
binary: 'strings',
name: 'strings',
category: 'reverse',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['string_reference'],
outputFormats: ['text'],
installHint: 'binutils (preinstalled on most systems)',
commandHint: 'strings -n 6 artifact.bin',
parserStatus: 'text',
notes: 'Extract printable strings for triage (hardcoded creds, URLs, format specifiers). Read-only.',
},
{
id: 'readelf',
binary: 'readelf',
name: 'readelf',
category: 'reverse',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['elf_headers', 'symbol_table'],
outputFormats: ['text'],
installHint: 'binutils (brew install binutils)',
commandHint: 'readelf -a artifact.bin',
parserStatus: 'text',
notes: 'ELF header/section/symbol/dynamic inspection. Read-only, complements objdump.',
},
{
id: 'apktool',
binary: 'apktool',
@@ -726,6 +934,102 @@ export const TOOL_ADAPTERS: ToolAdapter[] = [
parserStatus: 'planned',
notes: 'Android decompilation for scoped local APK review.',
},
{
id: 'apkleaks',
binary: 'apkleaks',
name: 'APKLeaks',
category: 'mobile',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['mobile_secret', 'exposed_endpoint'],
outputFormats: ['json', 'text'],
installHint: 'pipx install apkleaks',
commandHint: 'apkleaks -f app.apk -o out.json',
parserStatus: 'planned',
notes: 'Static scan of an APK for hardcoded secrets, URIs, and endpoints. Local read-only.',
},
{
id: 'mobsfscan',
binary: 'mobsfscan',
name: 'mobsfscan',
category: 'mobile',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['mobile_static_finding', 'insecure_api_usage'],
outputFormats: ['json', 'sarif'],
installHint: 'pipx install mobsfscan',
commandHint: 'mobsfscan --json <app-source-or-decompiled-dir>',
parserStatus: 'planned',
notes: 'Static analyzer for Android/iOS source + manifests (insecure API usage, weak crypto, hardcoded secrets). Local read-only. (The full MobSF framework is a separate server, not a PATH binary.)',
},
{
id: 'objection',
binary: 'objection',
name: 'Objection',
category: 'mobile',
families: REVERSE_FAMILIES,
risk: 'intrusive',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['runtime_hook', 'mobile_dynamic_finding'],
outputFormats: ['text'],
installHint: 'pipx install objection',
commandHint: 'objection -g <app> explore (Frida-based runtime; needs an owned/rooted device + approval)',
parserStatus: 'planned',
notes: 'Runtime mobile exploration built on Frida (SSL-pinning/root-detection bypass, keychain dump). Requires an owned device + explicit approval.',
},
{
id: 'frida',
binary: 'frida',
name: 'Frida',
category: 'mobile',
families: REVERSE_FAMILIES,
risk: 'dangerous',
execution: 'catalog_only',
networked: true,
evidenceKinds: ['runtime_hook'],
outputFormats: ['script_output'],
installHint: 'pipx install frida-tools',
commandHint: 'Catalog-only: runtime code injection into a live process; needs a per-script receipt + a device you own.',
parserStatus: 'planned',
notes: 'Dynamic instrumentation toolkit — injects into running processes. Do not expose through generic execution; needs explicit approval + an owned device — same gating as metasploit.',
},
{
id: 'drozer',
binary: 'drozer',
name: 'drozer',
category: 'mobile',
families: REVERSE_FAMILIES,
risk: 'intrusive',
execution: 'receipt_required',
networked: true,
evidenceKinds: ['mobile_attack_surface', 'ipc_exposure'],
outputFormats: ['text'],
installHint: 'pipx install drozer',
commandHint: 'drozer console connect (probes exported IPC/content-providers; needs an owned device + agent + approval)',
parserStatus: 'text',
notes: 'Android attack-surface + IPC/content-provider testing via an on-device agent. Owned device + explicit approval.',
},
{
id: 'class-dump',
binary: 'class-dump',
name: 'class-dump',
category: 'mobile',
families: REVERSE_FAMILIES,
risk: 'local_read',
execution: 'safe_command',
networked: false,
evidenceKinds: ['ios_class_headers', 'decompiled_source'],
outputFormats: ['text'],
installHint: 'brew install class-dump',
commandHint: 'class-dump -H App.app/App -o headers/',
parserStatus: 'text',
notes: 'iOS static — dumps Objective-C class/method headers from a Mach-O binary for scoped review. Read-only.',
},
{
id: 'exiftool',
binary: 'exiftool',
+186 -37
View File
@@ -7,6 +7,9 @@
import { EventEmitter } from 'eventemitter3';
import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import * as net from 'net';
import * as dns from 'dns';
@@ -61,6 +64,115 @@ async function checkPort(host: string, port: number, timeout: number = 2000): Pr
});
}
// =============================================================================
// TARGET HEADER INJECTION
// =============================================================================
interface TargetHeaderConfig {
origin: string;
headers: Headers;
}
const FORBIDDEN_TARGET_HEADERS = new Set([
'connection', 'content-length', 'host', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);
/** Parse an exact-origin binding and its default headers. Invalid configuration fails closed. */
function parseTargetHeaderConfig(): TargetHeaderConfig | null {
const rawOrigin = process.env.TEMPEST_TARGET_ORIGIN?.trim();
const rawHeaders = process.env.TEMPEST_TARGET_HEADERS?.trim();
if (!rawOrigin || !rawHeaders) return null;
try {
const target = new URL(rawOrigin);
if (!['http:', 'https:'].includes(target.protocol)
|| target.username || target.password
|| target.pathname !== '/' || target.search || target.hash) {
return null;
}
const parsed: unknown = JSON.parse(rawHeaders);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const headers = new Headers();
for (const [name, value] of Object.entries(parsed)) {
if (typeof value !== 'string' || FORBIDDEN_TARGET_HEADERS.has(name.toLowerCase())) return null;
headers.set(name, value);
}
return headers.keys().next().done ? null : { origin: target.origin, headers };
} catch {
return null;
}
}
function targetHeadersForUrl(url: string | URL, explicit?: RequestInit['headers']): Headers | undefined {
const merged = new Headers();
const config = parseTargetHeaderConfig();
try {
if (config && new URL(url).origin === config.origin) {
config.headers.forEach((value, name) => merged.set(name, value));
}
} catch {
// Let fetch or curl report malformed URLs; they simply receive no configured secrets.
}
new Headers(explicit).forEach((value, name) => merged.set(name, value));
return merged.keys().next().done ? undefined : merged;
}
function redactConfiguredSecrets(result: ToolResult): ToolResult {
const config = parseTargetHeaderConfig();
const secrets = config ? [...config.headers.values()].filter(Boolean) : [];
if (secrets.length === 0) return result;
const redact = (value: unknown): unknown => {
if (typeof value === 'string') {
return secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), value);
}
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, redact(nested)]));
}
return value;
};
return redact(result) as ToolResult;
}
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const CROSS_ORIGIN_CREDENTIAL_HEADERS = ['authorization', 'cookie', 'cookie2', 'proxy-authorization'];
/** Apply exact-origin headers to every built-in fetch without leaking them across redirects. */
async function targetFetch(url: string | URL, init: RequestInit = {}): Promise<Response> {
const config = parseTargetHeaderConfig();
let currentUrl = new URL(url);
if (!config || currentUrl.origin !== config.origin) return globalThis.fetch(url, init);
const explicitHeaders = new Headers(init.headers);
let method = (init.method || 'GET').toUpperCase();
let body = init.body;
for (let redirects = 0; redirects <= 20; redirects++) {
const headers = targetHeadersForUrl(currentUrl, explicitHeaders);
const response = await globalThis.fetch(currentUrl, { ...init, method, body, headers, redirect: 'manual' });
const location = response.headers.get('location');
if (!REDIRECT_STATUS.has(response.status) || !location) return response;
if (redirects === 20) throw new Error('Target request exceeded 20 redirects');
const nextUrl = new URL(location, currentUrl);
if (nextUrl.origin !== currentUrl.origin) {
config.headers.forEach((_value, name) => explicitHeaders.delete(name));
CROSS_ORIGIN_CREDENTIAL_HEADERS.forEach(name => explicitHeaders.delete(name));
}
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
method = 'GET';
body = undefined;
['content-encoding', 'content-language', 'content-location', 'content-type'].forEach(name => explicitHeaders.delete(name));
}
currentUrl = nextUrl;
}
throw new Error('Target request redirect handling failed');
}
// =============================================================================
// EVENTS
// =============================================================================
@@ -301,7 +413,7 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
const startTime = Date.now();
try {
const result = await tool.handler(context);
const result = redactConfiguredSecrets(await tool.handler(context));
const durationMs = Date.now() - startTime;
execution.completedAt = Date.now();
@@ -312,7 +424,10 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
return result;
} catch (error) {
execution.completedAt = Date.now();
execution.error = error instanceof Error ? error.message : String(error);
execution.error = redactConfiguredSecrets({
success: false,
error: error instanceof Error ? error.message : String(error),
}).error;
this.emit('tool:error', { tool, error: error as Error });
@@ -748,11 +863,11 @@ export const BUILTIN_TOOLS: CustomTool[] = [
handler: async (context) => {
const url = context.parameters.url as string;
const method = (context.parameters.method as string) || 'GET';
const headers = (context.parameters.headers as Record<string, string>) || {};
const headers = context.parameters.headers as Record<string, string> | undefined;
const body = context.parameters.body as string | undefined;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method,
headers,
body: body && method !== 'GET' ? body : undefined,
@@ -787,7 +902,10 @@ export const BUILTIN_TOOLS: CustomTool[] = [
'Permissions-Policy', 'Cross-Origin-Opener-Policy', 'Cross-Origin-Resource-Policy',
];
try {
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
const response = await targetFetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(10000),
});
const analysis = securityHeaders.map(h => {
const value = response.headers.get(h);
return `${h}: ${value ? `✓ ${value}` : '✗ Missing'}`;
@@ -834,7 +952,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
batch.map(async (path) => {
const fullUrl = `${baseUrl}/${path}`;
try {
const response = await fetch(fullUrl, {
const response = await targetFetch(fullUrl, {
method: 'GET',
signal: AbortSignal.timeout(timeout),
redirect: 'manual',
@@ -895,7 +1013,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
});
@@ -1022,7 +1140,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -1099,7 +1217,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
try {
const baselineUrl = new URL(baseUrl);
baselineUrl.searchParams.set(param, 'normalvalue');
const baselineResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baselineResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baselineBody = await baselineResp.text();
baselineLength = baselineBody.length;
} catch {
@@ -1111,7 +1229,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
const response = await targetFetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
const body = await response.text();
const bodyLower = body.toLowerCase();
@@ -1321,7 +1439,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
baselineBody.set(usernameField, username);
baselineBody.set(passwordField, 'definitely_invalid_password_xyz123!@#');
const baselineResp = await fetch(url, {
const baselineResp = await targetFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: baselineBody.toString(),
@@ -1345,7 +1463,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
body.set(usernameField, username);
body.set(passwordField, password);
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
@@ -1605,7 +1723,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
const robotsUrl = `${baseUrl}/robots.txt`;
const response = await fetch(robotsUrl, {
const response = await targetFetch(robotsUrl, {
signal: AbortSignal.timeout(10000),
});
@@ -1766,7 +1884,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
// Fetch main page
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
});
const headers = Object.fromEntries(response.headers.entries());
@@ -1840,7 +1958,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const probePromises = versionPaths.map(async (vp) => {
try {
const probeResp = await fetch(`${baseUrl}${vp.path}`, {
const probeResp = await targetFetch(`${baseUrl}${vp.path}`, {
signal: AbortSignal.timeout(5000),
redirect: 'manual',
});
@@ -1994,7 +2112,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
signal: AbortSignal.timeout(10000),
});
@@ -2151,7 +2269,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
batch.map(async (path) => {
try {
const fullUrl = `${baseUrl}${path}`;
const resp = await fetch(fullUrl, {
const resp = await targetFetch(fullUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000),
redirect: 'manual',
@@ -2231,7 +2349,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
// First try OPTIONS to see if Allow header is returned
let optionsAllow: string | null = null;
try {
const optResp = await fetch(url, {
const optResp = await targetFetch(url, {
method: 'OPTIONS',
signal: AbortSignal.timeout(5000),
});
@@ -2253,7 +2371,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
}
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method,
signal: AbortSignal.timeout(5000),
redirect: 'manual',
@@ -2332,7 +2450,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
for (const test of testOrigins) {
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
headers: { 'Origin': test.origin },
signal: AbortSignal.timeout(5000),
@@ -2403,7 +2521,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
redirect: 'manual', // Don't follow redirects to capture Set-Cookie
});
@@ -2521,7 +2639,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
redirect: 'manual', // Don't follow redirects
signal: AbortSignal.timeout(5000),
});
@@ -2613,7 +2731,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
const baselineUrl = new URL(baseUrl);
baselineUrl.searchParams.set(param, 'nonexistent_file_xyz');
const baseResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baseResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baseBody = await baseResp.text();
baselineLength = baseBody.length;
} catch {
@@ -2625,7 +2743,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -2727,7 +2845,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -2803,7 +2921,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
signal: AbortSignal.timeout(10000),
});
@@ -3102,6 +3220,10 @@ function sanitizeExternalFlags(tool: 'curl' | 'nmap', raw?: string): string[] {
return out;
}
function withoutCurlRedirectFlags(flags: string[]): string[] {
return flags.filter(flag => !/^--location(?:-trusted)?(?:=|$)/i.test(flag) && !/^-[^-]*L/.test(flag));
}
export const EXTERNAL_TOOLS: CustomTool[] = [
{
name: 'nmap_scan',
@@ -3249,6 +3371,8 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
const flags = context.parameters.flags as string | undefined;
const args = ['-s', '-i', '-X', method];
let configuredHeaders = targetHeadersForUrl(url);
let configDir: string | undefined;
if (data) {
// A body starting with @ (read local file) or < (read stdin) turns curl into a local-file
// disclosure primitive. --data-raw sends it verbatim and disables @/< interpretation.
@@ -3256,19 +3380,44 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
args.push('--data-raw', data);
}
if (headers) {
for (const h of headers.split(',')) {
args.push('-H', h.trim());
const headerLines = headers.split(',').map(header => header.trim()).filter(Boolean);
if (configuredHeaders) {
const explicitHeaders = new Headers();
for (const header of headerLines) {
const separator = header.indexOf(':');
if (separator <= 0) return { success: false, error: `curl_request: invalid header '${header}'` };
explicitHeaders.set(header.slice(0, separator).trim(), header.slice(separator + 1).trim());
}
configuredHeaders = targetHeadersForUrl(url, explicitHeaders);
} else {
for (const header of headerLines) args.push('-H', header);
}
}
if (flags) args.push(...sanitizeExternalFlags('curl', flags)); // drop -o/-K/-T/-x file-read/write injection
args.push(url);
const result = await runSubprocess('curl', args, { timeout: 30000 });
return {
success: result.exitCode === 0,
output: result.stdout,
error: result.exitCode !== 0 ? result.stderr : undefined,
};
if (flags) {
const sanitized = sanitizeExternalFlags('curl', flags); // drop -o/-K/-T/-x file-read/write injection
args.push(...(configuredHeaders ? withoutCurlRedirectFlags(sanitized) : sanitized));
}
try {
if (configuredHeaders) {
configDir = await mkdtemp(join(tmpdir(), 't3mp3st-curl-'));
const configPath = join(configDir, 'headers.conf');
const escapeConfig = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const config = [...configuredHeaders.entries()]
.map(([name, value]) => `header = "${escapeConfig(`${name}: ${value}`)}"`)
.join('\n');
await writeFile(configPath, `${config}\n`, { mode: 0o600 });
args.push('--config', configPath);
}
args.push(url);
const result = await runSubprocess('curl', args, { timeout: 30000 });
return redactConfiguredSecrets({
success: result.exitCode === 0,
output: result.stdout,
error: result.exitCode !== 0 ? result.stderr : undefined,
});
} finally {
if (configDir) await rm(configDir, { recursive: true, force: true });
}
},
},
];
+329
View File
@@ -0,0 +1,329 @@
/**
* T3MP3ST Arsenal — tool-output → structured evidence parsers.
*
* Turns the raw stdout of the JSON-emitting scanners into `ToolFinding[]` so the generic
* adapter factory (src/arsenal/adapter-tools.ts) can return STRUCTURED findings, not just
* text. Downstream the pipe already exists and is honest-by-construction: the agent loop
* stamps `provenance:'tool'` + the raw output onto each finding, the operator materialises
* it into a `Finding` with output-evidence, and the live gate (src/evidence/gate.ts) can
* then mark it verified. Until now these adapters (parserStatus:'planned') returned raw
* stdout only — the structured channel (`ToolResult.findings`) was never populated.
*
* HONESTY CONTRACT (same discipline as stub-honesty / no-phantom-tools):
* - Pure, dependency-free, and NEVER throws. Empty / garbled output → `[]`, never a
* fabricated finding. The caller keeps the raw stdout as the evidence of record; a
* parser SUMMARISES real output, it does not invent it.
* - Parsers do NOT set provenance/toolName/toolOutput — the agent loop stamps those from
* the real subprocess result, so a parser can never forge provenance.
*/
import type { Severity, ToolFinding } from '../types/index.js';
import { redactString } from '../redact.js';
// ── small, defensive helpers ────────────────────────────────────────────────
const SEVERITIES = new Set<Severity>(['critical', 'high', 'medium', 'low', 'info']);
// Map the many scanner severity vocabularies (semgrep ERROR/WARNING/INFO, trivy/grype
// CRITICAL…NEGLIGIBLE, npm-audit "moderate", …) onto the five canonical levels.
const SEVERITY_ALIAS: Record<string, Severity> = {
error: 'high', warning: 'medium', warn: 'medium', inventory: 'info', experiment: 'info',
moderate: 'medium', negligible: 'info', unknown: 'info', none: 'info', informational: 'info',
};
const sev = (v: unknown, fallback: Severity = 'info'): Severity => {
const s = String(v ?? '').trim().toLowerCase();
if (SEVERITIES.has(s as Severity)) return s as Severity;
return SEVERITY_ALIAS[s] ?? fallback;
};
const asObj = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
const asStrArray = (v: unknown): string[] =>
Array.isArray(v)
? v.map((x) => String(x)).filter((s) => s.trim().length > 0)
: v !== null && v !== undefined && v !== '' ? [String(v)] : [];
const num = (v: unknown): number | undefined => {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : undefined;
};
const truncate = (s: string, n = 400): string => (s.length > n ? `${s.slice(0, n)}…` : s);
/** Parse JSONL defensively: one object per non-empty line; any non-JSON line is skipped. */
function jsonl(raw: string): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (const line of String(raw).split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const parsed: unknown = JSON.parse(t);
if (parsed && typeof parsed === 'object') out.push(parsed as Record<string, unknown>);
} catch {
/* not a JSON line (banner, log, truncated tail) — skip */
}
}
return out;
}
/** Parse a single JSON document defensively; returns undefined on any error. */
function jsonDoc(raw: string): unknown {
try {
return JSON.parse(String(raw));
} catch {
return undefined;
}
}
// ── nuclei -jsonl : one finding per template match ───────────────────────────
function parseNuclei(raw: string): ToolFinding[] {
const out: ToolFinding[] = [];
for (const e of jsonl(raw)) {
const info = asObj(e.info);
const cls = asObj(info.classification);
const templateId = String(e['template-id'] ?? e.templateID ?? '');
const title = String(info.name || templateId || 'nuclei match');
const bits = [
e.host && `host: ${String(e.host)}`,
e['matched-at'] && `matched-at: ${String(e['matched-at'])}`,
info.description && `desc: ${truncate(String(info.description))}`,
].filter(Boolean) as string[];
const f: ToolFinding = { title, severity: sev(info.severity), details: bits.join(' | ') || title };
const cvss = num(cls['cvss-score']);
if (cvss !== undefined) f.cvss = cvss;
const cve = asStrArray(cls['cve-id']);
if (cve.length) f.cve = cve;
const cwe = asStrArray(cls['cwe-id']);
if (cwe.length) f.cwe = cwe;
if (info.remediation) f.remediation = String(info.remediation);
out.push(f);
}
return out;
}
// ── httpx -json : one info finding per live host (technology fingerprint) ─────
function parseHttpx(raw: string): ToolFinding[] {
const out: ToolFinding[] = [];
for (const e of jsonl(raw)) {
const url = String(e.url ?? e.input ?? e.host ?? '');
if (!url) continue;
const status = e.status_code ?? e['status-code'];
const title = String(e.title ?? '');
const tech = asStrArray(e.tech ?? e.technologies);
const server = String(e.webserver ?? e.server ?? '');
const bits = [
`url: ${url}`,
status !== null && status !== undefined && `status: ${String(status)}`,
title && `title: ${truncate(title, 120)}`,
server && `server: ${server}`,
tech.length && `tech: ${tech.join(', ')}`,
].filter(Boolean) as string[];
out.push({ title: `HTTP service ${url}`, severity: 'info', details: bits.join(' | ') });
}
return out;
}
// ── dalfox --format json : one finding per XSS probe ─────────────────────────
function parseDalfox(raw: string): ToolFinding[] {
const doc = jsonDoc(raw);
const arr: unknown[] = Array.isArray(doc)
? doc
: Array.isArray(asObj(doc).pocs) ? (asObj(doc).pocs as unknown[]) : [];
const out: ToolFinding[] = [];
for (const item of arr) {
const e = asObj(item);
const param = String(e.param ?? '');
const kind = String(e.type ?? e.inject_type ?? 'XSS');
const bits = [
e.method && `method: ${String(e.method)}`,
param && `param: ${param}`,
e.data && `data: ${truncate(String(e.data), 200)}`,
e.evidence && `evidence: ${truncate(String(e.evidence), 200)}`,
e.poc && `poc: ${truncate(String(e.poc), 300)}`,
].filter(Boolean) as string[];
const f: ToolFinding = {
title: `dalfox ${kind}${param ? ` @ ${param}` : ''}`,
severity: sev(e.severity, 'medium'),
details: bits.join(' | ') || String(e.message_str ?? e.message ?? 'XSS probe'),
};
const cwe = asStrArray(e.cwe).map((c) => (/^CWE-/i.test(c) ? c.toUpperCase() : `CWE-${c}`));
if (cwe.length) f.cwe = cwe;
out.push(f);
}
return out;
}
// ── ffuf -of json : one aggregate info finding (N paths) ─────────────────────
function parseFfuf(raw: string): ToolFinding[] {
const results = ((): unknown[] => {
const r = asObj(jsonDoc(raw)).results;
return Array.isArray(r) ? r : [];
})();
if (!results.length) return [];
const lines = results.slice(0, 50).map((r) => {
const e = asObj(r);
const path = String(asObj(e.input).FUZZ ?? e.input ?? e.url ?? '');
return `${path} (status ${String(e.status ?? '?')}, len ${String(e.length ?? '?')})`;
});
const more = results.length > 50 ? `\n… +${results.length - 50} more` : '';
return [{
title: `ffuf: ${results.length} path(s) discovered`,
severity: 'info',
details: lines.join('\n') + more,
}];
}
// ── katana -jsonl : one aggregate info finding (N endpoints) ──────────────────
function parseKatana(raw: string): ToolFinding[] {
const endpoints: string[] = [];
for (const e of jsonl(raw)) {
const ep = String(asObj(e.request).endpoint ?? e.endpoint ?? e.url ?? '');
if (ep) endpoints.push(ep);
}
if (!endpoints.length) return [];
const more = endpoints.length > 50 ? `\n… +${endpoints.length - 50} more` : '';
return [{
title: `katana: ${endpoints.length} endpoint(s) discovered`,
severity: 'info',
details: endpoints.slice(0, 50).join('\n') + more,
}];
}
// ── semgrep scan --json : one finding per rule match (SAST) ──────────────────
function parseSemgrep(raw: string): ToolFinding[] {
const results = ((): unknown[] => {
const r = asObj(jsonDoc(raw)).results;
return Array.isArray(r) ? r : [];
})();
const out: ToolFinding[] = [];
for (const item of results) {
const e = asObj(item);
const extra = asObj(e.extra);
const meta = asObj(extra.metadata);
const checkId = String(e.check_id ?? e.checkId ?? 'semgrep rule');
const path = String(e.path ?? '');
const line = num(asObj(e.start).line);
const where = path ? `${path}${line !== undefined ? `:${line}` : ''}` : '';
const f: ToolFinding = {
title: checkId,
severity: sev(extra.severity),
details: [where && `at ${where}`, extra.message && truncate(String(extra.message))].filter(Boolean).join(' | ') || checkId,
};
// semgrep CWE metadata is like ["CWE-89: SQL Injection"] — keep the CWE-NNN token.
const cwe = asStrArray(meta.cwe).map((c) => c.match(/CWE-\d+/i)?.[0] ?? '').filter(Boolean);
if (cwe.length) f.cwe = cwe.map((c) => c.toUpperCase());
const refs = asStrArray(meta.references);
if (refs.length) f.remediation = `refs: ${refs.slice(0, 3).join(', ')}`;
out.push(f);
}
return out;
}
// ── gitleaks detect --report-format json --redact : one finding per secret ────
function parseGitleaks(raw: string): ToolFinding[] {
const arr: unknown[] = Array.isArray(jsonDoc(raw)) ? (jsonDoc(raw) as unknown[]) : [];
const out: ToolFinding[] = [];
for (const item of arr) {
const e = asObj(item);
const rule = String(e.RuleID ?? e.Description ?? 'secret');
const file = String(e.File ?? '');
const line = num(e.StartLine);
const where = file ? `${file}${line !== undefined ? `:${line}` : ''}` : '';
// Defense in depth: the adapter already runs gitleaks with --redact, but scrub the details
// through redactString anyway so a raw secret can NEVER reach a finding or the evidence vault.
const details = redactString([where && `at ${where}`, String(e.Description ?? '')].filter(Boolean).join(' | '));
out.push({ title: `secret: ${rule}`, severity: 'high', details: details || `secret: ${rule}` });
}
return out;
}
// ── trivy fs --format json : one finding per package vulnerability ────────────
function parseTrivy(raw: string): ToolFinding[] {
const results = ((): unknown[] => {
const r = asObj(jsonDoc(raw)).Results;
return Array.isArray(r) ? r : [];
})();
const out: ToolFinding[] = [];
for (const res of results) {
const target = String(asObj(res).Target ?? '');
const vulns = asObj(res).Vulnerabilities;
if (!Array.isArray(vulns)) continue;
for (const item of vulns) {
const v = asObj(item);
const id = String(v.VulnerabilityID ?? '');
const pkg = String(v.PkgName ?? '');
const installed = String(v.InstalledVersion ?? '');
const fixed = String(v.FixedVersion ?? '');
const f: ToolFinding = {
title: String(v.Title || id || 'vulnerability'),
severity: sev(v.Severity),
details: [target && `target: ${target}`, pkg && `pkg: ${pkg}${installed ? ` ${installed}` : ''}`,
v.Description && truncate(String(v.Description), 200)].filter(Boolean).join(' | ') || id,
};
if (/^CVE-/i.test(id)) f.cve = [id];
const cwe = asStrArray(v.CweIDs);
if (cwe.length) f.cwe = cwe;
if (fixed) f.remediation = `upgrade ${pkg || 'package'} to ${fixed}`;
out.push(f);
}
}
return out;
}
// ── grype dir:. -o json : one finding per matched vulnerability ───────────────
function parseGrype(raw: string): ToolFinding[] {
const matches = ((): unknown[] => {
const m = asObj(jsonDoc(raw)).matches;
return Array.isArray(m) ? m : [];
})();
const out: ToolFinding[] = [];
for (const item of matches) {
const m = asObj(item);
const vuln = asObj(m.vulnerability);
const art = asObj(m.artifact);
const id = String(vuln.id ?? '');
const name = String(art.name ?? '');
const version = String(art.version ?? '');
const f: ToolFinding = {
title: id || 'vulnerability',
severity: sev(vuln.severity),
details: [name && `pkg: ${name}${version ? ` ${version}` : ''}`,
vuln.description && truncate(String(vuln.description), 200)].filter(Boolean).join(' | ') || id,
};
if (/^CVE-/i.test(id)) f.cve = [id];
out.push(f);
}
return out;
}
const PARSERS: Record<string, (raw: string) => ToolFinding[]> = {
nuclei: parseNuclei,
httpx: parseHttpx,
dalfox: parseDalfox,
ffuf: parseFfuf,
katana: parseKatana,
semgrep: parseSemgrep,
gitleaks: parseGitleaks,
trivy: parseTrivy,
grype: parseGrype,
};
/** Adapter ids that have a structured output parser wired here. */
export const PARSED_TOOL_IDS: readonly string[] = Object.keys(PARSERS);
/** True iff a structured parser is wired for this adapter id. */
export function hasParser(toolId: string): boolean {
return Object.prototype.hasOwnProperty.call(PARSERS, toolId);
}
/**
* Parse a tool's raw stdout into structured `ToolFinding[]`.
*
* Returns `[]` for any tool with no parser, for empty output, and for output a parser cannot
* make sense of. NEVER throws — an unexpected parser failure degrades to `[]` so a malformed
* scan can never crash the agent loop or fabricate a finding.
*/
export function parseToolOutput(toolId: string, rawOutput: string): ToolFinding[] {
const parser = PARSERS[toolId];
if (!parser || !rawOutput || !String(rawOutput).trim()) return [];
try {
return parser(rawOutput);
} catch {
return [];
}
}
+134 -15
View File
@@ -11,6 +11,8 @@ import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import type { LLMProvider, LLMConfig, FallbackEntry, OpsecLevel } from '../types/index.js';
type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'local';
// =============================================================================
// CONFIGURATION SCHEMA
// =============================================================================
@@ -23,7 +25,9 @@ export interface TempestSettings {
anthropic?: string;
openai?: string;
xai?: string;
gemini?: string;
litellm?: string;
local?: string;
};
// Default LLM settings
@@ -68,6 +72,12 @@ export interface TempestSettings {
defaultModel: string;
};
// Google Gemini — native Gemini API via its OpenAI-compatible endpoint
gemini: {
baseUrl: string;
defaultModel: string;
};
// Codex CLI/account subscription backend
codex: {
command: string;
@@ -136,6 +146,14 @@ const DEFAULT_SETTINGS: TempestSettings = {
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.
gemini: {
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
defaultModel: 'gemini-2.5-flash',
},
codex: {
command: 'codex',
defaultModel: 'codex-default',
@@ -444,6 +462,34 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
capabilities: ['reasoning', 'code', 'analysis', 'agents', 'tools'],
},
],
// Native Gemini model ids (bare, no `google/` prefix) for Google's OpenAI-compatible endpoint.
// Distinct from the `google/gemini-*` ids under `openrouter`, which route through OpenRouter.
gemini: [
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash (native)',
provider: 'Google',
contextWindow: 1000000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'],
},
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro (native)',
provider: 'Google',
contextWindow: 1000000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'multimodal', 'tools'],
},
{
id: 'gemini-2.0-flash',
name: 'Gemini 2.0 Flash (native)',
provider: 'Google',
contextWindow: 1000000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'],
},
],
local: [
{
id: 'local-model',
@@ -515,10 +561,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', 'local'];
for (const line of lines) {
const trimmed = line.trim();
@@ -531,9 +581,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;
}
}
@@ -569,7 +628,7 @@ class ConfigManager {
/**
* Set an API key for a provider
*/
setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm', key: string): void {
setApiKey(provider: ApiKeyProvider, key: string): void {
const apiKeys = this.config.get('apiKeys');
apiKeys[provider] = key;
this.config.set('apiKeys', apiKeys);
@@ -578,14 +637,22 @@ class ConfigManager {
/**
* Get an API key for a provider
*/
getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm'): 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.
if (provider === 'local') {
const localKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim();
if (localKey) return localKey;
return this.config.get('apiKeys')[provider];
}
const envVarMap = {
openrouter: 'OPENROUTER_API_KEY',
venice: 'VENICE_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
xai: 'XAI_API_KEY',
gemini: 'GEMINI_API_KEY',
litellm: 'LITELLM_API_KEY',
};
@@ -605,7 +672,7 @@ class ConfigManager {
/**
* Check if a provider has a valid API key configured
*/
hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm'): boolean {
hasApiKey(provider: ApiKeyProvider): boolean {
const key = this.getApiKey(provider);
return !!key && key.length > 10;
}
@@ -613,7 +680,7 @@ class ConfigManager {
/**
* Remove an API key
*/
removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm'): void {
removeApiKey(provider: ApiKeyProvider): void {
const apiKeys = this.config.get('apiKeys');
delete apiKeys[provider];
this.config.set('apiKeys', apiKeys);
@@ -641,6 +708,7 @@ class ConfigManager {
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');
// Codex uses the local Codex CLI/account auth instead of API-key storage.
providers.push('codex');
@@ -655,7 +723,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;
@@ -693,6 +770,12 @@ class ConfigManager {
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');
baseUrl = this.config.get('gemini').baseUrl;
actualModel = model || this.config.get('gemini').defaultModel;
break;
case 'codex':
actualModel = model || this.config.get('codex').defaultModel;
break;
@@ -700,13 +783,17 @@ class ConfigManager {
actualModel = 'mock-model';
break;
case 'local':
// Self-hosted, keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at
// any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp)
// and TEMPEST_LOCAL_MODEL at the model tag you're serving.
// Self-hosted, usually keyless. Defaults to Ollama; point TEMPEST_LOCAL_BASE_URL at
// any OpenAI-compatible server (LM Studio :1234/v1, vLLM :8000/v1, llama.cpp, Zhipu/z.ai
// /api/paas/v4) and TEMPEST_LOCAL_MODEL at the model tag you're serving.
// 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:
throw new Error(`Unknown provider: ${actualProvider}`);
@@ -738,7 +825,7 @@ 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' | 'litellm') => {
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm') => {
if (p === primary) return;
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
chain.push({
@@ -754,6 +841,7 @@ class ConfigManager {
add('anthropic');
add('openai');
add('xai');
add('gemini');
return chain;
}
@@ -777,12 +865,21 @@ class ConfigManager {
case 'openai':
this.config.set('defaultModel', this.config.get('openai').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;
case 'local':
this.config.set('defaultModel', process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3');
break;
}
}
@@ -803,6 +900,12 @@ class ConfigManager {
case 'openai':
this.config.set('openai', { ...this.config.get('openai'), 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;
@@ -844,6 +947,7 @@ class ConfigManager {
anthropic: settings.apiKeys.anthropic ? '***REDACTED***' : undefined,
openai: settings.apiKeys.openai ? '***REDACTED***' : undefined,
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined,
},
};
@@ -883,6 +987,21 @@ XAI_API_KEY=
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=
# Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server)
# Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below).
# For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1,
# vLLM :8000/v1, or e.g. Zhipu/z.ai /api/paas/v4 — any /vN path speaks the
# OpenAI wire (/chat/completions, choices[]); otherwise the Ollama native format is used.
# Most local servers are keyless; if yours requires a bearer (Zhipu, Together, …)
# set TEMPEST_LOCAL_API_KEY (ZAI_API_KEY / ZHIPUAI_API_KEY are accepted as aliases).
TEMPEST_LOCAL_BASE_URL=http://localhost:11434/api
TEMPEST_LOCAL_MODEL=llama3
TEMPEST_LOCAL_API_KEY=
`;
writeFileSync(filePath, template);
}
@@ -895,8 +1014,8 @@ LITELLM_MODEL=gpt-4o
export const config = new ConfigManager();
// Helper functions for quick access
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm') => config.getApiKey(provider);
export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm', key: string) => config.setApiKey(provider, key);
export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm') => 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();
+148 -14
View File
@@ -209,9 +209,12 @@ import type {
TempestConfig,
LLMConfig,
RuntimeHooks,
LLMProvider,
OperatorArchetype,
CommandEvents,
Finding,
ScanProgressEvent,
Task,
} from './types/index.js';
// Re-export commonly used types
@@ -269,6 +272,10 @@ import {
// TEMPEST COMMAND
// =============================================================================
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
*/
@@ -323,6 +330,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
private tickInterval: NodeJS.Timeout | null = null;
private tickCount: number = 0;
private hooks: RuntimeHooks;
private readonly taskTimeoutMs: number;
/**
* White-box source context (security-prioritized code excerpt), set by the
@@ -357,6 +365,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
super();
this.name = config.name;
this.hooks = config.hooks || {};
this.taskTimeoutMs = TempestCommand.resolveTaskTimeoutMs(config.llm.provider);
// Initialize LLM backbone
this.llm = new LLMBackbone(config.llm);
@@ -539,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 });
@@ -623,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.
@@ -787,6 +858,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
public resume(): void {
if (!this.running || !this.paused) return;
this.paused = false;
this.stallReason = null;
this.emit('command:resumed');
}
@@ -815,29 +887,34 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
* GENEROUS per-dispatch wall-clock backstop (ms). If a single task dispatch stays
* in-flight longer than this, the tick loop force-resolves it as a timeout so
* pendingOrActive can reach 0 and the mission can advance/complete even when an
* operator promise wedges. Deliberately large (default 5 min) so it NEVER kills a
* legitimately-slow-but-working LLM task (e.g. opus planning ~165s); it only fires
* on truly-hung dispatches. Override via T3MP3ST_TASK_TIMEOUT_MS.
* operator promise wedges. Deliberately large (default 5 min for API models,
* 30 min for local-agent backends) so it does not kill legitimately slow local
* CLI work; it only fires on truly-hung dispatches. Override via
* T3MP3ST_TASK_TIMEOUT_MS.
*/
private readonly taskTimeoutMs: number = TempestCommand.resolveTaskTimeoutMs();
private progressEvents: ScanProgressEvent[] = [];
/**
* Resolve the dispatch timeout from the environment, falling back to the 5-minute
* default. Guards against a non-numeric / non-positive override.
* Resolve the dispatch timeout from the environment, falling back to the
* provider-specific default. Guards against a non-numeric / non-positive override.
*/
private static resolveTaskTimeoutMs(): number {
private static resolveTaskTimeoutMs(provider?: LLMProvider): number {
const DEFAULT_TASK_TIMEOUT_MS = 300000; // 5 minutes — generous backstop, not a deadline
const LOCAL_AGENT_TASK_TIMEOUT_MS = 1800000; // local CLI agents can need multiple slow turns
const raw = process.env.T3MP3ST_TASK_TIMEOUT_MS;
if (raw != null && raw.trim() !== '') {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_TASK_TIMEOUT_MS;
return provider === 'local-agent' ? LOCAL_AGENT_TASK_TIMEOUT_MS : DEFAULT_TASK_TIMEOUT_MS;
}
/** Track whether we've seeded initial tasks for the current mission */
private taskSeeded: boolean = false;
/** Human-readable reason a mission was paused because required work failed. */
private stallReason: string | null = null;
/**
* Main tick loop — seeds tasks, dispatches to operators, advances phases
*/
@@ -890,13 +967,28 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
);
const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id));
// If we have tasks, all are done, and nothing is in-flight → advance phase
// If we have tasks, all are done, and nothing is in-flight → advance phase.
// Failed required tasks are terminal, but they are not successful progress.
// Stall instead of walking the phase bar forward with no backend/model work.
if (allMissionTasks.length > 0 && pendingOrActive.length === 0 && inFlight.length === 0) {
const failedCurrentPhase = allMissionTasks.filter(
t => t.phase === mission.currentPhase && t.status === 'failed'
);
if (failedCurrentPhase.length > 0) {
const firstError = failedCurrentPhase[0].result?.error;
this.stallReason = `stalled in ${mission.currentPhase}: ${failedCurrentPhase.length} required task(s) failed` +
(firstError ? ` — ${firstError}` : '');
this.paused = true;
this.emit('command:paused');
return;
}
const phaseIndex = mission.phases.indexOf(mission.currentPhase);
if (phaseIndex === -1) return; // Guard: phase not found (race condition)
if (phaseIndex < mission.phases.length - 1) {
// Advance to next phase and generate tasks
this.mission.advancePhase(mission.id);
this.stallReason = null;
const targets = this.targetEnv.getAllTargets();
for (const target of targets) {
this.mission.generateNextPhaseTasks(target.address);
@@ -927,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
@@ -973,8 +1066,12 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
// double-fire the completion hook.
if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id);
taskQueue.complete(task.id, result);
this.hooks.onTaskCompleted?.(task);
if (result.success === false) {
taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result');
} else {
taskQueue.complete(task.id, result);
this.hooks.onTaskCompleted?.(task);
}
}).catch((_error) => {
if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id);
@@ -1008,8 +1105,8 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
* never advances, and completeMission() is never called: the mission HANGS.
*
* On every tick we scan the in-flight dispatches and force-resolve any that have
* exceeded the GENEROUS wall-clock backstop (taskTimeoutMs, default 5 min), or
* that exhibit the exact wedge symptom (owning operator is `executing`/`tasked`
* exceeded the GENEROUS wall-clock backstop (taskTimeoutMs), or that exhibit
* the exact wedge symptom (owning operator is `executing`/`tasked`
* but its currentTask is null — i.e. it has silently dropped the task). For each
* we: (1) mark the task failed/timed-out in the queue, (2) clear its dispatch
* bookkeeping, and (3) reset the owning operator back to idle so it can take new
@@ -1071,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'],
@@ -1111,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) {
@@ -1136,8 +1237,11 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
// Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools =
// the curated per-operator tool allowlist). toolCategories stays as a coarse fallback.
const profile = ARCHETYPE_PROFILES[archetype];
const maxIterations = this.llm.getProvider() === 'local-agent'
? LOCAL_AGENT_MAX_ITERATIONS
: DEFAULT_AGENT_MAX_ITERATIONS;
const agentLoop = new AgentLoop(this.llm, this.arsenal, {
maxIterations: 15,
maxIterations,
maxTokens: 50000,
toolCategories: profile.toolCategories,
tools: profile.defaultTools,
@@ -1200,8 +1304,20 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
vault: ReturnType<EvidenceVault['getStats']>;
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,
@@ -1213,6 +1329,24 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
vault: this.vault.getStats(),
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,
}))
: [],
};
}
+15 -6
View File
@@ -748,9 +748,12 @@ class LocalAdapter implements LLMProviderAdapter {
return { valid: true };
}
// A /v1 base URL means an OpenAI-compatible server (/chat/completions, choices[]).
// A versioned base URL (/v1, /v2, /v4, …) means an OpenAI-compatible server
// (/chat/completions, choices[]). Many OpenAI-compatible providers version their
// API at paths other than /v1 (e.g. Zhipu/z.ai exposes /api/paas/v4), so match any
// /vN rather than literally /v1.
private isOpenAIWire(baseUrl: string): boolean {
return /\/v1(\/|$)/.test(baseUrl);
return /\/v\d+(\/|$)/.test(baseUrl);
}
// Inject the Arsenal contract as a system turn when tools are offered, and
@@ -764,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}` });
@@ -774,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> {
@@ -1090,7 +1096,8 @@ class LocalAgentAdapter implements LLMProviderAdapter {
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
const agentId = this.config.model || 'codex';
const prompt = this.formatPrompt(messages, options);
const content = (await localAgentChat(agentId, prompt, { timeoutMs: this.config.timeout || 240000 })).trim();
const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined;
const content = (await localAgentChat(agentId, prompt, { timeoutMs })).trim();
// Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the
// ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer.
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
@@ -1228,6 +1235,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
return new OpenAIAdapter(config);
case 'xai':
return new OpenAIAdapter(config); // xAI (Grok Build / grok-*) is OpenAI-compatible
case 'gemini':
return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai)
case 'codex':
return new CodexAdapter(config);
case 'mock':
+40 -5
View File
@@ -22,6 +22,7 @@ import {
import type { LLMBackbone } from '../llm/index.js';
import type { Arsenal } from '../arsenal/index.js';
import type { AgentLoop } from '../agent/index.js';
import type { ToolResult } from '../types/index.js';
import type { Target } from '../types/index.js';
import { OPERATOR_SYSTEM_PROMPTS } from '../prompts/index.js';
import { gateLiveFinding } from '../evidence/gate.js';
@@ -36,6 +37,9 @@ export interface OperatorEvents {
'task:completed': { task: Task; result: TaskResult };
'task:failed': { task: Task; error: string };
'task:decomposed': { parent: Task; subtasks: Task[]; reason: string };
'agent:thinking': { task: Task; content: string };
'agent:tool_call': { task: Task; name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' };
'agent:tool_result': { task: Task; name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
'finding:discovered': { finding: Finding };
'finding:gate-blocked': { finding: Finding; reasons: string[] };
'credential:harvested': { credential: Credential };
@@ -422,11 +426,38 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
// Pass the white-box source excerpt (if any) so the model sees the target's
// real source alongside the task; empty string keeps black-box behavior.
if (this.agentLoop && this.llm) {
// [Phase-2] Register as live on the board and pull the shared situation report so this
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
this.board?.heartbeat(this.id, 'hunting', task.name);
const sharedContext = this.board?.situationReport(this.id);
const result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
const onThinking = ({ content }: { content: string }): void => {
this.emit('agent:thinking', { task, content });
};
const onToolCall = ({ name, args, source }: { name: string; args: Record<string, unknown>; source?: 'agent' | 'backend_seeded' }): void => {
this.emit('agent:tool_call', { task, name, args, source });
};
const onToolResult = ({ name, result, source }: { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' }): void => {
this.emit('agent:tool_result', { task, name, result, source });
};
const canForwardAgentEvents =
typeof this.agentLoop.on === 'function' &&
typeof this.agentLoop.off === 'function';
if (canForwardAgentEvents) {
this.agentLoop.on('agent:thinking', onThinking);
this.agentLoop.on('agent:tool_call', onToolCall);
this.agentLoop.on('agent:tool_result', onToolResult);
}
let result;
try {
// [Phase-2] Register as live on the board and pull the shared situation report so this
// operator sees teammates' verified leads/claims — it builds on them instead of running blind.
this.board?.heartbeat(this.id, 'hunting', task.name);
const sharedContext = this.board?.situationReport(this.id);
result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
} finally {
if (canForwardAgentEvents) {
this.agentLoop.off('agent:thinking', onThinking);
this.agentLoop.off('agent:tool_call', onToolCall);
this.agentLoop.off('agent:tool_result', onToolResult);
}
}
// Convert agent findings to operator findings.
// PROVENANCE-HONEST: only a tool-backed finding gets tool-output evidence (the raw
@@ -445,6 +476,7 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
phase: task.phase as KillChainPhase,
cvss: finding.cvss,
cve: finding.cve,
cwe: finding.cwe,
evidence: toolBacked
? [{
type: 'output',
@@ -461,6 +493,9 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
return {
success: result.success,
output: result.summary,
error: result.finalSummaryError
? `${result.summary} Final summary failed: ${result.finalSummaryError}`
: undefined,
findings: result.findings.map(f => f.title),
nextTasks: undefined,
};
+357 -45
View File
@@ -32,9 +32,6 @@ import type { OperatorArchetype } from './types/index.js';
import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js';
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveContainedRepoPath, 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,16 +6185,24 @@ 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 (missionLLMConfig.provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(missionLLMConfig.model);
if (!localAgent.ok) {
res.status(503).json({ error: localAgent.error });
return;
}
}
if (targets.length === 0) {
res.status(400).json({ error: 'At least one target required' });
@@ -5994,6 +6215,14 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
if (!guard.allowed) { blockForApproval(res, guard); return; }
}
if (provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(model);
if (!localAgent.ok) {
res.status(503).json({ error: localAgent.error });
return;
}
}
// B-03: if an OPTIONAL white-box repoPath was supplied, containment-check it HERE
// (canonicalize + confine to the allowed root) before we build/start the command,
// so a bad/escaping path 400s cleanly instead of half-starting a run.
@@ -6012,7 +6241,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) {
@@ -6205,7 +6434,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;
}
@@ -6217,6 +6446,7 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
res.json({
active: status.running,
paused: status.paused,
stallReason: status.stallReason,
name: status.name,
tickCount: status.tickCount,
mission: mission ? {
@@ -6234,6 +6464,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,
@@ -6470,13 +6702,29 @@ 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;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function readGeneralTimeoutEnv(): number | undefined {
return readPositiveTimeoutEnv('TEMPEST_GENERAL_TIMEOUT_MS')
?? readPositiveTimeoutEnv('T3MP3ST_GENERAL_TIMEOUT_MS');
}
// SECURITY NOTE: `apiKey` is accepted from the request BODY here (and in the
// mission/general routes that call this). Sending secrets in the body is not
// ideal — an Authorization header is preferred — but the same-origin UI posts
// 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;
@@ -6484,7 +6732,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') {
@@ -6493,7 +6742,9 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
model: model || 'codex',
maxTokens: 8192,
temperature: 0.4,
timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000,
timeout: readGeneralTimeoutEnv()
?? readPositiveTimeoutEnv('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS')
?? 600000,
};
}
const baseConfig = config.getLLMConfig(selectedProvider as any, model);
@@ -6507,7 +6758,7 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
apiKey: effectiveKey,
maxTokens: 8192,
temperature: 0.4,
timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000, // General planning needs room (was a hardcoded 60s); override via env
timeout: readGeneralTimeoutEnv() ?? 300000, // General planning needs room (was a hardcoded 60s); override via env
};
}
@@ -6543,8 +6794,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"',
@@ -6570,7 +6819,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
@@ -6622,7 +6871,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) {
@@ -6642,7 +6891,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);
@@ -6675,8 +6924,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) {
@@ -6749,8 +6998,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;
@@ -6862,8 +7111,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) {
@@ -7121,7 +7370,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) {
@@ -7151,7 +7400,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 : '';
@@ -7186,7 +7435,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) {
@@ -7241,12 +7490,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;
}
@@ -7350,8 +7607,51 @@ app.get('/api/bounty/credentials', (_req: Request, res: Response) => {
// Detect agent CLIs that are already installed + logged-in on this machine and enlist them as
// operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see
// src/agent/local-agents.ts). In-memory registry of agents connected this session:
const connectedLocalAgents = new Map<string, { id: string; label: string; version?: string; connectedAt: number; lastPing?: AgentRunSummary | null }>();
type AgentRunSummary = { ok: boolean; latencyMs: number; output: string; error?: string };
type ConnectedLocalAgent = {
id: string;
label: string;
version?: string;
connectedAt: number;
lastPing?: AgentRunSummary | null;
lastHealthCheckAt?: number;
};
const connectedLocalAgents = new Map<string, ConnectedLocalAgent>();
const LOCAL_AGENT_HEALTH_TTL_MS = 30_000;
const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 45_000;
async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
const now = Date.now();
const due = Array.from(connectedLocalAgents.values()).filter((agent) => (
force ||
!agent.lastHealthCheckAt ||
now - agent.lastHealthCheckAt > LOCAL_AGENT_HEALTH_TTL_MS
));
await Promise.all(due.map(async (agent) => {
const result = await pingLocalAgent(agent.id, undefined, LOCAL_AGENT_HEALTH_TIMEOUT_MS);
const current = connectedLocalAgents.get(agent.id);
if (!current) return;
current.lastPing = result;
current.lastHealthCheckAt = Date.now();
}));
}
async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const id = String(model || 'codex').trim();
const entry = connectedLocalAgents.get(id);
if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` };
await refreshConnectedLocalAgentHealth(true);
const refreshed = connectedLocalAgents.get(id);
if (refreshed?.lastPing?.ok) return { ok: true, id };
return {
ok: false,
error: `local agent "${id}" is connected but not live` +
(refreshed?.lastPing?.error ? ` — ${refreshed.lastPing.error}` : ''),
};
}
// GET /api/agents/local/detect — which agents are installed / authed / ready (no tokens spent)
app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise<void> => {
@@ -7378,7 +7678,14 @@ app.post('/api/agents/local/connect', async (req: Request, res: Response): Promi
if (!d.authed) { results.push({ id, status: 'not-authed', version: d.version }); continue; }
let ping: AgentRunSummary | null = null;
if (doPing) ping = await pingLocalAgent(id);
connectedLocalAgents.set(id, { id, label: d.label, version: d.version, connectedAt: Date.now(), lastPing: ping });
connectedLocalAgents.set(id, {
id,
label: d.label,
version: d.version,
connectedAt: Date.now(),
lastPing: ping,
lastHealthCheckAt: ping ? Date.now() : undefined,
});
results.push({ id, status: 'active', label: d.label, version: d.version, authMethod: d.authMethod, ping });
}
syncLocalAgentSelection(connectedLocalAgents, ids, replace);
@@ -7391,7 +7698,10 @@ app.post('/api/agents/local/ping', async (req: Request, res: Response): Promise<
if (!body.id) { res.status(400).json({ error: 'id required' }); return; }
const r = await pingLocalAgent(body.id, body.prompt);
const entry = connectedLocalAgents.get(body.id);
if (entry) entry.lastPing = r;
if (entry) {
entry.lastPing = r;
entry.lastHealthCheckAt = Date.now();
}
res.json({ id: body.id, ...r });
});
@@ -7411,8 +7721,10 @@ app.post('/api/agents/local/disconnect', (req: Request, res: Response): void =>
res.json({ ok: true, connected: Array.from(connectedLocalAgents.keys()) });
});
// GET /api/agents/local/status — currently connected agents
app.get('/api/agents/local/status', (_req: Request, res: Response): void => {
// GET /api/agents/local/status — connected agents, optionally with a bounded live health check.
app.get('/api/agents/local/status', async (req: Request, res: Response): Promise<void> => {
const check = /^(1|true|yes|on)$/i.test(String(req.query.check || ''));
if (check) await refreshConnectedLocalAgentHealth(false);
res.json({ connected: Array.from(connectedLocalAgents.values()) });
});
+5 -15
View File
@@ -363,14 +363,15 @@ async function runSetup(): Promise<void> {
'Welcome to T3MP3ST Setup',
`This wizard will help you configure T3MP3ST for first use.
You'll need an API key from one of these providers:
You can use a local model with no API key, or add a key from one of these providers:
• ${chalk.cyan('Local')} - Ollama / LM Studio / vLLM via TEMPEST_LOCAL_BASE_URL
• ${chalk.cyan('OpenRouter')} (recommended) - Access multiple models
• ${chalk.cyan('Anthropic')} - Direct Claude access
• ${chalk.cyan('OpenAI')} - GPT models
• ${chalk.cyan('LiteLLM')} - AI gateway proxy (100+ providers)
The setup will guide you through:
1. Adding your API key(s)
1. Adding API key(s), if you use a hosted provider
2. Selecting your default provider and model
3. Configuring basic settings`,
'cyan'
@@ -448,12 +449,11 @@ async function setupApiKeys(): Promise<void> {
{
type: 'checkbox',
name: 'providers',
message: 'Which API keys would you like to configure?',
message: 'Which hosted API keys would you like to configure? Leave empty to use a local model only.',
choices: [
{
name: `OpenRouter ${hasApiKey('openrouter') ? chalk.green('(configured)') : chalk.yellow('(recommended)')}`,
value: 'openrouter',
checked: !hasApiKey('openrouter'),
},
{
name: `Venice ${hasApiKey('venice') ? chalk.green('(configured)') : ''}`,
@@ -499,23 +499,13 @@ async function setupApiKeys(): Promise<void> {
async function setupProvider(): Promise<void> {
const configuredProviders = [];
configuredProviders.push({ name: 'Local model (Ollama / LM Studio / vLLM, no API key)', value: 'local' });
if (hasApiKey('openrouter')) configuredProviders.push({ name: 'OpenRouter', value: 'openrouter' });
if (hasApiKey('venice')) configuredProviders.push({ name: 'Venice', value: 'venice' });
if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' });
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' });
if (configuredProviders.length === 0) {
showWarning('No API keys configured. Please add at least one API key first.');
return;
}
if (configuredProviders.length === 1) {
config.setDefaultProvider(configuredProviders[0].value as any);
showSuccess(`Default provider set to: ${configuredProviders[0].name}`);
return;
}
const { provider } = await inquirer.prompt([
{
type: 'list',
+7 -1
View File
@@ -271,6 +271,7 @@ export class TargetEnvironment extends EventEmitter<TargetEvents> {
export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): Target {
const parsed = new URL(url);
const normalizedUrl = parsed.toString();
return {
id: randomUUID(),
@@ -278,12 +279,17 @@ export function createTargetFromUrl(url: string, zone: TargetZone = 'external'):
type: 'web_application',
zone,
status: 'discovered',
address: url,
address: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
protocol: parsed.protocol.replace(':', ''),
services: [],
vulnerabilities: [],
credentials: [],
metadata: {
url: normalizedUrl,
origin: parsed.origin,
path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
},
discoveredAt: Date.now(),
};
}
+18 -1
View File
@@ -6,7 +6,7 @@
// LLM CONFIGURATION
// =============================================================================
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'litellm' | 'codex' | 'mock' | 'local' | 'local-agent';
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | '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. */