diff --git a/.gitignore b/.gitignore index b4bd99e..674a84c 100755 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ scripts/.export-denylist.local .aiwg/* !.aiwg/addons/ !.aiwg/addons/** + +server.pid diff --git a/README.md b/README.md index 938e6e1..8ee64fe 100755 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The framework is an 8-operator kill chain, and this table won't blow smoke about | Re-derivable measurement (`verify-claims`) | ✅ Stable | every headline recomputes from committed artifacts | | Recon engine | ✅ Stable | drives nmap / DNS / HTTP / fingerprinting; every finding traces to real tool output | | Mission engine + War Room + Op Admiral | ✅ Stable | keyless through a connected local agent | -| Arsenal, MCP server, HTTP API | ✅ Stable | 35 built-in tools by default; 83 with the opt-in `T3MP3ST_FULL_ARSENAL` (+48 adapters, with the dangerous post-ex drivers — metasploit, hydra — behind a human-approval gate) — both counts re-derive via `verify-claims`. `security_recon` over MCP | +| Arsenal, MCP server, HTTP API | ✅ Stable | 35 built-in tools by default; 102 with the opt-in `T3MP3ST_FULL_ARSENAL` (+67 adapters, with dangerous/catalog-only drivers — metasploit, hydra, pacu, frida — behind narrow approved paths rather than generic execution) — both counts re-derive via `verify-claims`. `security_recon` over MCP | | Egress-scope containment | ✅ Stable (on by default) | once a mission target is set, built-in networked tools refuse off-scope public hosts — not the target/subdomains, not loopback/private (`SCOPE DENIED`) — a tightened default, not a bare tool runner | | Coordinated-disclosure pipeline | ✅ Stable | OSV novelty + live PoC + refuter panel + CVSS; drafts only, a human sends | | White-box source analysis | ⚠️ Experimental | Python-only regex ingest; multi-model decomposition costs more tokens, not fewer | diff --git a/scripts/verify-claims.mjs b/scripts/verify-claims.mjs index 8f2f30d..6edb7b7 100644 --- a/scripts/verify-claims.mjs +++ b/scripts/verify-claims.mjs @@ -159,9 +159,9 @@ check('VERIFY gate implemented', /VERIFY gate|never appeared in tool output/.tes check('REFLECT gate implemented', /REFLECT gate/.test(bench), 'forced mid-run pivot'); // ── CLAIM 4: capability breadth ───────────────────────────────────────────── -console.log('\nCLAIM 4 — capability: 83 tools, 8-operator kill-chain'); +console.log('\nCLAIM 4 — capability: 102 tools, 8-operator kill-chain'); // DISTINCT tools = external-binary adapters (catalog.ts TOOL_ADAPTERS id:) + custom -// built-in tools (index.ts BUILTIN_TOOLS top-level name:). NOT a name:/id: regex count +// built-in/external tools (index.ts top-level name:). NOT a name:/id: regex count // (that double-counts each tool's id+name AND every parameter name). const catalogSrc = fs.existsSync(R('src/arsenal/catalog.ts')) ? fs.readFileSync(R('src/arsenal/catalog.ts'), 'utf8') : ''; const indexSrc = fs.existsSync(R('src/arsenal/index.ts')) ? fs.readFileSync(R('src/arsenal/index.ts'), 'utf8') : ''; diff --git a/src/__tests__/arsenal-count-honesty.test.ts b/src/__tests__/arsenal-count-honesty.test.ts index 0616dd5..476e420 100644 --- a/src/__tests__/arsenal-count-honesty.test.ts +++ b/src/__tests__/arsenal-count-honesty.test.ts @@ -11,7 +11,7 @@ import { // ───────────────────────────────────────────────────────────────────────────── // ARSENAL COUNT HONESTY — the "advertised = real, and nothing is silently dead". // -// The arsenal advertises "83 tools". verify-claims defends that number by counting +// 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: @@ -20,7 +20,8 @@ import { // 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), +// 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. // ───────────────────────────────────────────────────────────────────────────── @@ -33,16 +34,16 @@ 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', () => { + 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 83 (adapters=${TOOL_ADAPTERS.length}, ` + + `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(83); + ).toBe(102); expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate }); @@ -60,11 +61,12 @@ describe('arsenal count honesty (advertised = real registered surface)', () => { 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)', () => { + 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); bloodhound is import_only (collector output is imported, never run here). - expect(gated.map((a) => a.id).sort()).toEqual(['bloodhound', 'hydra', 'metasploit']); + // 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) { diff --git a/src/__tests__/local-agent-path-resolution.test.ts b/src/__tests__/local-agent-path-resolution.test.ts index 74b34cb..df3574c 100644 --- a/src/__tests__/local-agent-path-resolution.test.ts +++ b/src/__tests__/local-agent-path-resolution.test.ts @@ -11,9 +11,8 @@ * 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-only by design: `resolveBin` short-circuits on win32 (Windows resolution — where.exe / - * .cmd shims — is PR #18's domain, deliberately not duplicated here). The win32 case is asserted - * via a platform stub so the boundary is documented and locked. + * 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'; @@ -155,7 +154,7 @@ describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', () expect(resolveBin('definitely-not-a-real-cli-xyz')).toBe('definitely-not-a-real-cli-xyz'); }); - it('does NOT scan on win32 — returns the bare name (Windows resolution is PR #18\'s domain)', () => { + 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; @@ -163,7 +162,7 @@ describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', () const orig = Object.getOwnPropertyDescriptor(process, 'platform') || { value: process.platform, configurable: true }; Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); try { - expect(resolveBin('faketool')).toBe('faketool'); + expect(resolveBin('faketool')).toBeUndefined(); } finally { Object.defineProperty(process, 'platform', orig); } diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index cf6b5ed..9fcea39 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 auth under %LOCALAPPDATA%\hermes\ rather than ~/.hermes/. + * - 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). @@ -147,7 +157,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] : [])], }, ]; @@ -182,6 +195,10 @@ export interface AgentDetection { ready: boolean; // installed && authed } +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). * @@ -221,16 +238,28 @@ function wellKnownBinDirs(home: string): string[] { } /** - * Absolute path to `bin`, resolved WITHOUT a shell against PATH plus the well-known install dirs - * (issue #78). Fail-open: an unresolved name is returned unchanged so `execFile`/`spawn` still throw - * the same ENOENT (→ `installed:false`) they did before — no behavior change when a CLI is truly - * absent. Pure-fs (no shell) so it can't hang or be injected, and it uses an X_OK access check to - * match `execFile`'s own executable-file semantics (a non-executable name-collision is skipped, not - * returned). win32 is intentionally NOT handled here (where.exe / .cmd-shim resolution is PR #18's - * domain) — the bare name is returned so Node's own %PATH%/PATHEXT lookup stays in effect. + * 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 { - if (process.platform === 'win32' || bin.includes('/')) return bin; +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 @@ -248,6 +277,23 @@ export function resolveBin(bin: string): string { 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 } { for (const a of spec.authArtifacts) { if (existsSync(expand(a))) return { authed: true, method: 'file' }; @@ -269,29 +315,45 @@ function detectOne(spec: AgentSpec): Promise { id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin, blurb: spec.blurb, invokeHint: spec.invokeHint, }; - // Resolve against PATH + well-known install dirs so a Finder/desktop launch's minimal PATH - // doesn't hide an installed CLI (issue #78). Absolute when found; the bare name otherwise - // (→ execFile throws the same ENOENT → installed:false, preserving the true-absence path). - const exe = resolveBin(spec.bin); + // 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(exe, 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: exe !== spec.bin ? exe : undefined, - 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(); + } }); } @@ -301,7 +363,15 @@ 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)); + 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 { @@ -340,11 +410,10 @@ export function runLocalAgent( const maxChars = opts.maxChars ?? 4000; // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). const env = childEnv(); + const resolvedBin = resolveBin(spec.bin) || spec.bin; return new Promise((resolve) => { // stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait). - // resolveBin: same PATH-robust resolution the detector uses, so a CLI detected under a minimal - // launchd PATH also SPAWNS (detected-but-unspawnable would be worse than undetected) — issue #78. - const child = spawn(resolveBin(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; @@ -406,9 +475,9 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin } 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) => { - // resolveBin: PATH-robust resolution (issue #78) — see runLocalAgent. - const child = spawn(resolveBin(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;