* feat(llm): resume the Claude Code session across a kill-chain phase
Every ReAct iteration flattens the growing transcript into a brand-new
prompt and spawns a fresh `claude -p` process. Within one task that is
a quadratic resend; across a mission it also means each spawn re-pays
Claude Code's own CLAUDE.md/skills/MCP bootstrap tax from scratch,
since T3MP3ST never told the CLI these calls belong to one session.
LocalAgentAdapter now tracks the Claude Code session id from the JSON
envelope and passes it back in with --resume on every later call, so
the CLI carries the accumulated transcript itself and the repeated
part is billed at cache-read pricing instead of resent. The session
is scoped to one kill-chain phase: TempestCommand drops it for every
operator right after advancePhase(), since local-agent operators are
spawned once for the whole mission and would otherwise carry one
session across all 7 phases. A stale or expired session id fails fast
(confirmed against the real CLI: nonzero exit, no JSON on stdout) and
localAgentChat retries once without --resume rather than failing the
task over it.
Verified against the real CLI: a resumed call recalled prior context
correctly and dropped from $0.151 to $0.008 (cache_read_input_tokens
absorbing the prior turn instead of cache_creation), and an invalid
--resume id triggered the fallback and still succeeded. Ran live
against an authorized target: the same session id held across all 4
recon tasks in one phase, then a newly spawned operator's first call
in the next phase carried no --resume, confirming both the resume and
the reset.
Full suite: 764/764, no regressions.
* fix(llm): send only the delta on a resumed Claude Code call
jmagly's PR #149 review caught it: LocalAgentAdapter.chat() built its
prompt from the full messages array unconditionally, so a resumed
call sent the whole growing transcript again on top of a session that
already had it. The resend the feature exists to remove was still
happening, and the resumed session's own history now grew on top of
it too. Existing tests only asserted that sessionId reached the CLI,
never what was actually in the prompt, so this passed green.
LocalAgentAdapter now tracks the exact messages array object and how
many of its entries were already sent. A later call against the SAME
array (AgentLoop growing one task's transcript via push) sends only
the new tail. A DIFFERENT array (a new task) still sends everything,
since the resumed session has never seen that task's content even
though the CLI session itself carries over. The stale-session
fallback in localAgentChat now takes a separate fallbackPrompt: a
fresh session has no history, so its retry gets the full transcript,
never the delta sized for the failed resumed attempt.
Verified against the real CLI with a synthetic multi-round exchange
(~750 tokens of padding added between each call): promptTokens went
19127 -> 19625 -> 20135 -> 20645, a flat ~510 tokens per round
regardless of the accumulating transcript, instead of resending or
compounding. Added the regression coverage the review asked for: a
same-array resumed call excludes the prior turn's content while
retaining the tool contract, a new-array call still gets the full
prompt, and the fallback retry receives fallbackPrompt rather than
the delta, checked both at the LocalAgentAdapter call-shape level and
against a real spawned CLI script.
Full suite: 769/769, no regressions.
* fix(test): import LLMMessage in local-agent-tool-calling.test.ts
Hosted CI caught it, not my local run: the previous commit used
LLMMessage[] type annotations in two new tests without importing the
type, so tsc failed with TS2304 while vitest itself stayed green
(esbuild strips type annotations without checking them). I ran
typecheck once before writing those tests and never reran it after,
so this went out uncaught.
Full suite: 769/769. npx tsc --noEmit: clean.
* fix(llm): isolate per-operator Claude sessions and narrow the fallback
A self-review after the last round turned up a more serious version of
what jmagly caught: TempestCommand built exactly one LLMBackbone and
handed the SAME instance to every operator's AgentLoop (and to each
OperatorAgent's own decompose-on-failure path). LocalAgentAdapter's
new session/delta state lived on that shared instance, so one
operator's Claude Code session could get resumed by a completely
different operator, with the second operator's unrelated task content
sent into the first operator's conversation. Every test for the
resume feature so far constructed independent LLMBackbone instances
per test, which is not how spawnOperator() actually wires things, so
nothing caught it.
spawnOperator() now builds a fresh LLMBackbone per operator from the
same config, used for both that operator's AgentLoop and its own
decompose fallback, so state stays consistent within one operator and
isolated from every other one. OperatorCell.spawnOperator() takes an
optional llm override to carry that through; existing callers that
don't pass one keep the cell's shared default.
Two narrower issues from the same review:
localAgentChat's stale-session fallback retried on ANY failure of a
resumed call, not just a confirmed stale session. A real network
error or auth failure would silently double latency on a retry that
could not fix it, and compounded with LLMBackbone's own retry ladder
into up to 6 CLI spawns for one failing turn. The fallback now only
fires for the exact empirically-confirmed stale-session error text
("No conversation found with session ID").
The character-estimate usage fallback (used only when the JSON
envelope fails to parse) estimated from the delta prompt instead of
the full transcript. A resumed call's real promptTokens reflects the
whole cached conversation, confirmed empirically in the last round
(19127 -> 19625 -> 20135 -> 20645), so estimating from the small delta
text instead undercounted the budget check by an order of magnitude
in that one edge case.
One finding from the review was deliberately left alone: resending
the tool contract on every delta call is real overhead, but jmagly's
review explicitly required the contract stay on every call ("retaining
... the tool contract") and the existing test already pins that. Cutting
it would contradict that requirement for a small, bounded savings.
New coverage: an isolation test that spawns two operators through a
real TempestCommand and confirms neither's LocalAgentAdapter nor its
session leaks into the other, a test proving a non-stale failure
propagates without a fallback retry, and a test proving the usage
estimate reflects the full transcript on a mid-session parse failure.
Full suite: 772/772, no regressions.
* fix(llm): reset the Claude session at every task boundary
jmagly's third review: switching from delta to full-prompt sending on
a new messages array wasn't enough, since LocalAgentAdapter.chat()
still passed the OLD claudeSessionId either way. A second task on the
same operator inherited the first task's persisted Claude session,
carrying its system prompt, target data, and tool output (including
anything attacker-controlled) across a boundary T3MP3ST already has a
single sanctioned channel for: PackBoard.
chat() now clears claudeSessionId whenever the incoming messages
array is not the one it last saw, before building the request. A
different array means AgentLoop.run() started a new task; the call
after that reset carries no --resume, so it opens a genuinely fresh
session instead of resuming the old one under a bigger prompt. Within
one task the array is the same object across iterations (AgentLoop
grows it via push), so resumption and the delta-only send from the
last round are untouched.
Rewrote the tests that asserted continuity across separate array
literals, since that shape doesn't happen in AgentLoop and was masking
this exact gap: they now grow one array via push to represent a real
task, matching the pattern already used for the delta tests. Added
the test jmagly asked for directly: one operator, two sequential
tasks, task 2 has no session id, both at the LocalAgentAdapter level
and through a real TempestCommand.spawnOperator().
Full suite: 773/773, no regressions.
---------
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
* feat(llm): add native Novita AI provider via OpenAI-compatible endpoint
Wire Novita AI (https://api.novita.ai/openai/v1) as a first-class LLM
provider, following the same pattern used for DeepSeek/HuggingFace/NanoGPT:
dispatched through the existing OpenAIAdapter, resolved via NOVITA_API_KEY,
with a default model, model catalog entries, config/setup wiring, and
regression tests.
* fixup(llm): use deepseek/deepseek-v4-flash-0731 as the second Novita model
Swap the curated Novita model catalog's second entry from
qwen/qwen3-coder-30b-a3b-instruct to deepseek/deepseek-v4-flash-0731 per
requested model selection. Verified live against api.novita.ai/openai/v1/models.
* fixup(llm): replace superseded DeepSeek Flash id with current Pro flagship
QA flagged that deepseek/deepseek-v4-flash-0731 has been superseded in the
live Novita catalog; deepseek/deepseek-v4-pro-0813 is now the current
serving DeepSeek flagship. Swap the second Novita model catalog entry
accordingly.
---------
Co-authored-by: jax-novita <jax-novita@users.noreply.github.com>
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
* test(obsidivm): RED — bridge should give an actionable error when the service is down
The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not
running the bridge throws a bare 'fetch failed' (surfaced as 'FATAL: fetch
failed'), naming neither the service, the URL, nor how to start it. Pins that a
connection failure becomes an actionable, self-service error.
Fails on main: the message is 'fetch failed'.
Refs #156
* fix(obsidivm): actionable error when the range service is unreachable
The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not
running the bridge's shared call() let the raw 'fetch failed' propagate, which
surfaced as an opaque 'FATAL: fetch failed' (#156) naming neither the service,
the URL, nor how to start it — even in --hunter stub, whose grading step still
needs it. Wrap the fetch in call() (the single choke point for every OBSIDIVM
request) so a connection failure becomes: 'OBSIDIVM service unreachable at <url>
... Start it per docs/OBSIDIVM.md (python3 range.py, listens on :4200), or set
OBSIDIVM_URL', flagged err.unreachable to distinguish it from an HTTP-status
error; a timeout is reported distinctly. Clarify in the bench --help/usage that
every hunter mode (stub included) needs the OBSIDIVM service, live/t3mp3st also
a key. Adds scripts/obsidivm-bridge.d.mts so the new test types cleanly.
Closes#156
* fix(obsidivm): distinguish invalid-URL from unreachable; test timeout path
Adversarial review of the diff found two gaps:
- A malformed OBSIDIVM_URL (ERR_INVALID_URL) was folded into the 'service
unreachable — is it running?' path, misdirecting the user to start a service
when the real fix is the URL. Now branched to a distinct 'Invalid OBSIDIVM
base URL … set OBSIDIVM_URL' error, no err.unreachable flag.
- The timeout (AbortError) branch had no coverage. Added a test using a
never-responding TCP server + short timeoutMs asserting 'timed out after Nms'
and NOT unreachable; and a test for the invalid-URL branch.
Also completed the .d.mts stub with the cloudgoat* methods it was missing.
Refs #156
Make the contributor, CI, package, and tagged-release contracts agree with the shared BT6 baseline while preserving T3MP3ST's project-specific integrity checks. Exclude workspace-only material from published packages and keep local-agent detection tests host-independent.\n\nCloses #159
* test(binary): RED — no rule for memcpy/memmove with a non-constant length
Adds two corpus fixtures + direct assertions for a memcpy/memmove sink:
- memcpy-wire-length (bad): a wire-decoded length into a fixed buffer,
expect B-MEMCPY. Fails on main — the ruleset has no memcpy rule.
- memcpy-bounded-control (clean): two safe memcpys (sizeof-bounded + a
constant length). This forces a DISCRIMINATING rule: a naive /memcpy/
match false-positives here and fails the zero-false-positive gate.
Refs #150
* feat(binary): detect memcpy/memmove with a non-constant length
Adds B-MEMCPY to the decompiled-vuln ruleset — the classic unbounded-copy
overflow. The framework's own reasoning layer treats this as its textbook
target (src/orchestration/prompts.ts:60,80 — 'a wire-controlled length flowing
into an unchecked memcpy'; orchestration/index.ts:11), but the static
pre-filter had no rule for it: strcpy was covered, its memcpy(dst, src, len)
sibling was not.
The rule inspects the length (3rd) argument and fires only when it is
non-constant — a variable, struct field, deref, or computed/decoded value —
staying silent on a sizeof(...) or numeric-literal length. Validated against a
22-case dangerous/safe matrix (perfect discrimination). Corpus 6+2 -> 7+3; the
new sizeof/constant-bounded control keeps the rule honest — a bare /memcpy/
match would false-positive on it.
Closes#150
* fix(binary): stop B-MEMCPY false-positive on nested-call args; disclose limits
Adversarial review found a real false positive: a comma inside a nested call in
arg 1/2 (memcpy(dst, get_src(a,b), sizeof(dst))) mis-split the naive
[^,]+,[^,]+, arg list, so a sizeof-BOUNDED call fired. Restricting args 1-2 to
[^,()]+ (paren-free) removes it — verified the bounded nested-call form is now
silent while every common non-constant length still fires. The trade is a
memcpy whose 1st/2nd arg is itself a call is not matched (an acceptable miss for
a directional pre-filter, now disclosed).
Rule comment rewritten to honestly enumerate the directional misses (leading-
digit computed length, sizeof-plus-term, nested-call args, inline comments,
__memcpy_chk/memcpy_s/wmemcpy variants). Test expanded into an in-tree
discrimination matrix (fires / silent / pinned disclosed-limit cases) so the
scope boundary is auditable and any future tightening surfaces deliberately.
Refs #150
* fix(binary): match pointer casts in B-MEMCPY args; correct disclosed limits
A second adversarial pass on the shipped rule caught that the previous
paren-free tightening ([^,()]+) over-corrected: it also stopped matching
pointer CASTS on args 1-2 — memcpy((void *)dst, (void *)src, len) — which are
the most common memcpy shape in real decompiled output. The rule was silently
missing the main case.
Replace the arg matcher with one level of BALANCED parens,
(?:[^,()]|\([^()]*\))+, so a cast (void *)dst and a comma-bearing call
get(a,b) are each consumed as ONE arg. This fires on casts again, still fires on
a call-bearing arg with a non-constant length (now a true positive, previously a
disclosed miss), and keeps the original nested-call-with-bounded-length false
positive silent. Verified no catastrophic backtracking on pathological input.
Also corrects the disclosed-limits comment: the previous 'arg is itself a call'
miss is now only DOUBLY-nested parens, and the inline-comment note was
backwards (a comment obscuring a sizeof/literal false-fires, it does not cause a
miss) — restated accurately, with decompiled output not emitting inline comments
noted. Tests pin the cast fires and the variant exclusions.
Refs #150
* fix(llm): populate usage for the Claude Code local-agent path
LocalAgentAdapter.chat() never set `usage` on its LLMResponse, so
AgentLoop's token budget check (tokensUsed >= maxTokens) silently
never fired for Claude Code missions. maxIterations was the only
brake, and it bounds turn count, not actual spend.
Claude Code's --output-format json (instead of text) returns exact
per-call token counts, including prompt-cache creation/read tokens
that a text-length estimate has no way to see. localAgentChat now
requests that format for the claude agent id only. LocalAgentAdapter
parses the envelope into usage.promptTokens/completionTokens, with a
character-based estimate as a fallback if parsing fails, so usage
never goes back to undefined.
Verified directly against the CLI: a live run now reports
promptTokens 24458, completionTokens 1662 instead of undefined/0.
Full suite: 675/675, no regressions.
Fixes#139
* test(llm): harden Claude usage fallback
---------
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
* test(ingest): RED — JS/TS arrow and function-expression definitions are not extracted
Pins issue #141 in both directions: every named-value function form must be
extracted with its name, parameters and span, and the shapes carrying no
name-to-function binding (IIFE, destructured, HOC-wrapped) must stay unmatched.
Also covers the end-to-end consequence — an arrow-bound sink ranking
attack_surface, and an arrow-bound handler becoming an entry point.
Fails on main: the .js/.ts/.tsx def-queries bind declarations only.
Refs #141
* feat(ingest): extract JS/TS arrow and function-expression definitions
The .js/.ts/.tsx def-queries bound declarations only, so every definition bound
to a value produced no CodeBlock and its sinks never reached classification or
ranking. Adds the name-to-function-value shapes to all three queries:
const f = (a) => {} / function (a) {} variable_declarator
const f = a => ... un-parenthesized single param
obj.f = ... / module.exports.handler assignment_expression
onmessage = e => ... bare-identifier assignment
{ f: (a) => {} } pair
class K { f = (a) => {} } field_definition / public_field_definition
Query-only — no parser, pipeline or CodeBlock-shape change. nodeToCodeBlock
already handles any @def/@name/@params triple and kindOf maps these to
'function'. Property-assigned and object-literal forms are named by the
property, consistent with how method_definition is named: that is the name
buildCallGraph and the entry-point heuristic match on.
Not matched, by design: HOC-wrapped values (const h = withAuth(fn)) and
anonymous callbacks (app.get('/x', (req,res) => ...)) have no name-to-function
binding, and inventing one is worse than honest absence. whitebox.ts caveat
block updated to match.
Refs #141
* fix(ingest): capture #private class-field arrows; keep same-line block ids distinct
Two defects found by adversarial review of the arrow-extraction change:
- `class C { #handler = (x) => eval(x) }` yielded nothing: a private field's
name is a `private_property_identifier`, not the `property_identifier` the
class-field pattern required. A sink in a private handler is exactly as
dangerous as one in a public handler, so it is captured (name keeps the `#`).
- A block id is `path::name@line`, so two same-named definitions starting on the
same line collide, and buildCallGraph keys on that id — the two blocks silently
share one callers/callees entry even when only one carries a sink. Rare for
declarations, routine once value-bound functions are extracted (minified
bundles pack many `{ handler: … }` onto one line). The later block is now
disambiguated by column; the first keeps the plain id, so no existing id
changes and the Python path is untouched.
Refs #141
* feat(arsenal): add subdomain-takeover classifier + fingerprint table
Pure, I/O-free detection logic split into src/arsenal/takeover.ts so it is fully
unit-testable without DNS or network:
- TAKEOVER_FINGERPRINTS: curated CNAME + unclaimed-resource-body signatures for 15
high-frequency services (S3, GitHub Pages, Heroku, Azure, Fastly, Shopify, Netlify, ...)
- classifySubdomainTakeover(): conservative verdict (confirmed / potential / none) driven by
a dangling CNAME and/or a service body fingerprint; no cross-service false matches
- renderTakeoverReport(): human-readable output block
* feat(arsenal): register subdomain_takeover_check built-in + bump tool count 108->109
- New keyless, self-contained built-in tool 'subdomain_takeover_check' (recon): resolves the
CNAME, checks whether the target still resolves (dangling), fetches the live body via the
scope-gated targetFetch, and classifies via classifySubdomainTakeover. Emits a real finding.
- Added to the Recon operator's default toolkit so the swarm can reach it (operator-toolkits
coverage invariant).
- Bumped the advertised arsenal size 108 -> 109 in lockstep across the count-honesty test,
README, and verify-claims (73 adapters + 36 built-in).
* test(arsenal): cover subdomain_takeover_check (classifier matrix + mocked DNS/fetch e2e)
- classifySubdomainTakeover decision matrix: confirmed via body fingerprint, confirmed via
dangling nxdomain-prone CNAME, potential (known service, no confirmation), potential
(dangling unknown), none (no CNAME / resolves clean), and no cross-service false matching
- renderTakeoverReport output shape
- end-to-end handler test with DNS + fetch mocked, driving the confirmed S3 path
Align NanoGPT integration with the published OpenAI-compatible API contract, including model discovery, configuration/setup surfaces, and secret-redaction safeguards.\n\nFixes #129.
* feat(llm): add Hugging Face provider + selectable local Claude Code models
Adds two operator-selectable LLM backbones, wired through both the backend
and the War Room UI.
Hugging Face (OpenAI-compatible Inference Providers router):
- New `huggingface` provider reusing OpenAIAdapter (router.huggingface.co/v1)
- HF_TOKEN / HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN env resolution, config,
setup wizard, CLI, factory, best-available, and .env.example
- Settings key card + 4 inference models in the picker + direct client routing
- Tests: src/__tests__/huggingface-provider.test.ts
Local Claude from Claude Code:
- Explicit "active backbone" pin in Settings so a connected agent (Claude Code)
can be chosen; a pin outranks stored API keys in both the client resolver and
mission dispatch
- Claude Code model picker (Account default / Opus / Sonnet / Haiku), threaded
to the CLI's --model via an "agentId::model" encoding parsed by LocalAgentAdapter;
requireLiveLocalAgent strips the suffix for connected-agent validation
* fix(llm): address PR #127 review — self-contained keyless path + centralized pin/model routing
Finding 1 — keyless mission path was not self-contained:
- Add the `local-agent` case to ConfigManager.getLLMConfig so provider:'local-agent'
resolves (keyless, agent id — optionally "agentId::model" — in `model`) instead of
hitting the default "Unknown provider" throw.
Finding 2 — inconsistent pin/model routing across dispatch surfaces:
- Centralize backend agent resolution in window.t3mpDispatchAgent (single source of truth).
It honors an explicit pin OVER a stored API key and encodes the picked local model as
"agentId::model" so it is never dropped.
- Route all three surfaces through it: mission dispatch, General (getGeneralConfig), and
Admiral (_admBackbone). Previously General/Admiral only picked an agent when no key
existed and sent the bare agent id, so a stored key overrode the pin and the selected
Claude model was lost.
Tests:
- getLLMConfig('local-agent') regression coverage (self-contained keyless path)
- LocalAgentAdapter "agentId::model" passthrough reaches the CLI --model flag
- t3mpDispatchAgent behavior: pin>key precedence, model encoding, fallbacks (evaluated
from the shipped docs/index.html)
- centralization invariant: mission/General/Admiral all route through t3mpDispatchAgent
- update the Op-Admiral keyless-routing static test to the centralized resolver
---------
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
* fix(config): handle 'local-agent' provider in getLLMConfig
Routing a mission through a connected local CLI agent (Claude Code / Codex /
Hermes) sets the provider to `local-agent`, but getLLMConfig's switch had no
case for it, so it fell through to `default` and threw
`Unknown provider: local-agent`. This aborted every keyless mission with a
REFUSED, even though `local-agent` is a registered provider wired to
LocalAgentAdapter everywhere else.
Add a `case 'local-agent'` mirroring `codex`: keyless (no apiKey/baseUrl), with
the connected agent id (codex|claude|hermes) carried in `model` and a sensible
default. Verified getLLMConfig('local-agent','claude') now resolves to
{provider:'local-agent', model:'claude'} instead of throwing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(config): regression coverage for local-agent provider (#118)
Addresses review feedback: the fix had no test, so removing the
`case 'local-agent'` would leave CI green while re-breaking keyless missions.
Adds focused assertions in src/__tests__/local-agent-provider.test.ts:
- local-agent + model `claude` resolves to provider/model with no apiKey/baseUrl
- any supported agent id (codex|claude|hermes) passed as model is preserved
- omitting the model falls back to the default agent id
- getLLMConfig('local-agent') no longer throws
- AVAILABLE_MODELS['local-agent'] surfaces the connected-agent ids
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: hoptoad-off <mvzmysun@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#111 shipped 8 bad-auto-merge scars into the SPA past a CI that only ever
lints/tests `src/` — the 27k-line docs/index.html had (and still has) no
static gate. The parse test added with the #111 fix catches outright syntax
failures; this adds the *semantic* layer that parsing misses.
It runs ESLint's high-precision "possible problem" rules programmatically
over each inline <script> (no new dependency — `eslint` is already a
devDependency; no eslint.config or CI change — rides the existing `npm test`):
no-redeclare, no-unreachable, no-dupe-keys, no-dupe-args, no-dupe-else-if,
no-duplicate-case, no-const-assign, no-import-assign, no-class-assign — the
exact shapes a conflict-concatenating merge leaves behind.
Tight, always-a-bug rules, each script linted in isolation → zero false
positives on the real file. no-undef / no-unused-vars are excluded (the SPA
relies on cross-<script> and browser globals a per-block lint can't see) and
no-func-assign is excluded (the app legitimately monkey-patches navigateTo).
Verified: 0 findings on the fixed tree, and it rejects the pre-fix main
(catches the duplicate `response`). Full suite green.
Also drops a now-unused eslint-disable directive in the parse test.
Compiles every classic inline <script> in docs/index.html with node:vm
(how a browser parses it). Fails on current main with 'Identifier
response has already been declared' — the duplicate declaration in
_safeLLMCallOnce that stops the whole SPA from booting.
Integrate SOCKS5 attack-traffic proxying, egress leak visibility, and local-model hardening. Preserve direct local/LAN model traffic while the proxy is active and reconcile the branch with current upstream.
* feat: add richer JSON Schema types and AJV validation utility
- Extended ToolParameter with enum, items, properties, additionalProperties (max depth 3)
- Added ToolError class with ToolErrorCategory enum
- Added ToolValidationError interface
- Created AJV-based validateToolArgs() with lazy loading and schema depth enforcement
- 57 new tests covering types and validation
* feat: add validation gate, parallel execution, and structured error feedback
- Arsenal.execute() validates args against JSON Schema before handler execution
- Tool execution errors categorized (scope_denied, tool_not_found, timeout, execution_error, validation_error)
- ToolError import fix in agent (was missing, causing silent ReferenceError)
- Semaphore-based parallel tool execution with configurable concurrency (default 5)
- Results maintain original call order regardless of completion order
- Structured error feedback to LLM with category only (no raw error messages)
- Scope/approval gates checked per-call before execution
- 35 new tests covering validation, parallel execution, and error feedback
* feat: add Ollama native function calling with probe + cache + fallback
- Added nativeTools config option to LLMConfig
- LocalAdapter probes model support for native tools on first call
- Probe result cached per model name to avoid re-probing
- Native tools formatted as Ollama /api/chat tools parameter
- Tool calls parsed from Ollama's tool_calls response format
- Falls back to text-based renderToolContract when native not supported
- OpenAI-compatible wire format also supported (for vLLM, LM Studio)
- 10 new tests covering probe, cache, fallback, and config overrides
* fix(tools): harden validation and native fallback
---------
Co-authored-by: sronaal <sronaal@users.noreply.github.com>
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
* chore(ingest): pin web-tree-sitter@0.25.10 + tree-sitter-wasms@0.1.13; add multilang-ingest design docs
* feat(ingest): multi-language grammar registry (web-tree-sitter, 8 langs, fail-open); 100% cov
* feat(ingest): language-aware param-name splitter (py/go/ts/java/c); 100% cov
* feat(ingest): tree-sitter CodeBlock extractor with wall-clock parse budget + fail-open
* feat(ingest): cross-lang sinks + multilang config + wire tree-sitter into ingestRepository (sync)
* feat(ingest): repoint white-box production callers to multilang config + prod regression test
* server-init-wiring: load multi-language grammars once at bootstrap
initGrammars() runs after LLM init, before app.listen. Internally
fail-open (never rejects): on failure the grammar registry stays empty
and ingest falls back to the Python regex parser.
* adversarial-tests: hostile-input hardening for untrusted target source
Malformed per-language source, wrong-language content, binary/non-UTF8,
empty, unicode identifiers, 20k-func large file (wall-clock budget),
deeply nested input, and a symlink-loop crawl — all must fail-open, never
throw or spin. Drives parseFileMultiLang + ingestRepository/crawl.
* readme-status + docs: flip white-box status to multi-language ingest
README source-code rows now state web-tree-sitter multi-language ingest
(fail-open to Python regex). whitebox.ts scope caveat + no-source error
message updated off the stale Python-only wording.
* coverage-setup: enforce 100% coverage gate on the three new ingest files
vitest.config.ts adds a coverage-only block (v8, perFile thresholds=100)
scoped to ts-grammars.ts, param-split.ts, ts-parse.ts. Test discovery and
env stay on defaults. Edited files gate on new-branch coverage via the
regression suites; the server.ts initGrammars bootstrap line is the
documented smoke-covered carve-out.
* review: fix WASM heap leak, language-scoped fail-open, sink recall/precision
Adversarial security + strict code review (two independent subagents)
returned one blocking bug and several correctness findings; remediated:
BLOCKING #1 — native WASM memory leak: web-tree-sitter Tree/Parser objects
were never .delete()'d. Both reviewers reproduced a process-global wedge:
after ~7 large parses the shared WASM heap exhausts, every subsequent parse
aborts (caught by fail-open), and non-Python ingest silently returns []
forever. Fix: free the tree in a finally; delete the parser in resetParser.
Regression test parses ~8MB through the real shared parser 12x.
regex, which mis-parses brace/class syntax into corrupt blocks (wrong line
spans, lost methods). Now non-.py fail-open returns [] (honest absence); the
Python regex only ever runs on .py. Isolated ts-parse-fallback test.
http(s).request, axios(config), C exec-family); negative lookbehind stops
bare system(/popen(/fetch( from matching Python receivers (os.system etc.
still matched, corpus rankings unchanged). Recall+precision tests added.
operator sees non-Python degradation.
(one-line call-graph edges, no decorators, C++ member methods).
* review round 2: fix sink-regex regressions, real timeout test, deterministic leak guard
Third review round (correctness + security re-attack + test-quality) against
the round-1 fixes surfaced issues in the fixes themselves; remediated:
- Sink-regex regressions I introduced: (1) my fetch lookbehind dropped
window.fetch/this.fetch (top JS SSRF idiom) — reverted to \bfetch\(;
(2) bare .Get/.Post/.Do flagged db.Get/cache.Get/queue.Do as SSRF —
receiver-qualified to [Cc]lient. Recall+precision tests cover both;
regex is ReDoS-free (timed).
- Placebo leak test: the 12x function-dense loop did NOT wedge the parser
on machines with heap headroom (proven: passed with tree.delete removed).
Replaced with a deterministic vi.spyOn(Tree.prototype,'delete') assertion,
verified RED without the fix.
- Real (non-mocked) parse-timeout test: parseFileMultiLang gains an injectable
budgetMs; a 0ms budget drives the real progressCallback cancellation to [].
- runWhiteboxAnalysis swap now has coverage (spy on orchestrator.run) so the
2nd production caller can't silently revert to the Python-only config.
- Un-vacuoused weak tests: unicode now asserts the extracted block; nested/
wrong-language wrapped in invariants() + timing bounds.
- CI now runs npm run test:coverage (the 100% gate was defined but unenforced).
- Security: documented maxFileBytes as the control bounding a single parse's
peak WASM memory below the 2GB heap-abort ceiling.
- Stale 'fail-open to Python regex' docs fixed (ts-parse, whitebox, server,
adversarial); circular-import note added; temp-dir cleanup; honest coverage
wording in spec + config.
* docs(ingest): align fallback-contract text with the shipped language-scoped fail-open
Review feedback (#75, @jmagly): the design docs + one ts-grammars comment
still described the pre-implementation 'fall back to the Python regex'
model for non-Python files. The shipped contract is language-scoped: .py
uses parseFile; any non-.py file with a missing/unsupported/failed grammar
or a parse timeout returns [] (never the Python regex, which would mis-parse
brace/class syntax into corrupt blocks). Updated every stale reference in
multilang-ingest-spec.md, multilang-ingest-plan.md, and ts-grammars.ts so
the docs match the security boundary. Docs/comment only — no code change.
* fix(test): make the ReDoS wall-clock guard robust under coverage instrumentation
Review feedback (#75, @jmagly): the CI step this PR adds (npm run
test:coverage = vitest run --coverage) runs the full suite under V8
coverage, and that overhead pushes the pathological-brace DoS timing guard
in local-agent-tool-calling.test.ts:69 over its <300ms bound (320 < 300
fails on CI). Raise the bound to 1000ms — still 2x below the ~2100ms
quadratic-ReDoS regression it guards against, so it fails hard if the
regression returns, but no longer false-fails under coverage/slow CI.
Chose the reviewer's option (a) (robust bound) over scoping test:coverage,
so the gate stays a simple full-suite run and no test-file enumeration can
rot. Verified: npm run test:coverage is green and the 3 new files stay at
100%.
* fix(ingest): cover pointer-returning C functions
---------
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
* feat: update DeepSeek models to V4 Flash and V4 Pro
- Replace deprecated deepseek-chat / deepseek-reasoner with deepseek-v4-flash / deepseek-v4-pro
- Update base URL from api.deepseek.com/v1 to api.deepseek.com
- Update context window (1M) and max output (384K) per official API docs
- Old models deprecated 2026-07-24
* fix: resolveGeneralLLMConfig missing baseUrl + Admiral timeout 180s
resolveGeneralLLMConfig constructed a new LLMConfig but omitted
baseUrl, causing the adapter to fall back to api.openai.com/v1
regardless of provider — DeepSeek keys hit OpenAI (401).
Also raised Admiral preview timeout to 180s for thinking-mode models.
* fix: createTempestCommandInstance missing baseUrl for operator exec
Operator LLM calls also lacked baseUrl, same 401-for-DeepSeek bug
as resolveGeneralLLMConfig. Reads provider-specific baseUrl from config.
* fix(deepseek): migrate retired persisted defaults
---------
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
* feat(settings): Universal API Config panel with server-side model fetch (#90)
Consolidates provider configuration into one "Universal API Config" panel in
Settings — provider dropdown (OpenAI-compatible / Anthropic), base URL, API key,
model with a live "Fetch models" button, and max context tokens — above the
existing per-provider key sections.
Backward-compatible by design: the panel reads and writes the SAME state.settings
keys the individual sections already use (${provider}Key, selectedModel), so
nothing migrates and nothing is lost; the individual sections keep working and
stay in sync.
- src/config/provider-models.ts (new): listProviderModels queries a provider's
own model endpoint (OpenAI-compatible Bearer /models, Anthropic x-api-key
/v1/models, OpenRouter public list); resolveModels wraps it fail-open.
- POST /api/models (new route): server-side so the browser dodges provider CORS.
Binds key+baseUrl by source — a caller-supplied base URL must bring its own
key, so the server never lends its configured key to a body-chosen URL. Always
responds, failing open to the static AVAILABLE_MODELS list on a missing key,
network error, or unknown provider (incl. local-agent) — never hangs.
- Model options are built via the DOM API (createElement/textContent), never
innerHTML, so untrusted provider-returned model ids cannot inject markup.
Scope notes: augment-not-replace (removing the individual sections is deferred
to maintainer preference); base URL + context cap are stored and used for model
discovery and server/agent flows — in-browser mission routing still uses
OpenRouter/Venice, matching the existing "not wired" hints; the "Model Reasoning
(Eino)" sub-proposal is a separate follow-up pending a mapping.
Verified on macOS: 12 unit tests (RED->GREEN) for the model module, full suite
462 green, and Playwright QA (17 checks) driving the real panel — render,
provider switch, persistence across reload, backward-compat, and both the static
and live Fetch paths.
Refs #90
* fix(settings): harden model list fetch
---------
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
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>
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.
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>