* feat(llm): log the upstream provider behind OpenRouter
LiteLLM drops OpenRouter's top-level `provider` from stream chunks (including error chunks) and the `metadata.provider_name` of non-2xx replies, so the request log could not say which provider served or failed an attempt. Record both, plus OpenRouter's `metadata.error_type`, per attempt and emit them as `upstream=` / `upstream_error=` on the llm_request line.
* feat(llm): retry 4xx model errors
* test: let the blocked-provider test run out of 4xx retries
* refactor: retry any 4xx or 5xx status
* fix: don't retry 401, 402 or 403
* fix: don't retry 404
LiteLLM drops OpenRouter's top-level `provider` from stream chunks (including error chunks) and the `metadata.provider_name` of non-2xx replies, so the request log could not say which provider served or failed an attempt. Record both, plus OpenRouter's `metadata.error_type`, per attempt and emit them as `upstream=` / `upstream_error=` on the llm_request line.
* fix(execution): only strip images on image-rejection errors
The image-strip recovery fired on any 400/404/422, wiping a session's screenshots and replaying the turn on unrelated errors (e.g. an upstream incomplete-tool-call 400, a context overflow). Require an image-related message too.
* fix(execution): match only replies that mean the model takes no images
* fix(execution): match DeepSeek's image rejection
* fix(execution): guess at Vercel AI Gateway's image rejection
* feat(llm): scrub images from every request to a text-only model
Replace images at the MCP call before any result_transform, give load_skill the
same agent_browser screenshot swap as the system prompt, and add a
call_model_input_filter that turns any image left in the input into a tool error
while chaining any filter already set.
* chore: fix the ruff and bandit failures on main
* feat(llm): keep images away from text-only models
Leave view_image out of the tools, replace MCP image blocks with a text note,
and swap agent_browser's screenshot section for text-only guidance when the
run's model does not accept images.
* refactor(llm): disable view_image via is_enabled and gate skill image guidance by modality
* refactor(skills): render opt-in Jinja skills instead of comment markers
* Tweaked skill
* feat(llm): detect whether a model accepts image input
Add model_supports_images(): OpenRouter models are checked against architecture.input_modalities from OpenRouter's public model list (fetched once per process), others against LiteLLM's supports_vision. Unknown models and lookup failures count as image-capable. Not wired in yet.
* refactor(llm): read image support from litellm's catalog, assuming it for unknown models
* style: drop a trailing space in the image-support docstring
A row recorded as "DirectoryTraversal" or "XSLeaks" normalizes to one
token, which whole-word matching missed, so finish_scan/coverage.json
could report tested work as a gap. _entry_is_about now also matches a
phrasing's words joined into one token as a fallback - still a
whole-token check, never a substring. The branch is additive: it can
only turn a gap into a cover, never the reverse, so it introduces no
false gap.
_entry_is_about matched each phrasing word as a substring of the
flattened row, so a short class token matched inside unrelated words -
"rce" in "brute force"/"enforcement", "idor" in "corridor". An agent
carrying a skill could then record an unrelated row and the gap detector
would report that class as assessed, so coverage.json claimed work
nobody did.
Match whole words instead (word-set membership), with a forward +s
plural allowance so "service worker" still matches a "service workers"
row and "password" a "passwords" row. The reverse plural is deliberately
not allowed: it would let a row word "xs" satisfy the "xss" alias and let
an XS-Leaks row mask an XSS gap. Resolves#1447.
update_coverage overwrites a row's agent_id with the updater's, so a
browser_security surface a carrier genuinely assessed looked unexamined
once another agent edited the shared row. Preserve the superseded
author's id in history and let the carrier filter count a row whose
current OR earlier author carried the skill.
Resolve the Greptile review on the CSPT PR:
- coverage: a bundled skill's sub-topic is only covered by a row from an
agent that carried that skill, so an unrelated row (an open_redirect
"meta refresh") can no longer mask an untested browser surface
- reporting: the CSPT discriminator now requires an endpoint or a
code_location that survives _build_physical_locations (a repo-relative
file with a start line), so a drive/absolute path SARIF drops no longer
passes and leaves the alert locationless and collapsed
- sarif: drop the "client-side path traversal" title keyword so existing
dynamic reports keep their title token and fingerprint; CSPT is still
distinguished by the structured finding_class
- dedupe: exempt the default `dynamic` class so an older CSPT filed as
dynamic can still dedupe against a client_side_path_traversal report
- tests: relabel the fixture regression as a source-to-sink primitive
(reaches_sink / no_unguarded_sink), not an exploitability verdict;
add a coverage regression for the sub-topic masking fix
Add a dedicated client_side_path_traversal skill and wire the full
pipeline so agents know when to test for CSPT, cannot silently skip it,
and file it as a machine-separable finding class.
- skill: exploitable-CSPT discovery/escalation/validation, gated on a
proven request sink plus real impact (a decoded ../ that reaches no
sink with impact is a false positive)
- routing: a CSPT trigger in all four scan modes, plus cross-links from
browser_security, path_traversal_lfi_rfi, ssrf, xss, csrf, the
source-aware skills, and nextjs
- coverage: a canonical VULN_CLASSES registry (aliases, sub-topics, CWE)
with _SKILL_PHRASINGS derived from it, and a sub-topic gap detector so
a bundled skill like browser_security cannot be marked covered by
testing only its easiest surface
- reporting: finding_class on create_vulnerability_report, validated
from the registry, with a discriminator requirement for CSPT findings
- sarif/dedupe: prefer the structured finding_class over a title keyword
so CSPT and server-side path traversal (both CWE-22) no longer collide
- tests: finding-class/SARIF/dedupe regression and browser-free
source-to-sink fixtures; docs skills-catalog entry
A stop that lands between the silent-wait check and mark_running was
overwritten with running. resume_silent_user_wait does the check and the
state change under the coordinator lock and leaves anything else alone.
Plain text is now the only channel to the user. wait_for_user takes no
arguments and only parks the agent, so a reply can no longer be written
twice (once as text, once as a tool argument). The driver bounces a
wait_for_user call that follows no assistant text back into recovery.
TUI and viewer render wait_for_user as the waiting marker alone and keep
rendering recorded respond_to_user events with their message.
docker.from_env() ignores the current docker context, so Docker Desktop on
macOS (socket under ~/.docker/run unless the default-socket option is on),
OrbStack and Colima reported DOCKER NOT AVAILABLE while `docker ps` worked.
Every failure also printed the same "ensure Docker Desktop is running" text
followed by a RuntimeError traceback.
- resolve the endpoint as the CLI does: DOCKER_HOST, then DOCKER_CONTEXT or
the current context, then the default socket; the sandbox backend uses the
same resolution so startup and scan talk to the same daemon
- classify the SDK error (socket missing, permission denied, connection
refused, Windows named pipe) and print the fix for the current platform,
the endpoint that was tried and the underlying error
- exit 1 cleanly instead of raising after the panel
- telemetry reports docker_unavailable_<reason>
parse_arguments() rejected --fail-on without -n before main() could switch
to headless, so a CI job running `strix -t x --fail-on high` without -n
stopped at an argument error instead of scanning. The terminal check now
lives in cli_args as terminal_attached(); the parser only enforces the
headless-only flag when a terminal is attached and the TUI would open.
Without a tty on stdin and stdout (CI, nohup, pipes, cron, TERM=dumb) the Go
TUI handshakes fine and then exits 1 the moment Bubble Tea tries to take over
the screen. Because the backend was already activated, that exit escaped the
pre-activation mapping and surfaced as a raw traceback:
RuntimeError: Bubble Tea TUI exited with status 1.
Now main() checks for a terminal right after argument parsing. With a target
it switches to the headless path (same as -n) and prints one dim notice.
Without a target the start screen is the only way to enter one, so it prints
a panel telling the user to pass -t <target> -n and exits 1. A bare --resume
is left to the picker, which already lists runs when there is no terminal.
If the sidecar still dies after startup, check_return_code raises
TuiProcessExitedError, run_tui maps it to InteractiveInterfaceExitedError,
and main() prints an INTERACTIVE INTERFACE STOPPED panel with the -n hint
under its own telemetry name instead of re-raising.
preflight_request takes api_base_setting so a dedupe model that timed out
points the user at DEDUPE_LLM_API_BASE, not LLM_API_BASE. warm_up_llm is
now exercised with a dedicated dedupe model: own headers, same preflight
timeout, resolved through resolve_dedupe_model.
The warm-up request used settings.llm.timeout (LLM_TIMEOUT, 300s) both as
the request timeout and the wait_for bound, so a wrong LLM_API_BASE or a
dead proxy hung five minutes and then printed an empty Error line.
Add LlmSettings.preflight_timeout (LLM_PREFLIGHT_TIMEOUT, default 30) and
a shared preflight_request() used for the main and dedupe models. When it
expires the panel names the model, the limit and the setting.
strix --resume with no run name now lists the runs in ./strix_runs inline
in the terminal (started, target, status, findings, run name), newest
first, with arrow-key selection, type-to-search and esc to cancel.
Picking a run continues through the same path as --resume <name>.
Headless or non-TTY launches error with the run list instead.
No install-once global, no hand-rolled traceback formatting, no SystemExit special case: one helper logs the report with exc_info on strix.telemetry when a handler exists, and both hooks call it.