From 47fc8e9f77dbea2e462b1f9c7bd4576e47befd3d Mon Sep 17 00:00:00 2001 From: Pazificateur69 Date: Mon, 6 Jul 2026 10:42:46 +0200 Subject: [PATCH 01/12] feat(verify-claims): gate every committed solve as oracle-backed (no phantom pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-claims credits a solve straight from the committed `verdict` (detected / score===1). Nothing asserted that a scored solve actually agrees with its own committed flag oracle, so a hand-edited `detected:true` (or a merge slip / fabricated placeholder flag) would pass verification silently. The case-insensitive oracle comparator `ciMatch` was defined but never called — this is exactly the check it was meant for. Add an INTEGRITY GATE that re-derives, for every scored solve across the pinned golden + white-box XBEN sweeps and the Cybench per-run artifacts, that it is oracle-grounded: EITHER the self-reported flag matches the committed `expected` (ciMatch), OR the harness auto-detected `expected` verbatim in tool output (auto_detected, reported may be "UNKNOWN"). Anything scored that is neither is a phantom pass and fails the build. Also rejects a fabricated placeholder credited via the self-report path, and reports how much of the Cybench headline is backed by an oracle-bearing per-run artifact vs. counted only in the hand-editable aggregate. Adds src/__tests__/oracle-consistency.test.ts in the same spirit as no-phantom-tools / stub-honesty: audits the committed artifacts AND proves the gate has teeth against synthetic tampered/fabricated verdicts. Current receipts pass clean: 0 phantom / 606 scored solves, 0 fabricated. --- scripts/verify-claims.mjs | 42 +++++++ src/__tests__/oracle-consistency.test.ts | 140 +++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/__tests__/oracle-consistency.test.ts diff --git a/scripts/verify-claims.mjs b/scripts/verify-claims.mjs index dacdbf1..8f2f30d 100644 --- a/scripts/verify-claims.mjs +++ b/scripts/verify-claims.mjs @@ -229,6 +229,48 @@ console.log('\nCLAIM 7 — CVE-Zero-v2 (held-out): hunt generalizes to fresh uns } } +// ── INTEGRITY GATE: every scored solve is oracle-grounded (no phantom pass) ─── +// Every check above credits a solve straight from the committed `verdict` (detected / +// score===1). This gate closes the obvious tamper hole in that trust: a scored solve must +// still AGREE with its own committed flag oracle. A solve is oracle-grounded iff EITHER the +// agent's self-reported flag matches the committed `expected` under the same case-insensitive +// compare the harness uses (ciMatch), OR the harness auto-detected `expected` verbatim in tool +// output (auto_detected — the agent didn't self-report, so `reported` may be "UNKNOWN"). +// Anything scored as a solve that is NEITHER is a phantom pass: a hand-edited `detected:true` +// whose committed oracle does not back it. This wires ciMatch (until now defined but unused) +// into an executable gate, and rejects a fabricated placeholder credited via the self-report path. +console.log('\nINTEGRITY GATE — every committed solve is oracle-grounded (no phantom pass, no fabricated flag)'); +const oracleGrounded = (v) => + ciMatch(v.reported, v.expected) || v.auto_detected === true || /auto-?detected/i.test(String(v.reason || '')); +const scanSolves = (files) => { + let solves = 0, phantom = 0, fabricated = 0, grounded = 0; + for (const f of files) { + const v = (((JSON.parse(fs.readFileSync(f, 'utf8')).results) || [{}])[0] || {}).verdict || {}; + if (!(v.detected === true || v.score === 1)) continue; + solves++; + if (oracleGrounded(v)) grounded++; else phantom++; + // A fabricated placeholder may only ever be "credited" via the self-report path. + if (ciMatch(v.reported, v.expected) && looksFabricated(v.reported)) fabricated++; + } + return { solves, phantom, fabricated, grounded }; +}; +const xbenFiles = [...BESTBALL_DIRS, ...WHITEBOX_DIRS].flatMap((sub) => + glob(R('bench/xbow/results/' + sub), /^xben_\d+_24.*\.json$/)); +const cybenchFiles = [...glob(R('bench/cybench/results'), /^clean-.*\.json$/), + ...glob(R('bench/cybench/results'), /^service-cybsvc_.*\.json$/)]; +const ig = scanSolves([...xbenFiles, ...cybenchFiles]); +check('every scored solve is oracle-grounded (self-report ≡ committed oracle, or auto-detected)', + ig.phantom === 0, `${ig.phantom} phantom / ${ig.solves} scored solves`); +check('no fabricated placeholder flag is credited as a self-reported solve', + ig.fabricated === 0, `${ig.fabricated} fabricated / ${ig.solves} scored solves`); +// Informational — how much of the Cybench headline is backed by an oracle-bearing per-run +// artifact vs. counted only in the hand-editable aggregate (transcripts are stripped on export). +{ + const cRun = scanSolves(cybenchFiles); + const aggSolved = fs.existsSync(cf) ? JSON.parse(fs.readFileSync(cf, 'utf8')).solved : null; + console.log(` ℹ️ Cybench: aggregate reports ${aggSolved} solved · ${cRun.grounded} oracle-grounded per-run artifact(s) committed · ${(aggSolved ?? 0) - cRun.grounded} aggregate-only`); +} + // ── verdict ───────────────────────────────────────────────────────────────── console.log(`\n════════ ${fail === 0 ? '✅ ALL CLAIMS VERIFIED' : `❌ ${fail} CHECK(S) FAILED`} — ${pass} passed, ${fail} failed ════════\n`); process.exit(fail === 0 ? 0 : 1); diff --git a/src/__tests__/oracle-consistency.test.ts b/src/__tests__/oracle-consistency.test.ts new file mode 100644 index 0000000..66a80d0 --- /dev/null +++ b/src/__tests__/oracle-consistency.test.ts @@ -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); + }); +}); From ce5f5b974a0cb94a97557c03cf3e2e7de85324db Mon Sep 17 00:00:00 2001 From: Pazificateur69 Date: Mon, 6 Jul 2026 10:53:31 +0200 Subject: [PATCH 02/12] test(arsenal): lock the advertised tool count to the real callable surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arsenal advertises "83 tools" and verify-claims defends it by counting `id:`/`name:` source lines — a source-line count that would keep passing even if an entry became uncallable. Add a guard that ties the number to the real registered arrays (TOOL_ADAPTERS + BUILTIN_TOOLS + EXTERNAL_TOOLS === 83) and pins the invariant that makes the count honest: an adapter is off the generic callable surface (adapterToCustomTool → null) IFF it is explicitly gated by execution mode (catalog_only / import_only). So a tool can be counted-but-not-generically-callable only on purpose — metasploit / hydra (catalog_only, reachable solely via the approval-gated post-ex drivers) and bloodhound (import_only) — never through a silent DEFAULT or mint slip. Also asserts isMintable agrees with actual callability, the gated set is exactly {metasploit, hydra, bloodhound}, and every minted adapter has a real handler and a unique name. Sibling of no-phantom-tools / stub-honesty. --- src/__tests__/arsenal-count-honesty.test.ts | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/__tests__/arsenal-count-honesty.test.ts diff --git a/src/__tests__/arsenal-count-honesty.test.ts b/src/__tests__/arsenal-count-honesty.test.ts new file mode 100644 index 0000000..0616dd5 --- /dev/null +++ b/src/__tests__/arsenal-count-honesty.test.ts @@ -0,0 +1,91 @@ +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 "83 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), +// 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 "83 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 83 (adapters=${TOOL_ADAPTERS.length}, ` + + `built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` + + 'update the README / verify-claims headline to match', + ).toBe(83); + 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 (metasploit, hydra, bloodhound)', () => { + const gated = TOOL_ADAPTERS.filter(isGated); + // metasploit + hydra are catalog_only (callable only via the approval-gated hand-written + // post-ex drivers); bloodhound is import_only (collector output is imported, never run here). + expect(gated.map((a) => a.id).sort()).toEqual(['bloodhound', 'hydra', 'metasploit']); + 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(); + 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); + }); +}); From 6ba02ec50d9a68ea0f286acb70a3f71ffa237e47 Mon Sep 17 00:00:00 2001 From: psigho Date: Sun, 5 Jul 2026 17:39:53 +0500 Subject: [PATCH 03/12] fix(local-agents): Windows CLI detection + Hermes auth path Detection reported installed npm-global agents (Claude Code, Codex) as 'binary not found on PATH' on Windows, and an authed Hermes desktop as 'NOT AUTHED'. Two root causes: 1. resolvePath() used POSIX-only 'command -v'/'which', and execFile/spawn('claude', ...) cannot resolve npm's .cmd shims via PATHEXT -> ENOENT -> installed:false. Add resolveBin() (where.exe on Windows, preferring .exe > .cmd > .bat) and route detection + both spawn call sites through the resolved path. 2. Hermes desktop on Windows stores its login under %LOCALAPPDATA%\hermes not ~/.hermes/. Add localAppData() and check both (presence-only). Windows .cmd shims are launched via a new spawnAgent() helper that invokes cmd.exe with an argv ARRAY (shell:false) instead of shell:true, so the untrusted prompt is never re-parsed by cmd.exe -> no command-injection surface. No new deps; typecheck clean. --- .gitignore | 2 + src/agent/local-agents.ts | 94 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index a4c44ab..5b6e236 100755 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ bench/nyu/ # sensitive export leak-gate denylist (operator handle + embargoed disclosure # targets + personal vault) loaded by the exporter — never commit scripts/.export-denylist.local +server.pid +server.log diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index f6de5ec..21d2eec 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -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\\AppData\Local), where the Hermes desktop app + * stores its runtime — its .env / auth lives at %LOCALAPPDATA%\hermes\.env, NOT ~/.hermes/.env. + * - POSIX: /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). @@ -137,7 +147,10 @@ const SPECS: AgentSpec[] = [ invokeHint: 'hermes -z "" (--yolo only if T3MP3ST_HERMES_YOLO=1)', versionArgs: ['--version'], parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0], - authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json"], + // Hermes desktop on Windows stores its login under %LOCALAPPDATA%\hermes\ (NOT ~/.hermes/), so + // checking only ~/.hermes/ made an authed Windows install read as NOT AUTHED. Presence-only check; + // ~/.hermes/auth.json is upstream's POSIX/mac auth artifact, kept alongside the Windows paths. + authArtifacts: ['~/.hermes/.env', '~/.hermes/auth.json', localAppData('hermes/.env'), localAppData('hermes/auth.json')], oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])], }, ]; @@ -172,7 +185,34 @@ export interface AgentDetection { ready: boolean; // installed && authed } -function resolvePath(bin: string): string | undefined { +const isWindows = process.platform === 'win32'; +// Kept as a constructed RegExp (not a literal) so the source never embeds a raw CRLF inside a +// regex literal — that repeatedly corrupted this file when edited by line-based patch tooling. +const NEWLINE_RE = new RegExp('\\r?\\n'); + +/** + * Windows npm-global CLIs install as shell shims (e.g. `claude.cmd`), NOT bare `.exe`. Node's + * execFile/spawn only auto-resolve `.exe` via PATHEXT, so `execFile('claude', …)` throws ENOENT + * even though `claude` runs fine in a shell — which made every npm-installed agent read as + * "binary not found on PATH". resolveBin() resolves the REAL absolute file path cross-platform: + * - Windows: `where.exe` (honors PATHEXT), preferring .exe > .cmd > .bat > first hit. + * - POSIX: `command -v` then `which`. + * Detection and every spawn then use the resolved path, so a `.cmd` shim launches correctly + * (see needsShell). Returns undefined only when the bin is genuinely not on PATH. + */ +function resolveBin(bin: string): string | undefined { + if (isWindows) { + 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; } + } try { return execFileSync('command', ['-v', bin], { shell: '/bin/bash', encoding: 'utf8' }).trim() || undefined; } catch { @@ -180,6 +220,34 @@ function resolvePath(bin: string): string | undefined { } } +/** + * Windows-safe launcher for a resolved agent binary. + * + * A `.cmd`/`.bat` shim can't be spawned directly (shell:false throws EINVAL/ENOEXEC). The common + * "fix" — `spawn(bin, args, { shell: true })` — is UNSAFE here: runLocalAgent puts the (untrusted) + * prompt straight into argv (claude `-p `, codex `exec … `), and shell:true would + * route that prompt through cmd.exe where `&`, `|`, `>`, `%VAR%` become metacharacters → command + * injection in a security tool. Instead we invoke cmd.exe explicitly with an ARGV ARRAY + * (`cmd.exe /d /s /c …`) and shell:false. Node hands each element to the child + * as a distinct argv entry with no shell re-parsing, so cmd.exe resolves the `.cmd` association while + * the prompt is never interpreted. Real `.exe`/POSIX binaries spawn directly (no wrapper). + */ +function spawnAgent(resolvedBin: string, args: string[], options: import('child_process').SpawnOptions): import('child_process').ChildProcess { + if (needsShell(resolvedBin)) { + return spawn('cmd.exe', ['/d', '/s', '/c', resolvedBin, ...args], { ...options, shell: false }); + } + return spawn(resolvedBin, args, { ...options, shell: false }); +} + +/** + * True when the resolved binary is a Windows `.cmd`/`.bat` shim (npm global installs land as these). + * Such a shim can't be spawned directly — it must go through cmd.exe (see spawnAgent, which does so + * SAFELY via an argv array rather than shell:true). `.exe` and POSIX binaries return false. + */ +function needsShell(resolvedBin: string): boolean { + return isWindows && /\.(cmd|bat)$/i.test(resolvedBin); +} + function authState(spec: AgentSpec): { authed: boolean; method?: string } { for (const a of spec.authArtifacts) { if (existsSync(expand(a))) return { authed: true, method: 'file' }; @@ -198,8 +266,15 @@ function detectOne(spec: AgentSpec): Promise { id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin, blurb: spec.blurb, invokeHint: spec.invokeHint, }; + const resolved = resolveBin(spec.bin); return new Promise((resolve) => { - execFile(spec.bin, spec.versionArgs, { timeout: 8000 }, (err, stdout) => { + // Not on PATH at all → genuinely not installed. + if (!resolved) { + resolve({ ...base, installed: false, authed: false, ready: false }); + return; + } + // Launch the RESOLVED path (a .cmd shim needs a shell; a real .exe does not). + execFile(resolved, spec.versionArgs, { timeout: 8000, shell: needsShell(resolved) }, (err, stdout) => { if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') { resolve({ ...base, installed: false, authed: false, ready: false }); return; @@ -210,7 +285,7 @@ function detectOne(spec: AgentSpec): Promise { resolve({ ...base, installed: true, - path: resolvePath(spec.bin), + path: resolved, version, authed: auth.authed, authMethod: auth.method, @@ -265,9 +340,12 @@ export function runLocalAgent( const maxChars = opts.maxChars ?? 4000; // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). const env = childEnv(); + // Resolve the real binary path so a Windows .cmd shim launches (fallback to bare name on POSIX). + 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'] }); + // spawnAgent() launches a Windows .cmd shim SAFELY (cmd.exe argv array, no shell re-parsing of the prompt). + const child = spawnAgent(resolvedBin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; let errOut = ''; let done = false; @@ -329,8 +407,12 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin } const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } }; + // Resolve the real binary path (Windows .cmd shim vs real .exe). hermes resolves to hermes.exe, + // so needsShell is false there — its prompt arg never traverses a shell. See needsShell note. + 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'] }); + // spawnAgent() launches a Windows .cmd shim SAFELY (cmd.exe argv array, no shell re-parsing). + const child = spawnAgent(resolvedBin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); let out = ''; let errOut = ''; let done = false; From a836e61adbfde4ff0388d68e162ef4c52d288410 Mon Sep 17 00:00:00 2001 From: psigho Date: Fri, 10 Jul 2026 03:22:28 +0500 Subject: [PATCH 04/12] fix(local-agents): don't 500 /detect when a version-probe spawn is denied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectOne() ran the `--version` probe via execFile INSIDE a `new Promise` executor but only handled the async error path. execFile can throw SYNCHRONOUSLY (e.g. `spawn EPERM` when child-process creation is restricted), and a synchronous throw in a Promise executor REJECTS the promise. Because detectLocalAgents() used Promise.all, one agent's spawn failure rejected the whole batch → GET /api/agents/local/detect returned 500 → the Settings "Local Agents" card silently rendered "0/0 installed" even though all CLIs were on PATH. - detectOne: wrap execFile in try/catch. The binary was already found on PATH (resolveBin returned a path) so it IS installed; on a sync throw we resolve installed:true with version '?' and filesystem-based auth, never rejecting. - detectLocalAgents: Promise.allSettled instead of Promise.all, so one agent's failure degrades to "not installed" for that agent only, never the batch. Verified: /detect returns 200 with claude/codex/hermes installed+ready. Typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/agent/local-agents.ts | 57 +++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index 21d2eec..47265ce 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -273,25 +273,38 @@ function detectOne(spec: AgentSpec): Promise { resolve({ ...base, installed: false, authed: false, ready: false }); return; } - // Launch the RESOLVED path (a .cmd shim needs a shell; a real .exe does not). - execFile(resolved, spec.versionArgs, { timeout: 8000, shell: needsShell(resolved) }, (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 || '')); + // Found on PATH ⇒ installed. Used when the version probe can't RUN (a synchronous spawn + // throw like EPERM, or any async error that isn't ENOENT): the binary exists, we just + // couldn't read its version. Auth is a filesystem/keychain check, so it still works. + const installedNoVersion = () => { const auth = authState(spec); resolve({ - ...base, - installed: true, - path: resolved, - version, - authed: auth.authed, - authMethod: auth.method, - ready: auth.authed, + ...base, installed: true, path: resolved, version: '?', + authed: auth.authed, authMethod: auth.method, ready: auth.authed, }); - }); + }; + try { + // Launch the RESOLVED path (a .cmd shim needs a shell; a real .exe does not). + execFile(resolved, spec.versionArgs, { timeout: 8000, shell: needsShell(resolved) }, (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: resolved, version, + authed: auth.authed, authMethod: auth.method, ready: auth.authed, + }); + }); + } catch { + // execFile can throw SYNCHRONOUSLY (e.g. `spawn EPERM` when child-process creation is + // restricted). Inside this Promise executor a throw would REJECT the promise, which fails + // the Promise.allSettled sibling below and — pre-fix, under Promise.all — 500'd the whole + // /api/agents/local/detect endpoint, blanking the Settings panel to "0/0 installed". + installedNoVersion(); + } }); } @@ -301,7 +314,17 @@ export async function detectLocalAgents(): Promise { // 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)); + // allSettled, NOT all: a single agent's detection failure must degrade to "not installed" + // for that one agent, never reject the whole batch (which 500'd /detect and blanked the UI). + 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 { From bf50be9971387dcbace195f70e805b9ceeae7ec0 Mon Sep 17 00:00:00 2001 From: psigho Date: Fri, 10 Jul 2026 05:03:42 +0500 Subject: [PATCH 05/12] harden(local-agents): close cmd.exe prompt-injection + POSIX detect fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the Windows detection PR. 1. Command injection (the important one). spawnAgent ran .cmd/.bat shims via a hand-rolled `spawn('cmd.exe', ['/d','/s','/c', shim, ...args])`. cmd.exe re-parses its tail with its own rules and ignores `\"`-escaping, so an adversarial prompt (claude `-p `, codex `exec `) containing `"` + `& | > %VAR%` broke out and executed — the BatBadBut / CVE-2024-1874 class. Verified with a `x" & echo PWNED` payload: it ran. Fix: use Node's own safe `.cmd` handling — `spawn(shim, args, {shell:true})`. On Node >=18.20.2 / >=20.12.2 / >=21 (CVE-2024-27980) Node escapes each arg for cmd, so the whole prompt lands as ONE literal argument and cannot break out (verified on Node 24: the same payload arrived intact in %1). .exe/POSIX still spawn directly with shell:false. 2. POSIX detection regression. Detection gated on resolveBin() returning a value; on a bash-less/busybox image where both `command -v` and `which` are absent, an installed agent flipped to "not installed". Fix: on POSIX fall back to the bare name (`resolveBin(bin) || (isWindows ? undefined : bin)`) — the OS resolves PATH at exec time and the ENOENT branch still catches genuinely-absent binaries. Windows keeps resolveBin authoritative. Typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/agent/local-agents.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index 47265ce..b18594c 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -223,26 +223,31 @@ function resolveBin(bin: string): string | undefined { /** * Windows-safe launcher for a resolved agent binary. * - * A `.cmd`/`.bat` shim can't be spawned directly (shell:false throws EINVAL/ENOEXEC). The common - * "fix" — `spawn(bin, args, { shell: true })` — is UNSAFE here: runLocalAgent puts the (untrusted) - * prompt straight into argv (claude `-p `, codex `exec … `), and shell:true would - * route that prompt through cmd.exe where `&`, `|`, `>`, `%VAR%` become metacharacters → command - * injection in a security tool. Instead we invoke cmd.exe explicitly with an ARGV ARRAY - * (`cmd.exe /d /s /c …`) and shell:false. Node hands each element to the child - * as a distinct argv entry with no shell re-parsing, so cmd.exe resolves the `.cmd` association while - * the prompt is never interpreted. Real `.exe`/POSIX binaries spawn directly (no wrapper). + * A `.cmd`/`.bat` shim can't be spawned with shell:false (Node throws EINVAL on Windows) — it must + * run through cmd.exe. But hand-rolling `spawn('cmd.exe', ['/d','/s','/c', shim, ...args])` is + * UNSAFE: cmd.exe re-parses its command tail with its OWN rules and does not honor `\"`-escaping, so + * the (untrusted, adversarial) prompt reaching argv — claude `-p `, codex `exec ` — + * can contain `"` + `& | > %VAR%` and break out to execute (the BatBadBut / CVE-2024-1874 class; + * verified: a `x" & echo PWNED` payload ran). + * + * The safe route is Node's OWN `.cmd` handling: `spawn(shim, args, { shell: true })`. On Node + * >=18.20.2 / >=20.12.2 / >=21 (CVE-2024-27980 fix) Node escapes each arg for cmd, so the whole + * prompt lands as a SINGLE literal argument and cannot break out (verified on Node 24: the same + * payload arrived intact in %1). Real `.exe`/POSIX binaries spawn directly with shell:false. */ function spawnAgent(resolvedBin: string, args: string[], options: import('child_process').SpawnOptions): import('child_process').ChildProcess { if (needsShell(resolvedBin)) { - return spawn('cmd.exe', ['/d', '/s', '/c', resolvedBin, ...args], { ...options, shell: false }); + // shell:true lets Node run the .cmd via cmd.exe WITH its CVE-2024-27980 arg-escaping (safe); + // the manual cmd.exe wrapper bypassed that escaping and was injectable. + return spawn(resolvedBin, args, { ...options, shell: true }); } return spawn(resolvedBin, args, { ...options, shell: false }); } /** * True when the resolved binary is a Windows `.cmd`/`.bat` shim (npm global installs land as these). - * Such a shim can't be spawned directly — it must go through cmd.exe (see spawnAgent, which does so - * SAFELY via an argv array rather than shell:true). `.exe` and POSIX binaries return false. + * Such a shim can't be spawned with shell:false — it must go through cmd.exe (see spawnAgent, which + * does so SAFELY via Node's own shell:true `.cmd` arg-escaping). `.exe` and POSIX binaries return false. */ function needsShell(resolvedBin: string): boolean { return isWindows && /\.(cmd|bat)$/i.test(resolvedBin); @@ -266,9 +271,13 @@ function detectOne(spec: AgentSpec): Promise { id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin, blurb: spec.blurb, invokeHint: spec.invokeHint, }; - const resolved = resolveBin(spec.bin); + // POSIX fallback: if neither `command -v` nor `which` resolved it (e.g. a bash-less/busybox image + // where both are absent) still try the bare name — the OS resolves it against PATH at exec time, + // and the ENOENT branch below correctly flips a genuinely-absent binary to not-installed. Windows + // keeps `resolved` authoritative: a bare npm `.cmd` shim name has no safe direct spawn. + const resolved = resolveBin(spec.bin) || (isWindows ? undefined : spec.bin); return new Promise((resolve) => { - // Not on PATH at all → genuinely not installed. + // Not resolvable at all → genuinely not installed. if (!resolved) { resolve({ ...base, installed: false, authed: false, ready: false }); return; From 13fe0f2e56db3498a49eea16b079775119697ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=83=AB=E3=82=B8=E3=83=BC=F0=9F=90=A7?= Date: Fri, 10 Jul 2026 22:30:20 +0700 Subject: [PATCH 06/12] Update index.ts --- src/config/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index b1b9ea3..17fa577 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -516,7 +516,7 @@ class ConfigManager { constructor() { this.config = new Conf({ - projectName: 't3mp3st', + projectName: 'T3MP3ST', defaults: DEFAULT_SETTINGS, }); @@ -533,7 +533,7 @@ class ConfigManager { // operators often run T3MP3ST inside target repos, and importing that repo's // secrets would contaminate this process with unrelated credentials. const envPaths = [ - join(homedir(), '.t3mp3st', '.env'), + join(homedir(), 'T3MP3ST', '.env'), join(homedir(), '.env'), ]; From 971a452eac974cf0c4812efbc1f2ff40b8de6be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=83=AB=E3=82=B8=E3=83=BC=F0=9F=90=A7?= Date: Fri, 10 Jul 2026 22:56:13 +0700 Subject: [PATCH 07/12] Update index.ts --- src/config/index.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/config/index.ts b/src/config/index.ts index 17fa577..b50df32 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -537,6 +537,8 @@ class ConfigManager { join(homedir(), '.env'), ]; + let envProvider: string | undefined; + for (const envPath of envPaths) { if (existsSync(envPath)) { const envContent = readFileSync(envPath, 'utf-8'); @@ -553,9 +555,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') { + envProvider = value; + } } } } + // Set TEMPEST_DEFAULT_PROVIDER if LLM_PROVIDER found + if (envProvider && !process.env.TEMPEST_DEFAULT_PROVIDER) { + process.env.TEMPEST_DEFAULT_PROVIDER = envProvider; + } break; } } @@ -674,7 +685,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; From e1dda33bc403852ea2827c4c03bf29e31732f654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=83=AB=E3=82=B8=E3=83=BC=F0=9F=90=A7?= Date: Fri, 10 Jul 2026 23:06:49 +0700 Subject: [PATCH 08/12] Update setup-api.sh --- scripts/setup-api.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup-api.sh b/scripts/setup-api.sh index bd5c043..7dfae64 100755 --- a/scripts/setup-api.sh +++ b/scripts/setup-api.sh @@ -10,7 +10,7 @@ echo "║ T3MP3ST API KEY CONFIGURATION ║" echo "╚═══════════════════════════════════════════════════════════╝" echo "" -ENV_FILE="$HOME/.t3mp3st/.env" +ENV_FILE="$HOME/T3MP3ST/.env" mkdir -p "$(dirname "$ENV_FILE")" if [ -f "$ENV_FILE" ]; then From 788e90e0ee8aa327e4a8995b4213e8d0f1a03af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=83=AB=E3=82=B8=E3=83=BC=F0=9F=90=A7?= Date: Sat, 11 Jul 2026 05:48:06 +0700 Subject: [PATCH 09/12] Update index.ts --- src/config/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index b50df32..80417c8 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -516,7 +516,7 @@ class ConfigManager { constructor() { this.config = new Conf({ - projectName: 'T3MP3ST', + projectName: 't3mp3st', defaults: DEFAULT_SETTINGS, }); @@ -533,7 +533,7 @@ class ConfigManager { // operators often run T3MP3ST inside target repos, and importing that repo's // secrets would contaminate this process with unrelated credentials. const envPaths = [ - join(homedir(), 'T3MP3ST', '.env'), + join(homedir(), '.t3mp3st', '.env'), join(homedir(), '.env'), ]; @@ -544,6 +544,9 @@ class ConfigManager { const envContent = readFileSync(envPath, 'utf-8'); const lines = envContent.split('\n'); + // Validation variables added + const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'local']; + for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { @@ -557,8 +560,8 @@ class ConfigManager { process.env[key] = value; // Track LLM_PROVIDER for default provider - if (key === 'LLM_PROVIDER') { - envProvider = value; + if (key === 'LLM_PROVIDER' && VALID_PROVIDERS.includes(value.toLowerCase())) { + envProvider = value.toLowerCase(); } } } From 7e946ec92e37aaddd8384ce40e5009d4f6043032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=83=AB=E3=82=B8=E3=83=BC=F0=9F=90=A7?= Date: Sat, 11 Jul 2026 05:49:00 +0700 Subject: [PATCH 10/12] Update setup-api.sh --- scripts/setup-api.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup-api.sh b/scripts/setup-api.sh index 7dfae64..bd5c043 100755 --- a/scripts/setup-api.sh +++ b/scripts/setup-api.sh @@ -10,7 +10,7 @@ echo "║ T3MP3ST API KEY CONFIGURATION ║" echo "╚═══════════════════════════════════════════════════════════╝" echo "" -ENV_FILE="$HOME/T3MP3ST/.env" +ENV_FILE="$HOME/.t3mp3st/.env" mkdir -p "$(dirname "$ENV_FILE")" if [ -f "$ENV_FILE" ]; then From 64efcf63731d4e7eed8cc75b3c39b4f9465fedb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=83=AB=E3=82=B8=E3=83=BC=F0=9F=90=A7?= Date: Sat, 11 Jul 2026 06:04:11 +0700 Subject: [PATCH 11/12] Update index.ts --- src/config/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 80417c8..60e3efa 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -538,15 +538,14 @@ class ConfigManager { ]; 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', 'local']; - + for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { @@ -689,7 +688,7 @@ class ConfigManager { */ getLLMConfig(provider?: LLMProvider, model?: string): LLMConfig { let actualProvider = provider; - + if (!actualProvider) { const envProvider = process.env.TEMPEST_DEFAULT_PROVIDER?.trim(); if (envProvider) { From 7127fe82201c6ae49e5a36e8bed8049e63aa2751 Mon Sep 17 00:00:00 2001 From: psigho Date: Thu, 16 Jul 2026 01:57:09 +0500 Subject: [PATCH 12/12] fix(server): accept same-origin requests from a non-loopback UI bind (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the operator binds the server to a non-loopback address (T3MP3ST_HOST=0.0.0.0 or a LAN IP) they've explicitly opted into network access — the same decision that already stands the anti-rebinding Host guard down. But the CSRF origin guard and the SSE guard were NOT updated to match: they still only trust loopback origins, so the operator's own LAN-served UI (e.g. http://192.168.x.x:3333/ui) had every state-changing request and the event stream rejected with "Cross-origin request rejected". That makes hunts fail on any target from a LAN-accessed War Room (issue #84). Fix: add isSameOriginAsHost() and, ONLY on a non-loopback bind, also trust a request whose Origin/Referer host:port equals this server's own Host header — i.e. a genuine same-origin request from the operator's own UI. A foreign origin (evil.com) still mismatches the Host and is rejected, so the CSRF/drive-by protection is preserved. On a loopback bind the branch is skipped entirely: the strict loopback-only gate plus the Host guard remain fully in force, so there is no new CSRF or DNS-rebinding surface for the default configuration. Verified against a 0.0.0.0-bound instance: - same-origin LAN request (Origin==Host, non-loopback) → passes the guard ✅ - foreign origin (evil.com) → still 403 ✅ - no Origin (curl/CLI) → allowed ✅ Typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/server.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 01b5fb2..e8fe27d 100755 --- a/src/server.ts +++ b/src/server.ts @@ -184,6 +184,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 +237,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.', @@ -4639,7 +4660,10 @@ export function broadcastEvent(event: string, data: Record): 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.',