From 6ba02ec50d9a68ea0f286acb70a3f71ffa237e47 Mon Sep 17 00:00:00 2001 From: psigho Date: Sun, 5 Jul 2026 17:39:53 +0500 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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;