Enable authenticated scans across the HTTP arsenal while binding configured credentials to an exact target origin.
Includes case-insensitive per-request overrides, cross-origin redirect containment, protected curl configuration, result redaction, documentation, and regression coverage.
Co-authored-by: Fred Blaise <fred.blaise@protonmail.com>
* feat(ops): add portable War Room preflight command
Add npm run ops:preflight that checks operational readiness before
launching a War Room mission: API health, active mission status,
pending action receipts, local-agent/provider configuration, and
arsenal tool availability. Follows same pattern as scripts/doctor.mjs.
Supports --json for machine-readable output and --strict to fail
on warnings.
Fixes#35
Signed-off-by: Shivam Singh <shivamsingh-007@users.noreply.github.com>
* chore: revert package-lock.json churn to reduce diff noise
Restores package-lock.json to upstream main version.
Removes unintended npm-version lockfile rewrite (peer flips, @emnapi/* entry shifts).
* fix(ops-preflight): correct response-shape reads for mission, approvals, and agent checks
Three bugs identified by jmagly in review:
1. Mission-active: API returns {active: boolean}, not {phase}
2. Pending-approvals: API returns {approvals: [...]}, not a flat array
3. Local-agent: API returns {connected: [...]} (array), not a boolean
All three verified against live endpoints per review feedback.
Signed-off-by: Shivam Singh <shivamsingh-007@users.noreply.github.com>
* fix(ops): harden portable preflight checks
Signed-off-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
---------
Signed-off-by: Shivam Singh <shivamsingh-007@users.noreply.github.com>
Signed-off-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
Co-authored-by: Shivam Singh <shivamsingh-007@users.noreply.github.com>
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
Two War Room UI fixes (docs/index.html):
1. The floating "guided tour" relaunch button (#t3mpTourRelaunch) was
position:fixed pinned bottom-left, landing on top of the sidebar
"Connected" status pill and hiding its first character. Moved it to
bottom-right (the conventional help-FAB corner) so it collides with
nothing.
2. Added a theme switcher in the sidebar footer with four themes: Storm
(the existing teal/green default, visually unchanged), Ember (amber),
Crimson (red), and Void (violet). The choice persists in localStorage
and is applied before first paint to avoid a flash of the default theme.
To make themes reskin the whole UI rather than only the ~87 var(--accent)
sites, the dominant hardcoded brand colors (#00ff88 / #2fffd2 / #00d4ff
and their rgba() forms) were converted to CSS variables -- but only
inside <style> and inline style="..." attributes, where var() is valid.
Bare JS string literals and SVG fill= attributes are left as-is; most are
semantic status colors (green=ready, red=error) that should stay fixed.
No JS logic changed; the page renders and all scripts run unchanged.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the JSON-scanner parser layer: wire semgrep, gitleaks, trivy and
grype into parseToolOutput so their findings become structured, gate-verifiable
evidence instead of raw text (they were parserStatus:'planned').
- semgrep (scan --json) → one finding per rule match; ERROR/WARNING/INFO
mapped to severity; CWE token pulled from
metadata (e.g. "CWE-78: …" → CWE-78); refs → remediation.
- trivy (fs --format json) → one finding per package vuln; CVE + CweIDs +
"upgrade <pkg> to <fixed>" remediation.
- grype (dir:. -o json) → one finding per match; CVE + package coordinates.
- gitleaks (detect --report json --redact) → one high finding per secret. Defence in
depth: the details are run through redactString()
so a raw secret can NEVER reach a finding / the vault,
even though the adapter already passes --redact.
Broadens the severity map to cover scanner vocabularies (ERROR/WARNING, CRITICAL…
NEGLIGIBLE, moderate) onto the five canonical levels. Flips parserStatus to
'structured' for the four tools. Extends parsers.test.ts + fixtures: field mapping,
the gitleaks redaction guarantee, and the end-to-end gate pass for every new scanner.
Stacked on the parser layer (#42); pairs with the scanner-invocation fix (#40) that
gives these tools the JSON-emitting arg templates. Until #40 lands the parsers simply
degrade to [] (no JSON to parse) — no misbehaviour. Secret scanners beyond gitleaks
and SARIF-style tools remain a follow-up.
Co-authored-by: Pazificateur69 <agagliardi@guardiaschool.fr>
The generic adapter factory returned raw stdout only (`adapter-tools.ts` success
path), so 35 catalogued scanners with `parserStatus: 'planned'` never populated the
structured `ToolResult.findings` channel — their output was dropped on the floor and
the agent could neither verify nor chain on it. The whole downstream pipe already
exists and is honest-by-construction: the agent loop stamps `provenance:'tool'` + the
raw output, the operator materialises each finding with output-evidence, and the live
gate (`evidence/gate.ts`) marks it verified.
Add `src/arsenal/parsers.ts` — `parseToolOutput(toolId, rawStdout) → ToolFinding[]`
for the five scanners that already emit machine-readable JSON via their existing arg
templates (zero template changes): nuclei (-jsonl), httpx (-json), dalfox (--format
json), ffuf (-of json), katana (-jsonl). Parsers are pure, dependency-free, and NEVER
throw: empty/garbled/unknown input yields `[]`, never a fabricated finding (same
discipline as stub-honesty / no-phantom-tools). The factory attaches parsed findings
while ALWAYS keeping the raw stdout as `output` — the evidence of record the gate checks.
- nuclei/dalfox emit CWE, which the tool channel dropped: add optional `cwe?: string[]`
to `ToolFinding` and thread it through the operator's finding materialisation.
- flip `parserStatus: 'planned' → 'structured'` for httpx, dalfox, katana (nuclei/ffuf
were already structured).
Adds `src/__tests__/parsers.test.ts` + fixtures: per-scanner field mapping, the
honesty contract (empty/garbled/adversarial → [], no throw), a factory-wiring check
that a minted adapter returns findings while preserving raw stdout, and an end-to-end
assertion that every parsed finding PASSES `gateLiveFinding` with provenance='tool'.
The minted-adapter path is gated behind T3MP3ST_FULL_ARSENAL, so the benchmarked
default arsenal is untouched. Supply-chain JSON tools (semgrep/gitleaks/trivy/grype)
and secret scanners (need redaction) are a clean follow-up.
* fix(ui): reset War Room to idle when a hunt is declined or aborted
A declined-approval hunt ("hunt not started") — or the red-X abort — left the
War Room stuck advertising an active run: the ENGAGE button stayed on the
"ENGAGING…" spinner (disabled), the LIVE fidelity banner kept claiming "real
tools probing the target", and the narration rail kept showing "Looking at your
target". completeMission() only toggled the button's display, never restoring
its label/disabled/opacity, and never cleared the fidelity banner or narration
rail; abortMission() had the same gap and no-op'd entirely when nothing was
"running" (so the red X did nothing on a declined hunt).
Add resetEngageUI(): restores the ENGAGE button (▶ ENGAGE, enabled) and clears
the fidelity banner (setFidelity('off')) + narration rail. Route both
completeMission() and abortMission() through it, and let the red X clear a
leftover engaging/probing UI even when missionRunning is already false.
Client-side only; no behavior change to an actually-running mission.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): clear stale substatus when the red X resets an idle War Room
Review nit: the idle-abort branch set cmdMissionName='STANDBY' but left
cmdMissionStatus unchanged, so after an approval-declined the header could read
STANDBY with a stale red "approval declined" line beneath it. Reset the
substatus to the initial idle state ("Awaiting orders", #666) too.
Verified: abort after a decline now shows STANDBY / "Awaiting orders" (grey).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): route OBSIDIVM benchmarks through any backend, not OpenRouter-only
The OBSIDIVM benchmark/self-improvement UI ran its own client-side,
OpenRouter-only LLM path: getApiKey() read only the OpenRouter key, every
call hardcoded openrouter.ai, and the gate hard-blocked with "Configure
OpenRouter API key first" — so a configured Venice key was ignored and a
connected local agent was never used.
Add a client-side backend resolver (OpenRouter key -> Venice key -> connected
agent -> server LLM) and route all benchmark executors, judges, and gates
through it:
- resolveLLMBackend() + LLM_BACKEND_HINT; safeLLMCall()/_safeLLMCallOnce()
become backend-aware (Venice uses api.venice.ai with a Venice-valid model;
OpenRouter unchanged). _backendCall() routes keyless backends through the
existing server endpoints: /api/agents/local/dispatch (drives the connected
Claude Code/Codex/Hermes agent) and /api/llm/chat (server LLM).
- runLiveBenchmark, ctfCallLLM, the self-improvement analyzer, and the config
optimizer now call safeLLMCall instead of a hardcoded openrouter.ai fetch.
- Live-Test / improvement-loop gates and runLLMJudge accept any backend.
Client-side only; no server changes (endpoints already existed). The
recon-only SPECTRA helper is intentionally left OpenRouter-only (out of scope).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): keyless routing must use the API base URL (cross-origin) + model consistency
Addresses review feedback on the OBSIDIVM backend-routing PR.
1. Relative keyless URLs broke the primary cross-origin deployment. _backendCall
fetched '/api/agents/local/dispatch' and '/api/llm/chat' as RELATIVE URLs, but
when the static War Room (GitHub Pages docs/) talks to a separate backend the
app sets T3MP3ST_API.baseUrl = http://<host>:3333. There the relative fetches
hit the Pages origin → 404/CORS, so the headline local-agent / server-LLM
routing silently failed (worked only same-origin). Fix: prefix
`${T3MP3ST_API.baseUrl}` on both; empty base stays relative for same-origin.
Verified: with baseUrl set, both now resolve to http://<host>:3333/api/...
2. Model default consistency. Four benchmark fallbacks read
'anthropic/claude-opus-4.8', out of step with the state default, the <option>,
renderModels and saveApiKey (all 'claude-opus-4.6'). Reverted to 4.6 to match.
(The /api/agents/local/dispatch server route exists — src/server.ts.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): backend-aware gates on the last OpenRouter-only benchmark paths
Addresses the re-review on this PR: two flows this PR claims support Venice /
connected local agent / server LLM still hard-gated on an OpenRouter key.
1. runSingleBenchTest() (single benchmark click): replaced the getApiKey()
length check with `resolveLLMBackend().kind === 'none'` (+ LLM_BACKEND_HINT),
matching runBenchmarks('live'). A Venice-only / agent / server backend can now
click-run a single test instead of being blocked before safeLLMCall().
2. Self-improvement auto-apply:
- autoApplyForLoop() now falls back to applyFallbackImprovements() only when
NO backend exists (resolveLLMBackend().kind === 'none'), not merely when the
OpenRouter key is empty — so the optimizer step uses Venice/agent/server.
- autoApplyRecommendations() entry gate uses the backend check.
- The Auto-Apply button show/hide (displayAnalysis) uses `hasBackend =
resolveLLMBackend().kind !== 'none'` instead of an OpenRouter-only key test.
Verified: gates pass for venice/agent/server, block only when kind==='none';
node --check clean on the inline scripts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Settings agent card's render() set window.__t3mpLiveAgents from CLIENT-side
pings only (Object.keys(pingStatus).filter(ok)). Agents connected server-side —
via autoReconnect (ping:false) or a status?check=1 re-ping — are live on the
server even though this client never pinged them, so every re-render reset the
count to 0. Since upstream's t3mpHasBackend() is now `llmAvailable || live > 0`,
that clobber made the War Room show "No LLM backbone — connect an agent before
hunting" on localhost even with a genuinely-live local agent (repro: 3 agents
connected, hermes lastPing.ok on the server, yet hasBackend=false after any
detect/render).
Fix: live = max(client-pinged-ok, server-reported-live), reading the server's
own agent state (window.__t3mpServerAgents, refreshed via status?check=1). The
summary and the global now both reflect reality and re-renders no longer break
the gate. Verified: hasBackend stays true across detect/connect re-renders.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A T3MP3ST server launched from Finder, the desktop app, or any non-interactive
shell inherits launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). The
local-agent detector resolved `claude`/`codex`/`hermes` against that PATH via
execFile, so an installed, authed CLI in ~/.local/bin (Claude Code's native
installer), Homebrew, npm-global, or a version-manager shim (mise/nvm/fnm) threw
ENOENT and was reported installed:false — the Settings "Connect Local Agents"
checkboxes went dead and it read like a UI bug.
Add resolveBin(): pure-fs (no shell) resolution over PATH plus a curated set of
well-known install dirs under the real agent home (agentHome(), so a HOME
redirect can't hide them), with an executable-file access check and fail-open to
the bare name — an unresolved name yields the same ENOENT → installed:false as
before, so true-absence detection is unchanged. Route the detector and both spawn
sites (runLocalAgent, localAgentChat) through it, so a CLI detected under a
minimal PATH also actually spawns. Bound the keychain auth-probe with a 2s
timeout so a locked/headless keychain (the same non-interactive launch context)
can't hang detection.
POSIX/macOS-scoped: resolveBin short-circuits on win32 — Windows resolution
(where.exe / .cmd shims) is handled by the complementary open PR #18.
Verified on macOS: RED->GREEN unit + integration tests (resolver, detector
wiring, and both spawn sites), and manual QA driving the real Web UI under a
reproduced minimal PATH (Claude Code flips from "Not installed" to detected).
Refs #78 — fixes the macOS/POSIX variant. The original report was a Windows
Sandbox environment (PR #18's domain), so this intentionally does not auto-close
that issue.
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 <noreply@anthropic.com>
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 <prompt>`, codex `exec <prompt>`) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>