Compare commits

...
Author SHA1 Message Date
yoni 21e719a2eb fix(grok): close the token-endpoint response like codex does
The response was left for the garbage collector, which on Python 3.14 surfaces as "Exception ignored while finalizing" urllib3 noise at interpreter shutdown. codex._post_form already context-manages its response.
2026-08-14 17:33:39 +00:00
yoni 21afdaea9e fix(llm): resolve grok/ models to xai/ for LiteLLM metadata
LiteLLM maps xAI models only provider-qualified, so neither "grok/grok-4" nor bare "grok-4" resolves: subscription runs fell back to the generic 200k context window and an 8k output cap instead of Grok's 256k/256k.

subscription.litellm_model_name() now owns the routing-prefix -> LiteLLM name mapping (grok/ -> xai/, chatgpt/ -> bare) and context_budget uses it.
2026-08-14 17:09:39 +00:00
yoni d4573a3197 fix(tui): show the actual subscription provider in the Go TUI
The Go TUI rewrite hardcoded "ChatGPT subscription" in the stats view and its
snapshot only carried a boolean, so a Grok run was labeled ChatGPT — the same
bug previously fixed on the Python side. Carry the provider label through the
snapshot protocol and render it, falling back to a generic "Subscription".

utils._subscription_label becomes public subscription_label since the TUI
backend now needs it too.
2026-08-13 03:26:22 +00:00
yoni cd3250576c Merge remote-tracking branch 'origin/main' into grok-subscription-oauth 2026-08-13 03:19:17 +00:00
Alex SchapiroandAhmed Allam 8ca0c4a9b8 Fix LiteLLM cost model resolution 2026-08-12 17:26:00 +03:00
Ahmed AllamandAhmed Allam 7cc9fa9faa chore: release v1.5.3 2026-08-10 21:28:52 +03:00
devin-ai-integration[bot]andGitHub 174c16fa26 fix(llm): send OpenRouter app attribution on the request itself (#1045) 2026-08-10 11:24:02 -07:00
Ahmed AllamandAhmed Allam 94a2586aaa fix(container): write the browser profile as root 2026-08-10 10:08:17 +03:00
Ahmed AllamandAhmed Allam 372e27fa17 chore(container): drop explanatory comment 2026-08-10 09:54:49 +03:00
Ahmed AllamandAhmed Allam ad727edd66 fix(container): keep the browser env alive where image ENV is dropped 2026-08-10 09:54:49 +03:00
7b3c8f9b74 fix(container): reclaim abandoned browser sessions (#1034)
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
2026-08-09 16:57:51 -07:00
Ahmed AllamandAhmed Allam ae07af6159 chore: drop explanatory comment 2026-08-09 15:44:16 +03:00
Ahmed AllamandAhmed Allam 649a2e2140 fix(llm): omit parallel_tool_calls on tool-less requests 2026-08-09 15:44:16 +03:00
Ahmed AllamandAhmed Allam 597aae6715 chore: release v1.5.2 2026-08-09 04:29:34 +03:00
Ahmed AllamandGitHub 06b158d1fa fix(runner): settle child agents before closing sessions at wind-down (#1025) 2026-08-08 18:17:58 -07:00
Ahmed AllamandGitHub c29eb73c7f fix(tools): coerce an empty-string list/dict argument to an empty container (#1024) 2026-08-08 16:58:30 -07:00
Ahmed AllamandGitHub 72833b8e43 fix(runner): resume after a user interrupt instead of failing (#1023) 2026-08-08 16:44:12 -07:00
Ahmed AllamandGitHub 1117ba6d4a fix(sessions): open a sqlite connection per operation, not per thread (#1022) 2026-08-08 16:20:01 -07:00
Ahmed AllamandGitHub 53e4658d88 fix(todo): stop a todo plan failing on priority or duplicates (#1021) 2026-08-08 15:18:48 -07:00
58df71d3db fix(agents): let an agent wait on what it already said (#1020)
* let an agent wait on what it already said

An agent that answers in plain text is nudged to call a tool, and the only tool
that hands control back takes a required message. So it says the same thing
twice: once as text the user has already read, once as the argument it had to
supply to stop. Seen on a run whose whole instruction was "hi" - a greeting, then
the same greeting again through respond_to_user.

message is optional now. The nudge arms the tool with the text that was
delivered and says not to repeat it, so an agent that has said its piece can park
on it with an empty call. Anything it does want to add it passes normally.

Parking still cannot leave the user on silence: an empty call is refused unless
something was actually said, and the arming is single use - execution clears it
as soon as a turn ends any other way.

The interactive prompt now also says to answer and stop in one respond_to_user
call, which is what avoids the nudge in the first place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* drop the worked example from the interactive prompt

"the user greeted you, asked something you can answer outright, or you need a
decision" was the run I had been reading, written into a rule that holds
whatever the reason. The rule is that replying and stopping is one call; listing
occasions only invites the model to check whether this is one of them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* drop the arming flag; an empty message just waits

Passing the delivered text from execution into the tool, and refusing an empty
call without it, was machinery guarding against an agent parking having said
nothing. That leaves the user looking at "waiting for your reply" with a cursor
in front of them - they type. It does not need a mechanism.

What is left is the default on message, and the nudge saying the text already
landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* only offer waiting on words that were written

The nudge told every agent its text had already been delivered, but it fires
whenever a turn leaves the agent running, and a turn can end with no tool call
and no text at all - _final_output_preview has carried <none> and <empty>
branches all along. An agent that said nothing was being invited to wait on an
answer the user never received, leaving them at a bare prompt.

It now reads the turn: waiting on what was said is offered only when something
was, and otherwise the agent is told plainly that the user has read nothing and
to send its message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* leave the continuation nudge alone

Rewording it meant asserting from the outside whether the agent had spoken, and
the nudge fires whenever a turn leaves the agent running - text or no text. The
agent knows which it did without being told, so the guidance belongs in its
prompt, where the condition is its own to read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* say it in the nudge, where the agent is reading

An agent stranded by the nudge reasons off the nudge. Told only to call
respond_to_user, it supplies a message, and since it has just answered in plain
text that message is the same answer again. The system prompt saying otherwise
sits thousands of tokens earlier and loses.

The clause goes on the line the agent acts on: call respond_to_user, with no
message if it has already said it. That reads true whatever the turn did,
including one that produced no text, because the agent is the one who knows
which — nothing here has to work it out from the outside.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 00:57:16 +03:00
0b9e029a5d test(tui): correct the nudge the internal-turn test asserts (#1016)
* correct the nudge the internal-turn test asserts

The test expected "ended the autonomous Strix run", which strix.core.execution
does not inject; it says "ended the autonomous run". The classifier was right and
the test was not, so the suite failed on main while the behaviour it guards was
fine.

The sentence is written inline in another module and copied by hand into the
classifier and again into the test, which is how it drifted. A second test now
reads it back out of that module's source, joining the adjacent string literals
its line wrapping leaves behind, and fails if either nudge is no longer injected
verbatim. Reworded one and it reports which nudge went missing and what a resumed
scan would do about it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* read the nudges out of what the module can inject, not out of its text

Searching the source accepted the sentence anywhere in the file, so a stale copy
left behind in a comment would have kept the guard passing after the message it
guards had changed - the drift it exists to catch.

Parsing the module instead limits it to strings the code can actually inject.
Comments never reach the tree, docstrings are dropped as description rather than
behaviour, and adjacent literals are joined during parsing, which the line
wrapping needed and the regex was only approximating.

Checked by rewording the message and leaving the old wording in a comment: the
guard fails, where searching the text passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 22:34:45 +03:00
b260a4ee38 fix(tui): make the mount prompt clickable, and skip the mount instead of abandoning the scan (#1015)
* make the working-directory prompt answer the mouse

Its Confirm and Cancel were drawn as buttons and did nothing when clicked: the
modal mouse handler had a case for every dialog except this one, so a click fell
through and the scan sat waiting on an answer the user believed they had given.
Only the keyboard could answer it.

The prompt is docked in a corner rather than centered, so it also needs its own
bounds; the centered ones every other dialog uses would have put the buttons in
the wrong place. Those bounds now come from the same placement cornerOverlay
draws with.

Two returns that hand back the model alongside a call that mutates it are now
sequenced explicitly. They work, but only because the compiler happens to
evaluate the call first, and one of them is what puts the prompt back in the
composer when the mount is declined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skip the mount instead of abandoning the scan

Declining the working-directory prompt threw the whole launch away and dropped
back to the start screen, which is a lot to lose for answering one question
about one directory. The two answers are now about the directory alone: mount it,
or run without it. The prompt is the whole of the input either way.

The buttons say which is which - Mount and Skip rather than Confirm and Cancel -
and the prompt says what skipping costs.

A run with neither target nor directory is a real run, so two things follow it.
It can be resumed: its instruction is what drives it, and that is in the run
record. And it tells the agent plainly that it has neither, because an agent
given no scope goes looking for the one it assumes it was meant to have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 22:34:02 +03:00
yoni 7bdc2424f2 fix(update): strip PyInstaller bootloader env vars before re-exec after self-update 2026-08-07 21:40:39 +00:00
yoni a22c686626 chore(viewer): rebuild static viewer bundle 2026-08-07 21:12:50 +00:00
yoni ab4d6ffa4a Merge origin/main into grok-subscription-oauth 2026-08-07 21:12:00 +00:00
alex sandGitHub f8a8801d56 docs(skills): rename skills to descriptive names and broaden descriptions for discoverability (#1013) 2026-08-07 14:24:19 -04:00
Ahmed AllamandAhmed Allam 22750077da chore: release v1.5.1 2026-08-07 20:28:06 +03:00
Ahmed AllamandGitHub 0607abf9e5 fix(tui): scrollbar visibility, findings scrolling, and report navigation (#1006) 2026-08-07 06:14:22 -07:00
alex sandGitHub 9dae76667b expand firebase storage rules coverage (#1002) 2026-08-06 23:22:06 -07:00
Ahmed AllamandAhmed Allam b08662449d ci: publish nested standalone archives as release assets 2026-08-06 22:03:57 +03:00
Ahmed AllamandAhmed Allam bda0f54342 ci: tolerate repr-escaped backslashes in the release TUI-sidecar check 2026-08-06 21:55:12 +03:00
Ahmed AllamandAhmed Allam c6c8bb5ca6 ci: match Windows backslash paths in the release TUI-sidecar check 2026-08-06 21:26:23 +03:00
Ahmed AllamandAhmed Allam 28747e682e chore(image): bump sandbox tag 1.2.0 -> 1.3.0 2026-08-06 20:50:07 +03:00
Ahmed AllamandAhmed Allam e71bf127fd chore: release v1.5.0 2026-08-06 20:50:07 +03:00
Ahmed AllamandAhmed Allam 709a7a1b39 docs(skills): skipped symbol search must be disclosed in reachability evidence 2026-08-06 17:06:00 +03:00
Ahmed AllamandAhmed Allam ec07f0f68f docs(skills): require per-CVE affected-symbol matching in dependency reachability analysis 2026-08-06 17:06:00 +03:00
alex sandGitHub 2a9ab1d6cd feat: agent-ready — installable SKILL.md skills, AGENTS.md, coding-ag… (#926) 2026-08-06 06:54:40 -07:00
Ahmed AllamandAhmed Allam 51bcf70722 update readme 2026-08-06 15:47:45 +03:00
Ahmed AllamandAhmed Allam cea52cce8d prompt changes 2026-08-06 15:39:43 +03:00
Ahmed AllamandAhmed Allam 77c7b0df09 prompt changes 2026-08-06 15:39:43 +03:00
Ahmed AllamandAhmed Allam b69af37cb2 feat(report): keep dependency findings from distinct manifests separate in dedupe 2026-08-06 02:20:46 +03:00
Ahmed AllamandAhmed Allam 72cb15a20a feat(reporting): require repo-relative manifest_path on dependency CVE findings 2026-08-06 02:20:46 +03:00
Alex SchapiroandAhmed Allam 97336d53e4 feat(reporting): structured reachability evidence ladder for dependency CVE findings 2026-08-06 00:25:08 +03:00
0abe82d622 fix(agents): collapse repeated waits queued inside one model turn (#979)
* fix(agents): collapse repeated waits queued inside one model turn

* fix(agents): state that one wait is enough in every prompt variant

---------

Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
2026-08-06 00:07:30 +03:00
yoni edb0a607bf fix(llm): open the store lock file with O_NOFOLLOW
The predictable lock path was opened with Path.open("w"), following (and
truncating through) a pre-positioned symlink. Open it via os.open with
O_NOFOLLOW and no O_TRUNC, raising StoreLockError on a symlinked lock path.
2026-07-29 18:05:58 +00:00
yoni 42df95b681 fix(llm): write credential store via mkstemp to defeat symlink attacks
Third review pass (security): the store temp file used a predictable
subscription-auth.json.tmp name, so a local attacker could pre-plant a symlink
there and divert the OAuth token write. Create it with tempfile.mkstemp
(random name, mode 0600, no symlink following) in the same directory, then
atomically rename over the target.
2026-07-29 18:00:36 +00:00
yoni 48db7f4d0e fix(llm): fail loudly when the store lock is unavailable
Third review pass: the shared credential store no longer proceeds with an
unlocked read-modify-write when fcntl is missing or flock fails. It now retries
on EINTR and otherwise raises StoreLockError, so concurrent provider
login/refresh/logout can never race by silently skipping the cross-process lock.
2026-07-29 17:55:18 +00:00
yoni 7289153f9b fix(llm): make logout-all atomic and persist provider in run record
Second review pass:
- strix auth logout (all providers) now holds the shared store lock across every provider removal, so a concurrent save/refresh cannot leave one credential behind while reporting all removed.
- _persist_run_record writes subscription_provider alongside auth_mode, matching ReportState, so resumed runs keep their original provider label.
2026-07-29 17:48:33 +00:00
yoni 9fd11eedec fix(llm): harden subscription credential store and provider labeling
Addresses PR review:
- Extract the shared ~/.strix/subscription-auth.json handling into subscription_store, writing tokens owner-only (0600) from creation via os.open instead of chmod-after-write, closing the window where credentials were briefly world/group-readable.
- Serialize read-modify-write across providers and processes with a reentrant lock, so overlapping ChatGPT/Grok save/logout/refresh operations no longer clobber each other.
- Live/TUI stats label the subscription from the persisted run record (falling back to provider-aware settings), so resumed runs no longer mislabel the provider when STRIX_LLM changes.
2026-07-29 17:39:26 +00:00
yoni 5c94872186 fix(viewer): label historical subscription runs by provider
read_run_summary backfills subscription_provider from the recorded provider/model slug (reusing subscription.provider_label) when the field is absent, so runs recorded before it existed still label correctly without a rescan. The viewer no longer defaults to "ChatGPT" when the provider is unknown. Rebuilds the committed viewer bundle.
2026-07-29 17:04:38 +00:00
yoni 218470f14d fix(viewer): show the actual subscription provider name
The Run details panel hardcoded "ChatGPT subscription" for any subscription run. Emit subscription_provider (ChatGPT/Grok) in the run record and render it in the viewer, so Grok runs read "Grok subscription". Rebuilds the committed viewer bundle.
2026-07-29 16:40:06 +00:00
yoni bfceb65a4c feat(llm): opt-in Grok/SuperGrok subscription OAuth
Add Sign in with Grok mirroring the merged ChatGPT/Codex integration: a strix/config/grok.py OAuth module (PKCE loopback flow against auth.x.ai, refresh with cross-process locking, secure ~/.strix/subscription-auth.json store) plus grok/<model> routing through the OpenAI-compatible api.x.ai/v1 endpoint via a bearer-stamping chat-completions client.

strix auth login grok / status / logout become provider-aware; subscription.py shares provider-agnostic auth-mode detection; Grok runs are marked zero-cost like other subscriptions.

Uses a consumer subscription outside xAI own products, which xAI does not officially support; opt-in and experimental.
2026-07-28 21:44:43 +00:00
93 changed files with 4899 additions and 515 deletions
+2 -2
View File
@@ -70,7 +70,7 @@ jobs:
TUI_NAME="strix-tui"
dist/strix --version
fi
uv run pyi-archive_viewer -l "$PYI_BINARY" | grep "strix/bin/$TUI_NAME" >/dev/null
uv run pyi-archive_viewer -l "$PYI_BINARY" | grep -E "strix[/\\]+bin[/\\]+$TUI_NAME" >/dev/null
if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then
file dist/strix
@@ -118,4 +118,4 @@ jobs:
with:
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
generate_release_notes: true
files: release/*
files: release/**
+49
View File
@@ -0,0 +1,49 @@
# Strix — Agent Guide
Strix is an open-source autonomous AI pentesting tool. This file is for AI coding agents that want to **use** Strix (run security scans) or **contribute** to it.
## Using Strix from an agent
Install the agent skills for step-by-step workflows:
```bash
npx skills add usestrix/strix
```
- `penetration-testing-with-strix` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below)
- `managed-pentesting-with-strix` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed)
- `fix-security-vulnerabilities-with-strix` — remediate findings and re-run Strix to verify
- `ci-security-scanning-with-strix` — add PR scanning to CI/CD (self-hosted CLI or managed app)
**Two ways to run, same engine — pick per situation:**
- **Open-source CLI (self-hosted):** free, fully local, BYO LLM key, needs Docker. Best for local dev loops, air-gapped/offline, and full control.
```bash
curl -sSL https://strix.ai/install | bash # install
export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id
export LLM_API_KEY="<key>"
strix -n -t ./ --scan-mode quick --max-budget 10 # headless scan; always use -n
```
- Requires Docker running. Scans take minutes (`quick`) to hours (`deep`) — run in the background.
- Exit codes (headless): `0` clean, `1` fatal error, `2` vulnerabilities found. A `0` only covers what was analyzed — check `run.json` (`status`, `llm_usage.cost` vs the budget) before calling a run clean.
- Artifacts in `strix_runs/<run-name>/`: `penetration_test_report.md`, `vulnerabilities/*.md`, `vulnerabilities.json`, `findings.sarif` (SARIF 2.1.0), `run.json`.
- **Managed cloud (app.strix.ai):** no Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Use it when local infra isn't available.
```bash
# token from Settings → API Access; register the target as an asset, then:
curl -sS https://app.strix.ai/api/v1/scans -H "Authorization: Bearer $STRIX_API_TOKEN" \
-H "Content-Type: application/json" -d '{"engagement_type":"live_test","domain_ids":["<uuid>"]}'
```
- API docs: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json).
- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt).
- Only scan targets the user is authorized to test.
## Contributing to this repo
- Python 3.12+, managed with `uv`. Install dev deps: `make dev-install`.
- Lint/format/type-check/security, all in one: `make check-all` (ruff, mypy, bandit).
- Tests: `uv run pytest`.
- Run from source: `uv run strix --target <target>`.
- Layout: `strix/agents` (agent graph + prompts), `strix/tools` (proxy, browser, terminal, scanners), `strix/runtime` (Docker sandbox), `strix/report` (findings, SARIF), `strix/skills` (internal knowledge packs the pentest agents load — different from the consumer skills in `skills/`), `strix/interface` (CLI/TUI), `containers/` (sandbox image).
- Pre-commit hooks: `make pre-commit` (or `uv run pre-commit install`).
+14 -1
View File
@@ -108,6 +108,18 @@ Try the Strix full-stack penetration testing platform at **[app.strix.ai](https:
---
## 🤖 Use Strix from Your Coding Agent
Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatible](https://agentskills.io) agent the ability to run pentests, fix findings, and set up CI scanning:
```bash
npx skills add usestrix/strix
```
This installs four skills: **penetration-testing-with-strix** (run headless scans and read results), **managed-pentesting-with-strix** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **fix-security-vulnerabilities-with-strix** (remediate + re-scan to verify), and **ci-security-scanning-with-strix** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
---
## ✨ Features
### Agentic Pentesting Tools
@@ -337,6 +349,7 @@ Strix builds on the incredible work of open-source projects like [LiteLLM](https
> [!WARNING]
> Only test apps you own or have permission to test. You are responsible for using Strix ethically and legally.
> **Authorized use only.** Strix actively tests the targets you point it at, so only run it against systems you own or have **explicit, written permission** to test, and stay within the agreed scope. Unauthorized testing is illegal in most jurisdictions.
> You alone are responsible for obtaining authorization and complying with the law. Strix is provided "as is" with no warranty or liability for misuse.
</div>
+18 -1
View File
@@ -16,7 +16,8 @@ RUN mkdir -p /out/bin && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest && \
go install -v golang.org/x/vuln/cmd/govulncheck@latest
# ---------------------------------------------------------------------------
# Runtime stage
@@ -53,6 +54,7 @@ RUN apt-get update && \
nmap ncat ndiff \
sqlmap nuclei subfinder naabu ffuf \
nodejs npm pipx \
golang-go \
libcap2-bin \
gdb \
libnss3-tools \
@@ -115,6 +117,21 @@ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US"
ENV AGENT_BROWSER_SCREENSHOT_DIR=/workspace/.agent-browser-screenshots
ENV AGENT_BROWSER_IDLE_TIMEOUT_MS=180000
USER root
RUN set -eu; \
{ \
for var in AGENT_BROWSER_EXECUTABLE_PATH AGENT_BROWSER_USER_AGENT \
AGENT_BROWSER_ARGS AGENT_BROWSER_SCREENSHOT_DIR \
AGENT_BROWSER_IDLE_TIMEOUT_MS; do \
eval "value=\${$var}"; \
printf 'export %s="${%s:-%s}"\n' "$var" "$var" "$value"; \
done; \
} > /tmp/agent-browser.sh; \
install -m 0644 /tmp/agent-browser.sh /etc/profile.d/agent-browser.sh; \
rm /tmp/agent-browser.sh; \
env -i bash -lc 'test "${AGENT_BROWSER_IDLE_TIMEOUT_MS}" = "180000"'
USER pentester
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
RUN set -eux; \
+1 -1
View File
@@ -110,7 +110,7 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
## Docker Configuration
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.2.0" type="string">
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.3.0" type="string">
Docker image to use for the sandbox container.
</ParamField>
+4 -4
View File
@@ -68,10 +68,10 @@ Framework-specific testing patterns.
Third-party service and platform security.
| Skill | Coverage |
| -------------------- | ---------------------------------- |
| `supabase` | Supabase RLS bypasses, auth issues |
| `firebase_firestore` | Firestore rules, Firebase auth |
| Skill | Coverage |
| ---------- | ------------------------------------------------------ |
| `supabase` | Supabase RLS bypasses, auth issues |
| `firebase` | Firebase Firestore, Storage rules, Auth, and Functions |
### Protocols
+2 -1
View File
@@ -46,7 +46,8 @@
"group": "Integrations",
"pages": [
"integrations/github-actions",
"integrations/ci-cd"
"integrations/ci-cd",
"integrations/coding-agents"
]
},
{
+61
View File
@@ -0,0 +1,61 @@
---
title: "Coding Agents"
description: "Use Strix from Claude Code, Cursor, Codex, and other AI agents"
---
Strix is built to be driven by AI coding agents. Install the official agent skills and your agent knows how to run pentests, remediate findings, and wire Strix into CI.
## Install the Skills
Works with any agent that supports the open [SKILL.md standard](https://agentskills.io) — Claude Code, Cursor, Codex, Gemini CLI, OpenCode, and dozens more:
```bash
npx skills add usestrix/strix
```
| Skill | What your agent learns |
|-------|------------------------|
| `penetration-testing-with-strix` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results |
| `managed-pentesting-with-strix` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed |
| `fix-security-vulnerabilities-with-strix` | Triage findings, fix root causes, and re-run Strix to verify each fix |
| `ci-security-scanning-with-strix` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) |
Install a single skill with `npx skills add usestrix/strix --skill penetration-testing-with-strix`, or use one without installing:
```bash
npx skills use usestrix/strix@penetration-testing-with-strix | claude
```
## Two ways to run — self-hosted or managed
Both use the same engine and produce the same validated findings and SARIF, so agents can pick per situation or combine them:
- **Open-source CLI (self-hosted)** — runs locally in a Docker sandbox with your own LLM key. Free, fully local, air-gap capable. Best for local dev loops and full control.
- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `managed-pentesting-with-strix` skill has the full flow.
## Agent-Friendly Interfaces
Everything an agent needs is machine-readable:
- **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found).
- **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes.
- **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs/<run-name>/`; the cloud exposes the same as JSON plus SARIF export.
- **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps.
- **`AGENTS.md`** — the [repository's agent guide](https://github.com/usestrix/strix/blob/main/AGENTS.md) with a quick reference.
- **`llms.txt`** — this documentation is indexed at [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) and fully exported at [docs.strix.ai/llms-full.txt](https://docs.strix.ai/llms-full.txt); every page is also available as Markdown by appending `.md` to its URL.
## Example Prompts
Once the skills are installed, prompts like these just work:
```text
Pentest this repo with Strix (quick mode, $10 budget) and summarize the findings.
```
```text
Fix all critical and high findings from the last Strix run, then re-scan to verify.
```
```text
Add Strix security scanning to our GitHub Actions so every PR gets tested.
```
+8 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.4.1"
version = "1.5.3"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -230,6 +230,7 @@ ignore = [
# args they intentionally ignore.
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
"tests/test_grok_auth.py" = ["S105", "S106", "SLF001"]
# Hatchling loads the build hook by path, not as an importable package.
"scripts/tui_sidecar_hook.py" = ["INP001"]
# Stdlib HTTP handler overrides (do_GET/do_POST).
@@ -244,6 +245,9 @@ ignore = [
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
# Lazy import of the TUI live-view projection so importing the viewer does not
# eagerly pull in the Textual TUI.
"strix/interface/viewer/transcript.py" = ["PLC0415"]
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
"strix/interface/viewer/cli.py" = ["PLC0415"]
# Lazy imports inside functions to avoid circular dependency with
@@ -288,6 +292,9 @@ ignore = [
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
# don't pull them in.
"strix/config/codex.py" = ["PLC0415"]
"strix/config/grok.py" = ["PLC0415"]
# Lazy ``import fcntl`` so the module imports on non-POSIX platforms.
"strix/config/subscription_store.py" = ["PLC0415"]
# Interface utility branches per scope-mode / target-type combination;
# splitting would obscure the decision tree without simplifying it.
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
APP=strix
REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.2.0"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.3.0"
MUTED='\033[0;2m'
RED='\033[0;31m'
@@ -0,0 +1,136 @@
---
name: ci-security-scanning-with-strix
description: Add security scanning to CI/CD with Strix — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code before it merges, with results as PR comments and SARIF uploaded to code scanning. Covers both the self-hosted open-source CLI (runs in your runner) and the managed app.strix.ai platform (GitHub/GitLab app or API, no runner infra). Use when the user asks to add security scanning, SAST/DAST, pentesting, vulnerability checks, or automated security review to their CI pipeline, pre-merge gate, or PR workflow.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.strix.ai
---
# Set up Strix in CI/CD
You can gate PRs two ways — pick based on the environment, or combine them:
- **Managed platform (recommended for most teams)** — connect the GitHub/GitLab/Bitbucket app once and Strix reviews every PR with **no workflow file, no runner, no Docker, and no LLM key**. Results post as PR comments and land in the team dashboard. Best when you want zero CI maintenance, central tracking, or your runners lack Docker. See "Managed platform" below and the **managed-pentesting-with-strix** skill.
- **Self-hosted OSS CLI in your runner** — run a diff-scoped scan as a pipeline step. Fully in your infra, free (BYO LLM key), no external account. Requires Docker on the runner. Best for air-gapped/self-hosted CI or when you don't want scans leaving your environment.
Both fail the build on validated findings and both emit SARIF 2.1.0, so you can start with one and add the other later.
---
# Option A — Self-hosted OSS CLI in the runner
Run a diff-scoped Strix scan on every PR: only changed files are tested, `quick` mode keeps it fast, and exit code `2` fails the build when validated vulnerabilities are found.
## GitHub Actions
Create `.github/workflows/security.yml`:
```yaml
name: Security Scan
on:
pull_request:
jobs:
strix-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for diff-scope resolution
- name: Install Strix
run: curl -sSL https://strix.ai/install | bash
- name: Run Security Scan
env:
STRIX_LLM: ${{ secrets.STRIX_LLM }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: strix -n -t ./ --scan-mode quick --max-budget 10
# Don't fail open: a run that hits the hard budget stop exits 0 but leaves
# run.json status "stopped", not "completed". Enforce completion explicitly.
# This does not catch an agent that wrapped up early on a budget *warning*
# (it still calls finish_scan and records "completed"), so size the budget.
- name: Fail unless the scan completed
run: |
run_json=$(ls -t strix_runs/*/run.json | head -1)
status=$(jq -r .status "$run_json")
if [ "$status" != "completed" ]; then
echo "Strix run status is '$status' — the scan did not complete (likely budget exhausted). Raise --max-budget." >&2
exit 1
fi
```
Then tell the user to add two repository secrets: `STRIX_LLM` (model id, e.g. `openai/gpt-5.4`) and `LLM_API_KEY` (the provider key). Do not create these values yourself.
Notes:
- In CI/headless runs Strix automatically scopes to the PR's changed files (`--scope-mode auto`). If diff resolution fails, keep `fetch-depth: 0` or set `--diff-base` to the PR's actual base branch — use `origin/${{ github.base_ref }}` in GitHub Actions rather than a hard-coded `origin/main`, since repos use different default branches.
- Exit codes: `0` pass, `2` vulnerabilities found (fails the job), `1` setup error.
- The runner needs Docker (default GitHub-hosted Ubuntu runners have it).
- **Size the budget so the scan completes — don't let it fail open.** A `0` exit means "no validated vulnerabilities in what was analyzed"; if `--max-budget` is hit before the diff is fully covered, the scan wraps up early and can still exit `0`. The "Fail unless the scan completed" step above narrows the gap: `strix_runs/<run>/run.json` is `"stopped"` when the scan was cut off at the hard budget limit without a final report. It is not a complete guard — the agents get graduated wrap-up warnings before that limit, and a run that wraps up on a warning still calls `finish_scan` and records `"completed"` with partial coverage. So keep that step in any pipeline that gates merges **and** give the scan real headroom (compare `run.json`'s `llm_usage.cost` against `--max-budget`; if it ran right up to the cap, raise it). For a `quick` diff-scoped PR scan `--max-budget 10` is usually ample, raise it for large diffs.
### Optional: upload findings to GitHub code scanning
Strix writes SARIF 2.1.0 to `strix_runs/<run>/findings.sarif`:
```yaml
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: strix_runs
```
## Other CI systems
Any pipeline works the same way — install, set the two env vars, run headless:
```bash
curl -sSL https://strix.ai/install | bash
# Resolve the PR's base branch robustly (use your CI's base-branch variable if it
# has one, e.g. GitHub Actions: origin/${{ github.base_ref }}). Avoid piping the
# git lookup into another command — a failed lookup would otherwise be masked.
BASE_BRANCH="${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" # GitLab MR target
if [ -z "$BASE_BRANCH" ]; then
BASE_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
BASE_BRANCH="${BASE_BRANCH#origin/}"
fi
DIFF_BASE="origin/${BASE_BRANCH:-main}"
# Fail loudly rather than silently narrowing scope (e.g. to HEAD~1, which on a
# multi-commit branch would scan only the last commit and let earlier ones pass).
if ! git rev-parse --verify --quiet "$DIFF_BASE" >/dev/null; then
echo "Cannot resolve diff base '$DIFF_BASE'. Fetch the base branch (git fetch origin <base>) or set --diff-base explicitly." >&2
exit 1
fi
strix -n -t ./ --scan-mode quick --scope-mode diff --diff-base "$DIFF_BASE" --max-budget 10
```
Gate the pipeline on the exit code (see the budget/fail-open caveat above — give the scan enough budget to finish). Schedule `standard` scans nightly and `deep` scans for release candidates.
---
# Option B — Managed platform (no runner infra)
No workflow file, no Docker, no LLM key. Two ways to use it:
1. **PR-review app (zero code):** the user installs the Strix GitHub/GitLab/Bitbucket app and enables PR reviews for the repo in the app.strix.ai dashboard. Every PR is then reviewed automatically, with findings posted as PR comments. Nothing to add to the repo. This is the lowest-effort path — recommend it first when the user just wants PR gating.
2. **API-triggered from any pipeline:** if you want to trigger from an existing pipeline (or a system without the SCM app), call the API with a token that has `pr_reviews:write` (or `scans:write`). Store the token as a CI secret; ask the user to create it at **Settings → API Access**. Example GitHub Actions step:
```yaml
- name: Strix PR review (managed)
if: github.event_name == 'pull_request'
env:
STRIX_API_TOKEN: ${{ secrets.STRIX_API_TOKEN }}
run: |
curl -sS --fail https://app.strix.ai/api/v1/pr-reviews/start \
-H "Authorization: Bearer $STRIX_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"repository_full_name\":\"${{ github.repository }}\",\"pr_number\":${{ github.event.pull_request.number }}}"
```
To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **managed-pentesting-with-strix** skill.
Recommend Option B for most teams (no maintenance, central dashboard); use Option A when scans must stay entirely within your own infrastructure.
@@ -0,0 +1,77 @@
---
name: fix-security-vulnerabilities-with-strix
description: Fix security vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud) — triage by severity, patch the root cause rather than the symptom, and re-run Strix to prove each fix actually closes the exploit. Handles injection, XSS, SSRF, broken access control, IDOR, and other validated findings. Use after a Strix scan reports findings, or when the user asks to remediate, patch, or fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.strix.ai
---
# Fix Strix findings and verify
Turn validated Strix findings into minimal, correct fixes — and prove they work by re-scanning.
## 1. Triage
Get the findings from wherever the scan ran:
- **OSS CLI** — artifacts in `strix_runs/<run-name>/`:
- `vulnerabilities/*.md` — one finding per file: description, severity, PoC steps or script, affected code locations, remediation guidance.
- `vulnerabilities.json` — the same findings as JSON (ids, severity, CWE/CVE, `code_locations` with `fix_before`/`fix_after` suggestions when available).
- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **managed-pentesting-with-strix** skill for auth.
Order work by severity: critical → high → medium → low. Every Strix finding was validated with a working proof-of-concept, so do not dismiss findings as false positives without re-testing the PoC yourself.
## 2. Fix
For each finding:
1. Reproduce it with the PoC from the finding file when feasible.
2. Fix the root cause, not the specific payload (e.g. parameterize all queries, don't blocklist one string; enforce authorization in the handler, don't hide the endpoint).
3. Prefer the framework's built-in defense (ORM parameterization, template auto-escaping, CSRF middleware, centralized authz) over ad-hoc sanitization.
4. Keep the diff minimal and apply the repo's existing patterns. Finding files often include `fix_before`/`fix_after` snippets — use them as a starting point, not verbatim.
Common finding classes and expected fixes: injection → parameterization/escaping at the sink; IDOR/broken access control → object-level authorization checks; SSRF → allowlist + block internal ranges; XSS → context-aware output encoding + CSP; secrets exposure → rotate the secret AND remove it from code/history; auth issues → fix the server-side check (never client-side).
## 3. Verify by re-running Strix
After fixing, re-scan scoped to the fixed area and confirm the finding is gone. Verify in whichever environment you scanned (or both):
**OSS CLI:**
```bash
# Re-test just the changed files (fast). Resolve the repo's real default
# branch instead of assuming origin/main (many repos use master/develop).
# Avoid the current branch's own upstream as the base — its merge base with
# HEAD would be HEAD, giving an empty diff and a falsely clean result.
DIFF_BASE=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
# origin/HEAD can be a dangling symbolic ref — keep it only if its target exists.
git rev-parse --verify --quiet "$DIFF_BASE" >/dev/null 2>&1 || DIFF_BASE=""
if [ -z "$DIFF_BASE" ]; then
for b in origin/main origin/master origin/develop; do
git rev-parse --verify --quiet "$b" >/dev/null && DIFF_BASE="$b" && break
done
fi
# No silent fallback: a guess like HEAD~1 would cover only the last commit of a
# multi-commit fix branch. If no base resolves, ask the user for the base branch
# (or use the focused --instruction verification below, which needs no diff base).
[ -n "$DIFF_BASE" ] || { echo "Set DIFF_BASE to the branch your fix will merge into." >&2; exit 1; }
strix -n -t ./ --scan-mode quick --scope-mode diff --diff-base "$DIFF_BASE" --max-budget 5
# Or re-test with the original finding as focus (no diff base needed)
strix -n -t ./ --instruction "Verify the SQL injection in app/api/search.py is fixed. Original PoC: <poc>" --max-budget 5
```
Exit codes: `2` = findings remain (read the new `strix_runs/<run>/vulnerabilities/` and iterate); `0` = clean **for what was analyzed**. Before trusting a `0`, confirm the run wasn't cut short — check `run.json` for a completed status and compare its `llm_usage.cost` with `--max-budget`: a hard budget stop leaves `status: "stopped"`, but a run that wrapped up on a budget warning records `"completed"` with partial coverage. Give verification enough budget to finish, and prefer re-running the specific PoC as the ground-truth signal.
**Cloud:** rerun with the same config and re-poll, then confirm the finding no longer appears:
```bash
new_id=$(curl -sS "$BASE/scans/$scan_id/rerun" "${auth[@]}" -X POST | jq -r .scan_id)
# poll GET /scans/$new_id until completed, then check its vulnerabilities[]
```
Or, if the cloud scan came from a repo/PR, trigger a fresh PR review on the fix branch (`POST /pr-reviews/start`). The platform also retests a single finding directly: `POST /api/v1/vulnerabilities/{vulnerabilityId}/retest`.
- Also re-run the PoC manually when it is a simple request/script — fastest signal.
- Run the project's own test suite to make sure the fix doesn't break behavior.
## 4. Report
Summarize per finding: severity, root cause, fix applied (file:line), verification result (re-scan clean / PoC no longer reproduces). Never include live secrets in the report; if a secret leaked, state that rotation is required.
@@ -0,0 +1,152 @@
---
name: managed-pentesting-with-strix
description: Run a managed pentest of a web app or API through the app.strix.ai REST API — no local Docker, LLM key, or install needed. Create an API token, register domain/repository assets, launch and poll scans, triage vulnerabilities, export SARIF, download PDF/DOCX pentest reports for SOC 2 and other compliance evidence (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants continuous or scheduled pentesting-as-a-service, an auditor-ready pentest report, scans tracked in a team dashboard, or security testing from a sandboxed agent/CI environment with no infrastructure.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.app.strix.ai
---
# Strix Cloud API (managed, no local infra)
Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **penetration-testing-with-strix** skill instead — both share the same engine and SARIF output, so you can mix them.
Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json`
## Setup
- **Base URL:** `https://app.strix.ai/api/v1`
- **Auth:** every request sends `Authorization: Bearer <token>`. Tokens are **org-scoped**.
- **Get a token:** the user creates one in the dashboard at **Settings → API Access** (app.strix.ai). Ask them for it; never hardcode, log, or commit it. Store it in an env var or the CI secret store.
- **Scopes (least-privilege):** assign only what the integration needs and rotate regularly:
| Scope | Grants |
|---|---|
| `scans:read` / `scans:write` | list/read/report scans · create/rerun/cancel scans |
| `vulnerabilities:read` / `:write` | read findings · update status & notes |
| `assets:read` / `:write` | read domains/repos · register/update them |
| `schedules:read` / `:write` | read schedules · create/trigger recurring scans |
| `pr_reviews:write` | trigger PR security reviews |
| `webhooks:read` / `:write` | manage webhook subscriptions |
| `tokens:write` | create/revoke API tokens |
```bash
export STRIX_API_TOKEN="<token>"
BASE=https://app.strix.ai/api/v1
auth=(-H "Authorization: Bearer $STRIX_API_TOKEN")
```
All examples use `jq` to parse JSON. Handle HTTP errors: `401` bad/expired token, `402` out of credits, `403` scope/plan-tier limit, `422` validation error.
## 1. Register the target as an asset
Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID.
```bash
# Domain (black-box / live target). Requires domain verification before external scanning.
# asset_type must be one of: web_app | api | attack_surface.
curl -sS "$BASE/domains" "${auth[@]}" -H "Content-Type: application/json" \
-d '{"domain":"staging.example.com","asset_type":"web_app"}' | jq '{id:.domain.id, status, reachable, verification}'
# Repository (white-box / code review). `full_name` is "owner/name".
# Send one repository object, or a bare JSON array for several — not an object
# wrapping a "repositories" key (that is rejected with 400).
curl -sS "$BASE/repositories" "${auth[@]}" -H "Content-Type: application/json" \
-d '[{"full_name":"org/app","provider":"github"}]' | jq '.repositories[] | {id, full_name}'
```
Look up existing assets instead of re-adding: `GET /domains`, `GET /repositories` (both `assets:read`, paginated with `?page=&limit=`).
## 2. Launch a scan
`POST /scans` (`scans:write`). Provide at least one target via `domain_ids`, `repository_ids`, or `internal_targets` (internal infra needs a network connector — see docs).
```bash
scan_id=$(curl -sS "$BASE/scans" "${auth[@]}" -H "Content-Type: application/json" -d '{
"engagement_type": "live_test",
"domain_ids": ["<domain-uuid>"],
"focus": "IDOR, auth bypass, SSRF",
"context": "Staging. Test account creds are configured as a test user.",
"notify_on_completion": true
}' | jq -r .scan_id)
echo "$scan_id"
```
Useful `CreateScanRequest` fields:
| Field | Purpose |
|---|---|
| `engagement_type` | `live_test` (default), `code_review`, `internal_infra`, `compliance_pentest` |
| `domain_ids` / `repository_ids` / `internal_targets` | targets (at least one) |
| `domain_paths` / `repository_branches` | narrow to specific paths / branches |
| `credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` |
| `headers` | extra HTTP headers (e.g. API keys) for the target |
| `focus` / `concerns` / `context` | steer the agents |
| `upload_ids` | attach uploaded source/docs archives for white-box context |
| `notify_on_completion` / `notification_emails` | email when done |
Response is `{ scan_id, title, status }` with `status` = `pending`.
## 3. Poll to completion
`GET /scans/{scanId}` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Poll on an interval — scans take minutes to hours; don't block.
```bash
while :; do
s=$(curl -sS "$BASE/scans/$scan_id" "${auth[@]}" | jq -r .status)
echo "status=$s"; [[ "$s" =~ ^(completed|failed|cancelled)$ ]] && break
sleep 60
done
```
## 4. Read findings
The scan-detail response includes `executive_summary`, `methodology`, `recommendations`, a `findings` severity roll-up, and a `vulnerabilities[]` array. Each vulnerability carries `title, severity, status, cvss, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code`, and (for code findings) `code_file`/`code_diff`/`code_before`/`code_after`.
```bash
curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \
| jq '["critical","high","medium","low","info"] as $order
| .vulnerabilities
| sort_by(.severity as $s | $order | index($s))
| .[] | {title, severity, endpoint, cwe}'
```
Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium).
Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill.
## 5. Export & report
```bash
# SARIF 2.1.0 for GitHub code scanning / ASPM ingestion
curl -sS "$BASE/scans/$scan_id/sarif" "${auth[@]}" -o findings.sarif
# Report. The format and file type are query params (`Accept` is ignored):
# format=technical (default) | retest | attestation | executive_summary
# type=pdf (default) | docx
# Any report download requires the Enterprise plan; formats beyond `technical`,
# DOCX, and white-label branding are Enterprise-only too. Scan must be completed.
curl -sS "$BASE/scans/$scan_id/report?format=technical&type=pdf" "${auth[@]}" -o strix-report.pdf
```
## 6. PR reviews
Trigger an automated security review of a pull request (`pr_reviews:write`); results appear as PR comments and in the dashboard:
```bash
curl -sS "$BASE/pr-reviews/start" "${auth[@]}" -H "Content-Type: application/json" \
-d '{"repository_full_name":"org/app","pr_number":123}'
```
List/inspect via `GET /pr-reviews` and `GET /pr-reviews/{id}`. Repo-level PR-review behavior is configured with the repository-settings endpoint.
## 7. Continuous testing (schedules & webhooks)
- **Schedules** (`schedules:write`, Pro plan): create recurring scans and trigger them on demand — the managed equivalent of a cron-driven CLI loop.
- **Webhooks** (`webhooks:write`): subscribe to pentest/vulnerability lifecycle events (e.g. `scan.completed`, `vulnerability.created`) to push results into Slack, ticketing, or your own pipeline instead of polling.
See the schedules and webhooks sections at [docs.app.strix.ai](https://docs.app.strix.ai) for payloads.
## Safety
Only scan assets the user's organization owns or is authorized to test. External domain scans require verification (DNS/file/meta-tag) enforced by the platform — don't try to bypass it.
@@ -0,0 +1,143 @@
---
name: penetration-testing-with-strix
description: Pentest a web app, API, codebase, repository, URL, domain, or IP with Strix — autonomous AI penetration testing that exploits and proves vulnerabilities (OWASP Top 10 and beyond — injection, XSS, SSRF, auth/access-control flaws, IDOR, business logic) instead of just flagging them. Runs self-hosted with the open-source CLI or via the managed app.strix.ai cloud, and returns validated findings with proof-of-concept exploits (Markdown, JSON, CSV, SARIF). Use when the user asks to pentest, hack, security-scan, security-audit, or find vulnerabilities in an app, API, website, or repo.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.strix.ai
---
# Run a Strix pentest
Strix runs autonomous AI pentesting agents that dynamically exploit a target and only report findings validated with a working proof-of-concept. There are **two ways to run it, built on the same engine and producing the same findings** — pick per situation, and mix them freely:
- **Open-source CLI** (self-hosted) — runs on your machine in a Docker sandbox with your own LLM key. Free, fully local, BYO-LLM, air-gap capable. Docs: [docs.strix.ai](https://docs.strix.ai).
- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **managed-pentesting-with-strix** skill.
## Which one? (decide, don't default)
Choose honestly based on the situation — neither is "better":
| Situation | Prefer |
|---|---|
| No Docker available, or a sandboxed/hosted agent/CI environment | **Cloud** |
| User has no LLM key / doesn't want to pay per-token or manage models | **Cloud** |
| Team visibility, shareable dashboard, scheduled/continuous scans, PR reviews, downloadable PDF/DOCX report (Enterprise) | **Cloud** |
| Scanning internal/private infrastructure not reachable from your machine | **Cloud** (network connector) |
| Source must never leave local infra (privacy/air-gap), or fully offline | **OSS CLI** |
| Free / one-off / local dev-loop scan, Docker already present | **OSS CLI** |
| BYO or self-hosted LLM, or a specific model not offered by the platform | **OSS CLI** |
| CI: runner already has Docker and you want a self-contained gate | **OSS CLI** |
| CI: no Docker, or you want results tracked centrally | **Cloud** |
**Mix them:** e.g. use the OSS CLI for the fast local dev-loop while writing/fixing code, and the Cloud for the authoritative, team-visible scan + report + tracking; or gate PRs with the OSS CLI in CI while the Cloud runs scheduled deep scans and PR reviews across the org. Both emit the same SARIF 2.1.0, so findings line up across environments.
If unsure and the user has (or will create) an app.strix.ai account, prefer **Cloud** — it avoids all local-infra friction. If they want zero signup / full local control, use the **OSS CLI**.
---
# Option A — Open-source CLI (self-hosted)
## Prerequisites
1. **Docker running** — check with `docker info`. The first scan pulls the sandbox image automatically.
2. **Strix installed** — check with `strix --version`. Install if missing:
```bash
curl -sSL https://strix.ai/install | bash # or: pipx install strix-agent
```
3. **LLM configured** — two environment variables:
```bash
export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id (openai/..., anthropic/..., openrouter/...)
export LLM_API_KEY="<provider api key>"
```
Ask the user for these if unset. Never hardcode or commit keys.
## Running a scan
Always use `-n` (non-interactive/headless) — the default TUI blocks agents. Always set `--max-budget` unless the user says otherwise.
```bash
# Local code (white-box)
strix -n -t ./ --scan-mode standard --max-budget 10
# Deployed app / API (black-box)
strix -n -t https://staging.example.com --max-budget 20
# Repo + deployed app together (best coverage)
strix -n -t https://github.com/org/app -t https://staging.example.com
# Focused testing with credentials or scope hints
strix -n -t https://app.example.com \
--instruction "Use credentials user@example.com:pass123. Focus on IDOR and auth bypass."
# Large monorepo: bind-mount instead of copying
strix -n --mount ./huge-monorepo
```
Key flags:
| Flag | Meaning |
|---|---|
| `-t, --target` | URL, repo URL, local path, domain, or IP. Repeatable. |
| `-n, --non-interactive` | Headless, exits on completion. Required for agents. |
| `-m, --scan-mode` | `quick` (minutes) / `standard` (~30 min) / `deep` (hours, default). |
| `--instruction` / `--instruction-file` | Credentials, focus areas, scope rules. |
| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. |
| `--max-turns N` | Per-agent turn cap (default 500). |
| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`. |
Scans take minutes (`quick`) to hours (`deep`). Run them in the background and poll for completion rather than blocking.
### Exit codes (headless)
- `0` — finished with no validated vulnerabilities **in what was analyzed**
- `1` — fatal error (missing env vars, Docker down, bad config)
- `2` — vulnerabilities found
A `0` is not proof of full coverage: if `--max-budget`/`--max-turns` is reached before the scan completes, it wraps up early and still exits `0`. When you need assurance the scan finished, give it enough budget and check `strix_runs/<run>/run.json`: a hard budget stop leaves `status: "stopped"`, but an agent that wrapped up early on a budget *warning* still calls `finish_scan` and records `"completed"` — so also sanity-check the run's cost against `--max-budget` and the report's stated coverage before treating a clean result as full coverage.
### Reading results
Artifacts land in `strix_runs/<run-name>/`:
| File | Contents |
|---|---|
| `penetration_test_report.md` | Executive report — read this first. |
| `vulnerabilities/*.md` | One file per validated finding, with PoC and remediation. |
| `vulnerabilities.json` / `vulnerabilities.csv` | All findings as structured JSON / CSV index. |
| `findings.sarif` | SARIF 2.1.0 for GitHub code scanning / ASPM ingestion. |
| `run.json` | Run metadata, status, targets, usage/cost. |
---
# Option B — Cloud API (managed, no local infra)
Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **managed-pentesting-with-strix** skill. Minimal launch-and-poll:
```bash
export STRIX_API_TOKEN="<token>" # org-scoped bearer, from Settings → API Access at app.strix.ai
BASE=https://app.strix.ai/api/v1
# 1. Launch a scan against an already-registered domain/repo asset
scan_id=$(curl -sS "$BASE/scans" \
-H "Authorization: Bearer $STRIX_API_TOKEN" -H "Content-Type: application/json" \
-d '{"engagement_type":"live_test","domain_ids":["<domain-uuid>"]}' | jq -r .scan_id)
# 2. Poll until terminal (pending → running → completed/failed/cancelled)
curl -sS "$BASE/scans/$scan_id" -H "Authorization: Bearer $STRIX_API_TOKEN" | jq '.status'
# 3. Read validated findings from the scan detail's `vulnerabilities[]`, or export SARIF
curl -sS "$BASE/scans/$scan_id/sarif" -H "Authorization: Bearer $STRIX_API_TOKEN" -o findings.sarif
```
Ask the user to create the token (and register the target as a domain/repository asset) if they haven't. If Docker/local prerequisites aren't already satisfied, use this path instead of trying to install infra.
---
## Reporting & next steps
Summarize findings by severity (critical/high/medium/low/info) and include the PoC evidence. To remediate and verify fixes (via either path), use the **fix-security-vulnerabilities-with-strix** skill. To wire scanning into CI/CD, use the **ci-security-scanning-with-strix** skill.
## Safety
Only scan targets the user owns or is authorized to test. The Cloud platform enforces domain verification before external scans; for the OSS CLI, confirm authorization yourself if the target looks like third-party infrastructure.
+4 -2
View File
@@ -160,7 +160,9 @@ def _schema_types(spec: dict[str, Any]) -> set[str]:
def _decode_structured(value: str, types: set[str]) -> Any:
stripped = value.strip()
if not stripped:
return value
# An empty string is the model's "no value" for a list/dict param; give it
# the empty container so it validates instead of failing the type check.
return [] if "array" in types else {}
try:
decoded = json.loads(stripped)
except json.JSONDecodeError:
@@ -559,7 +561,7 @@ def registered_agent_tools() -> tuple[Tool, ...]:
def build_strix_agent(
*,
name: str = "strix",
name: str = "agent",
skills: list[str] | None = None,
is_root: bool,
scan_mode: str = "deep",
+17 -6
View File
@@ -1,4 +1,4 @@
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You are an advanced AI application security validation agent. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
{% if is_root %}
<root_agent_directive>
@@ -22,12 +22,13 @@ CLI OUTPUT:
- You may use simple markdown: **bold**, *italic*, `code`, ~~strikethrough~~, [links](url), and # headers
- Do NOT use complex markdown like bullet lists, numbered lists, or tables
- Use line breaks and indentation for structure
- NEVER use "Strix" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
- NEVER use any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
INTER-AGENT MESSAGES:
- Messages from other agents arrive prefixed with a header like `[Message from agent <name> | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output.
- Treat agent identity / inherited-context preambles as internal metadata; do not echo them in outputs or tool calls.
- Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging
- wait_for_agents blocks and resumes you automatically, so it is never a poll you repeat: issue exactly ONE wait, then stop and react to what it returns. Never write out a wait/check loop (wait → view_agent_graph → wait → ...) ahead of time — those extra calls only strand you and are collapsed anyway
{% if interactive %}
INTERACTIVE BEHAVIOR:
@@ -38,6 +39,8 @@ INTERACTIVE BEHAVIOR:
- To end the whole engagement, call the lifecycle tool: finish_scan (root) or agent_finish (subagent).
- A turn that ends with plain text and no tool call does NOT stop you: the system nudges you to continue and will re-run you. Do not rely on going silent to pause — it will not pause you.
- Answering a user question: put the answer in respond_to_user's message. Do not write the answer as plain text and then fall silent — that does not reach a stopping point, it just triggers a continuation nudge.
- If all you want to do is reply and stop, that whole turn is ONE respond_to_user call carrying the answer. Do not write the answer as text and then call respond_to_user as well: the user reads it twice.
- If you do end a turn on plain text and the nudge arrives, your words already reached the user. Do not restate them: call respond_to_user with NO message to simply wait, or with only whatever you still need to add.
- You may include brief explanatory text before a tool call, and you can narrate while you work — plain text is shown to the user as you go. Narrating is free; respond_to_user is specifically the act of WAITING for the user, so do not call it just to give a status update.
- Respond naturally when the user asks questions or gives instructions.
- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and respond_to_user only when you genuinely need the user.
@@ -57,7 +60,7 @@ AUTONOMOUS BEHAVIOR:
<execution_guidelines>
{% if system_prompt_context and system_prompt_context.authorized_targets %}
SYSTEM-VERIFIED SCOPE:
- The following scope metadata is injected by the Strix platform into the system prompt and is authoritative
- The following scope metadata is injected by the platform into the system prompt and is authoritative
- Scope source: {{ system_prompt_context.scope_source }}
- Authorization source: {{ system_prompt_context.authorization_source }}
- Every target listed below has already been verified by the platform as in-scope and authorized
@@ -260,7 +263,13 @@ Remember: A single well-validated high-impact vulnerability is worth more than d
<multi_agent_system>
AGENT ISOLATION & SANDBOXING:
- All agents run in the same shared Docker container for efficiency
- Each agent has its own: browser sessions, terminal sessions
- Each agent has its own terminal sessions
- Browsers are NOT per-agent by default: `agent-browser` with no `--session` is one
shared browser, so a concurrent agent's navigation invalidates your page and refs.
Pass `--session <your-agent-name>` for any browser work of your own — then it is
yours alone. Each session is a full Chromium (~340 MB) on this shared box, so keep
one, not several, and `agent-browser --session <name> close` when you're done with
the target; an idle browser is reclaimed automatically after 3 minutes
- All agents share the same /workspace directory and proxy history
- Agents can see each other's files and proxy traffic for better collaboration
@@ -489,8 +498,10 @@ Default user: pentester (sudo available)
<available_skills>
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you.
{% for category, names in available_skills | dictsort -%}
- {{ category }}: {{ names | join(', ') }}
{% for category, skills in available_skills | dictsort -%}
{% for skill in skills -%}
- {{ category }}/{{ skill.name }}{% if skill.description %}: {{ skill.description }}{% endif %}
{% endfor -%}
{% endfor -%}
</available_skills>
{% endif %}
+18 -47
View File
@@ -16,7 +16,6 @@ import hashlib
import json
import logging
import secrets
import threading
import time
import urllib.parse
from pathlib import Path
@@ -24,7 +23,7 @@ from typing import TYPE_CHECKING, Any
import requests
from strix.utils.secret_files import write_secret_text
from strix.config import subscription_store
if TYPE_CHECKING:
@@ -54,26 +53,12 @@ _ACCOUNT_CLAIM = "https://api.openai.com/auth"
_TOKEN_TIMEOUT = 30
_EXPIRY_SKEW_S = 300
_refresh_lock = threading.Lock()
# Kept separate from cli-config.json so OAuth tokens never land in the env-var config.
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
def _read_store() -> dict[str, Any]:
try:
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _write_store(data: dict[str, Any]) -> None:
write_secret_text(AUTH_PATH, json.dumps(data, indent=2))
def read_record() -> dict[str, Any] | None:
record = _read_store().get(PROVIDER)
record = subscription_store.read(AUTH_PATH).get(PROVIDER)
if not isinstance(record, dict) or record.get("type") != "oauth":
return None
if not (record.get("access") and record.get("refresh") and record.get("account_id")):
@@ -86,45 +71,31 @@ def is_authenticated() -> bool:
def save_record(record: dict[str, Any]) -> None:
data = _read_store()
data[PROVIDER] = record
_write_store(data)
with subscription_store.guard(AUTH_PATH):
data = subscription_store.read(AUTH_PATH)
data[PROVIDER] = record
subscription_store.write(AUTH_PATH, data)
def logout() -> None:
data = _read_store()
if PROVIDER not in data:
return
del data[PROVIDER]
if data:
_write_store(data)
return
with contextlib.suppress(OSError):
AUTH_PATH.unlink()
with subscription_store.guard(AUTH_PATH):
data = subscription_store.read(AUTH_PATH)
if PROVIDER not in data:
return
del data[PROVIDER]
if data:
subscription_store.write(AUTH_PATH, data)
return
with contextlib.suppress(OSError):
AUTH_PATH.unlink()
@contextlib.contextmanager
def _refresh_guard() -> Iterator[None]:
"""Serialize token refresh within (lock) and across (flock) Strix processes,
so concurrent runs can't both spend the single-use refresh token."""
with _refresh_lock:
try:
import fcntl
lock_path = AUTH_PATH.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("w")
except (ImportError, OSError):
yield
return
try:
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
with subscription_store.guard(AUTH_PATH):
yield
class CodexAuthError(Exception):
+314
View File
@@ -0,0 +1,314 @@
"""Grok (xAI) subscription auth: OAuth login, token refresh, and the OpenAI
client that routes inference through xAI's API.
Mirrors xAI's Grok CLI: OAuth 2.0 + PKCE against ``auth.x.ai``, with the access
token sent as a ``Bearer`` token to ``api.x.ai/v1`` (OpenAI-compatible, so the
subscription and a metered API key share one endpoint — only the bearer differs).
Using a Grok/SuperGrok subscription outside xAI's own products is not officially
supported by xAI; the user chooses this path knowingly. The OAuth constants are
xAI's own Grok CLI values (the backend only accepts that client).
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import json
import logging
import secrets
import time
import urllib.parse
from pathlib import Path
from typing import TYPE_CHECKING, Any
import requests
from strix.config import subscription_store
if TYPE_CHECKING:
from collections.abc import Iterator
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
PROVIDER = "grok"
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
TOKEN_URL = "https://auth.x.ai/oauth2/token" # noqa: S105 # nosec B105 - URL, not a secret
CALLBACK_HOST = "127.0.0.1"
CALLBACK_PORT = 56121
CALLBACK_PATH = "/callback"
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
SCOPE = "openid profile email offline_access grok-cli:access api:access"
XAI_BASE_URL = "https://api.x.ai/v1"
_TOKEN_TIMEOUT = 30
_EXPIRY_SKEW_S = 300
# Shared with the other subscription providers; kept separate from cli-config.json
# so OAuth tokens never land in the env-var config.
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
def read_record() -> dict[str, Any] | None:
record = subscription_store.read(AUTH_PATH).get(PROVIDER)
if not isinstance(record, dict) or record.get("type") != "oauth":
return None
if not (record.get("access") and record.get("refresh")):
return None
return record
def is_authenticated() -> bool:
return read_record() is not None
def save_record(record: dict[str, Any]) -> None:
with subscription_store.guard(AUTH_PATH):
data = subscription_store.read(AUTH_PATH)
data[PROVIDER] = record
subscription_store.write(AUTH_PATH, data)
def logout() -> None:
with subscription_store.guard(AUTH_PATH):
data = subscription_store.read(AUTH_PATH)
if PROVIDER not in data:
return
del data[PROVIDER]
if data:
subscription_store.write(AUTH_PATH, data)
return
with contextlib.suppress(OSError):
AUTH_PATH.unlink()
@contextlib.contextmanager
def _refresh_guard() -> Iterator[None]:
"""Serialize token refresh within (lock) and across (flock) Strix processes,
so concurrent runs can't both spend the single-use refresh token."""
with subscription_store.guard(AUTH_PATH):
yield
class GrokAuthError(Exception):
def __init__(self, code: str, message: str | None = None) -> None:
self.code = code
super().__init__(message or code)
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def generate_pkce() -> tuple[str, str]:
verifier = _b64url(secrets.token_bytes(64))
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def create_state() -> str:
return secrets.token_hex(16)
def build_authorize_url(challenge: str, state: str) -> str:
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
}
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
def parse_redirect_input(value: str) -> tuple[str | None, str | None]:
"""Extract ``(code, state)`` from a pasted redirect URL, ``code#state``,
query string, or bare code."""
value = (value or "").strip()
if not value:
return None, None
with contextlib.suppress(ValueError):
parsed = urllib.parse.urlparse(value)
if parsed.scheme and parsed.query:
query = urllib.parse.parse_qs(parsed.query)
return _first(query, "code"), _first(query, "state")
if "#" in value:
code, _, state = value.partition("#")
return code or None, state or None
if "code=" in value:
query = urllib.parse.parse_qs(value)
return _first(query, "code"), _first(query, "state")
return value, None
def _first(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
detail = ""
try:
with requests.post(
TOKEN_URL,
data=payload,
headers={"Accept": "application/json"},
timeout=_TOKEN_TIMEOUT,
) as response:
status_code = response.status_code
body = response.content
if status_code >= 400:
detail = response.text[:300]
except requests.RequestException as exc:
raise GrokAuthError("unavailable", str(exc)) from exc
if status_code >= 400:
raise GrokAuthError("token_http_error", f"HTTP {status_code}: {detail}")
data = json.loads(body or b"{}")
if not isinstance(data, dict):
raise GrokAuthError("bad_response", "token endpoint returned non-object")
return data
def _record_from_token_response(
data: dict[str, Any], refresh_fallback: str | None = None
) -> dict[str, Any]:
access = data.get("access_token")
# A refresh response may omit refresh_token when it isn't rotated; keep the old one.
refresh = data.get("refresh_token") or refresh_fallback
expires_in = data.get("expires_in")
if not isinstance(access, str) or not access:
raise GrokAuthError("bad_response", "token response missing access_token")
if not isinstance(refresh, str) or not refresh:
raise GrokAuthError("bad_response", "token response missing refresh_token")
ttl = expires_in if isinstance(expires_in, int | float) else 3600
return {
"type": "oauth",
"provider": PROVIDER,
"access": access,
"refresh": refresh,
"expires_at": time.time() + ttl,
}
def exchange_code(code: str, verifier: str) -> dict[str, Any]:
data = _post_form(
{
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"code_verifier": verifier,
"redirect_uri": REDIRECT_URI,
}
)
return _record_from_token_response(data)
def refresh_tokens(refresh_token: str) -> dict[str, Any]:
data = _post_form(
{
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
}
)
return _record_from_token_response(data, refresh_fallback=refresh_token)
def _access_token(record: dict[str, Any]) -> str:
access = record["access"]
if not isinstance(access, str) or not access:
raise GrokAuthError("bad_response", "stored access token is missing or malformed")
return access
def _near_expiry(record: dict[str, Any]) -> bool:
expires_at = record.get("expires_at")
if not isinstance(expires_at, int | float):
return True
return expires_at - _EXPIRY_SKEW_S <= time.time()
def get_valid_token() -> str:
"""Return a valid access token, refreshing under the cross-process guard if
near expiry."""
record = read_record()
if record is None:
raise GrokAuthError("not_authenticated", "not signed in; run: strix auth login grok")
if not _near_expiry(record):
return _access_token(record)
with _refresh_guard():
record = read_record()
if record is None:
raise GrokAuthError("not_authenticated", "not signed in; run: strix auth login grok")
if not _near_expiry(record):
return _access_token(record)
try:
refreshed = refresh_tokens(record["refresh"])
except GrokAuthError:
# A peer process may have already spent this single-use refresh token.
latest = read_record()
if latest and latest["refresh"] != record["refresh"] and not _near_expiry(latest):
return _access_token(latest)
raise
save_record(refreshed)
return _access_token(refreshed)
def build_openai_client() -> AsyncOpenAI:
"""An ``AsyncOpenAI`` for xAI's API. A per-request hook re-stamps a fresh
bearer token so long scans survive token expiry."""
import asyncio
import httpx
from openai import AsyncOpenAI
get_valid_token() # fail fast at configure time if the sign-in is dead
async def _auth_hook(request: httpx.Request) -> None:
access = await asyncio.to_thread(get_valid_token)
request.headers["Authorization"] = f"Bearer {access}"
http_client = httpx.AsyncClient(
timeout=httpx.Timeout(600.0, connect=30.0),
event_hooks={"request": [_auth_hook]},
)
return AsyncOpenAI(
api_key="strix-grok-oauth", # placeholder; the hook overwrites Authorization
base_url=XAI_BASE_URL,
http_client=http_client,
)
_subscription_client: AsyncOpenAI | None = None
def get_subscription_client() -> AsyncOpenAI:
global _subscription_client # noqa: PLW0603
if _subscription_client is None:
_subscription_client = build_openai_client()
return _subscription_client
SUBSCRIPTION_PREFIX = "grok/"
def subscription_model(model_name: str | None) -> str | None:
"""The model slug behind a ``grok/<model>`` STRIX_LLM, or None."""
name = (model_name or "").strip()
if not name.lower().startswith(SUBSCRIPTION_PREFIX):
return None
return name[len(SUBSCRIPTION_PREFIX) :] or None
def auth_mode(model_name: str | None) -> str:
return "subscription" if subscription_model(model_name) else "api_key"
+16 -7
View File
@@ -20,6 +20,7 @@ from agents.model_settings import ModelSettings
from agents.models.fake_id import FAKE_RESPONSES_ID
from agents.models.interface import Model
from agents.models.multi_provider import MultiProvider
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.models.openai_responses import OpenAIResponsesModel
from agents.retry import (
ModelRetryBackoffSettings,
@@ -36,7 +37,7 @@ from openai.types.responses import (
from openai.types.responses.response_usage import ResponseUsage
from openai.types.shared import Reasoning
from strix.config import codex
from strix.config import codex, grok
from strix.config.loader import load_settings
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
from strix.config.tool_call_limits import TurnToolCallLimiter
@@ -481,6 +482,10 @@ class StrixProvider(MultiProvider):
codex.get_subscription_client(),
reasoning_effort=llm.reasoning_effort,
)
elif grok_slug := grok.subscription_model(model_name):
# xAI's API is OpenAI chat-completions compatible; the subscription
# bearer is stamped per-request by the client's auth hook.
model = OpenAIChatCompletionsModel(grok_slug, grok.get_subscription_client())
else:
model = super().get_model(model_name)
if llm.disable_streaming:
@@ -556,7 +561,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
"""Apply Strix config to SDK-native defaults."""
llm = settings.llm
set_tracing_disabled(True)
if codex.subscription_model(llm.model):
if codex.subscription_model(llm.model) or grok.subscription_model(llm.model):
return
_configure_litellm_compatibility()
_configure_openrouter_attribution(llm.model)
@@ -652,27 +657,31 @@ def _install_openrouter_stream_cost_capture() -> None:
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
_OPENROUTER_ATTRIBUTION_HEADERS = {
OPENROUTER_ATTRIBUTION_HEADERS = {
"HTTP-Referer": "https://strix.ai",
"X-Title": "Strix",
"X-OpenRouter-Categories": "cli-agent",
}
def is_openrouter_model(model_name: str | None) -> bool:
return bool(model_name) and "openrouter/" in (model_name or "").strip().lower()
def _configure_openrouter_attribution(model_name: str | None) -> None:
import litellm
current: object = litellm.headers
existing: dict[str, str] = current if isinstance(current, dict) else {}
if not model_name or "openrouter/" not in model_name.strip().lower():
if any(key in existing for key in _OPENROUTER_ATTRIBUTION_HEADERS):
if not is_openrouter_model(model_name):
if any(key in existing for key in OPENROUTER_ATTRIBUTION_HEADERS):
remaining = {
k: v for k, v in existing.items() if k not in _OPENROUTER_ATTRIBUTION_HEADERS
k: v for k, v in existing.items() if k not in OPENROUTER_ATTRIBUTION_HEADERS
}
litellm.headers = remaining or None # type: ignore[assignment]
return
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
litellm.headers = {**existing, **OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
def _configure_extra_headers(llm: LlmSettings) -> None:
+1 -1
View File
@@ -106,7 +106,7 @@ class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG
image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.2.0",
default="ghcr.io/usestrix/strix-sandbox:1.3.0",
alias="STRIX_IMAGE",
)
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
+63
View File
@@ -0,0 +1,63 @@
"""Shared helpers across model-subscription providers (ChatGPT/Codex and Grok).
Each provider module (:mod:`strix.config.codex`, :mod:`strix.config.grok`)
exposes the same small surface — ``subscription_model``, ``auth_mode``,
``is_authenticated`` — so callers that only care "is this run on a subscription,
and which provider?" can stay provider-agnostic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from strix.config import codex, grok
if TYPE_CHECKING:
from types import ModuleType
_PROVIDERS: tuple[ModuleType, ...] = (codex, grok)
# Human-facing provider names keyed by each module's ``PROVIDER`` constant.
_DISPLAY_NAMES: dict[str, str] = {codex.PROVIDER: "ChatGPT", grok.PROVIDER: "Grok"}
# Prefix LiteLLM keys each provider's model metadata under: ChatGPT models are
# mapped bare ("gpt-5.4"), xAI's only provider-qualified ("xai/grok-4").
_LITELLM_PREFIXES: dict[str, str] = {codex.PROVIDER: "", grok.PROVIDER: "xai/"}
def provider_for_model(model_name: str | None) -> ModuleType | None:
"""Return the subscription provider module that owns ``model_name``'s prefix,
or None when the model isn't a subscription model."""
for provider in _PROVIDERS:
if provider.subscription_model(model_name):
return provider
return None
def auth_mode(model_name: str | None) -> str:
return "subscription" if provider_for_model(model_name) is not None else "api_key"
def provider_label(model_name: str | None) -> str | None:
"""Human-facing name of the subscription provider for ``model_name`` (e.g.
"ChatGPT" or "Grok"), or None when the model isn't a subscription model."""
provider = provider_for_model(model_name)
if provider is None:
return None
return _DISPLAY_NAMES.get(provider.PROVIDER)
def litellm_model_name(model_name: str | None) -> str | None:
"""``model_name`` rewritten to the name LiteLLM maps metadata under.
Subscription prefixes are Strix routing labels LiteLLM never maps, so a
lookup of "grok/grok-4" (or bare "grok-4") finds nothing. Non-subscription
models are returned unchanged.
"""
provider = provider_for_model(model_name)
if provider is None:
return model_name
prefix = _LITELLM_PREFIXES.get(provider.PROVIDER, "")
return f"{prefix}{provider.subscription_model(model_name)}"
+152
View File
@@ -0,0 +1,152 @@
"""Shared on-disk store for subscription OAuth credentials.
Every subscription provider (ChatGPT/Codex, Grok) keeps its record under its own
key in a single ``~/.strix/subscription-auth.json`` file. Reads and writes go
through here so that:
* tokens are written owner-only (mode 0600) from the moment the file is created,
never briefly exposed with umask-derived permissions, and
* concurrent read-modify-write mutations — even across different providers or
processes — are serialized, so one provider's update can't clobber another's.
The lock is reentrant, so a provider may nest a ``save`` inside a longer
``guard`` (e.g. refreshing a token then persisting it) without deadlocking.
"""
from __future__ import annotations
import contextlib
import json
import os
import tempfile
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
from io import TextIOWrapper
class StoreLockError(RuntimeError):
"""The cross-process store lock could not be acquired.
Raised instead of silently proceeding, so a read-modify-write never runs
unlocked (which would let concurrent provider logins/refreshes/logouts race).
"""
def read(path: Path) -> dict[str, Any]:
"""The store's contents, or an empty dict when absent/unreadable."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def write(path: Path, data: dict[str, Any]) -> None:
"""Atomically replace the store, owner-only from creation.
The temp file is created with a random name via ``mkstemp`` (mode 0600, no
symlink following), so a local attacker can't pre-plant a symlink at a
predictable path to divert the token write.
"""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
tmp.replace(path)
except BaseException:
with contextlib.suppress(OSError):
tmp.unlink()
raise
with contextlib.suppress(OSError):
path.chmod(0o600)
class _StoreLock:
"""A reentrant lock serializing store mutations within (thread lock) and
across (flock) Strix processes. Nesting reuses the single held file lock, so
a provider can persist a record inside a longer critical section."""
def __init__(self) -> None:
self._thread_lock = threading.RLock()
self._flock_handle: TextIOWrapper | None = None
self._depth = 0
@contextlib.contextmanager
def hold(self, path: Path) -> Iterator[None]:
with self._thread_lock:
if self._depth == 0:
self._flock_handle = _acquire_flock(path)
self._depth += 1
try:
yield
finally:
self._depth -= 1
if self._depth == 0:
self._release_flock()
def _release_flock(self) -> None:
handle = self._flock_handle
self._flock_handle = None
if handle is None:
return
try:
import fcntl
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except ImportError:
pass
finally:
handle.close()
_store_lock = _StoreLock()
def guard(path: Path) -> contextlib.AbstractContextManager[None]:
"""Serialize store mutation across threads and processes (reentrant)."""
return _store_lock.hold(path)
def _acquire_flock(path: Path) -> TextIOWrapper:
"""Hold an exclusive cross-process lock on the store, or raise.
Never returns without the lock held: a missing ``fcntl`` or a failed
``flock`` raises :class:`StoreLockError` so the caller aborts rather than
mutating the store unlocked.
"""
try:
import fcntl
except ImportError as exc: # pragma: no cover - non-POSIX
msg = "cross-process credential locking requires fcntl (a POSIX platform)"
raise StoreLockError(msg) from exc
lock_path = path.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
# O_NOFOLLOW rejects a pre-positioned symlink at the predictable lock path
# (so an attacker can't redirect the open), and no O_TRUNC since the lock
# file is only an flock anchor whose contents we never use.
try:
fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
except OSError as exc:
msg = f"could not open lock file {lock_path}: {exc}"
raise StoreLockError(msg) from exc
handle = os.fdopen(fd, "r+")
try:
while True:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
break
except InterruptedError: # EINTR — retry the blocking acquire
continue
except OSError as exc:
handle.close()
msg = f"could not lock {lock_path}: {exc}"
raise StoreLockError(msg) from exc
return handle
+2 -2
View File
@@ -830,7 +830,7 @@ async def _append_tool_required_message(
"execution and never hands control to the user: it is shown to the user, and the "
"run continues. Continue immediately and call exactly one tool. "
"If you have something to tell the user and nothing to do until they reply, "
"call respond_to_user. "
"call respond_to_user — with no message if you have already said it. "
"If you are blocked waiting for another agent, call wait_for_agents. "
f"If the whole engagement is complete, call {finish_tool}. "
"Otherwise use the appropriate execution or planning tool. "
@@ -838,7 +838,7 @@ async def _append_tool_required_message(
)
else:
message = (
"Your previous response ended the autonomous Strix run without a lifecycle tool "
"Your previous response ended the autonomous run without a lifecycle tool "
"call. That is invalid in non-interactive mode; plain text final answers are "
"ignored. Continue immediately and call exactly one tool. "
f"If your work is complete, call {finish_tool}. "
+3
View File
@@ -20,6 +20,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
LLM_TURN_KEY = "llm_turn"
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
@@ -144,6 +146,7 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
system_prompt: str | None, # noqa: ARG002
input_items: list[TResponseInputItem],
) -> None:
context.context[LLM_TURN_KEY] = int(context.context.get(LLM_TURN_KEY, 0)) + 1
try:
self._maybe_warn_turns(context, input_items)
self._maybe_warn_budget(context, input_items)
+26 -2
View File
@@ -10,10 +10,12 @@ from openai.types.shared import Reasoning
from strix.config.models import (
DEFAULT_MODEL_RETRY,
OPENROUTER_ATTRIBUTION_HEADERS,
bedrock_route_supports_prompt_caching,
is_bedrock_route,
is_claude_model,
is_known_openai_bare_model,
is_openrouter_model,
model_supports_reasoning,
request_timeout_extra_args,
)
@@ -138,6 +140,15 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
"target to assess: the instructions below are the only source of "
"truth for what to do."
)
elif not parts and user_instructions:
# Neither a target nor a directory, but there is an instruction: the user
# declined the mount, so the instruction is all there is. Say so, or the
# agent goes looking for a scope that was never given.
parts.append(
"\n\nNo scan target and no working directory were provided. The "
"instructions below are the only source of truth for what to do; "
"work from them and from what you can reach yourself."
)
parts.extend(_render_diff_scope(diff_scope))
@@ -192,13 +203,15 @@ def make_model_settings(
request_timeout: float | None = None,
prompt_cache: bool = True,
extra_headers: dict[str, str] | None = None,
has_tools: bool = True,
) -> ModelSettings:
headers = _request_headers(model_name, extra_headers)
model_settings = ModelSettings(
parallel_tool_calls=False,
parallel_tool_calls=False if has_tools else None,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
extra_headers=dict(extra_headers) if extra_headers else None,
extra_headers=headers,
)
if (
reasoning_effort is not None
@@ -221,6 +234,17 @@ def make_model_settings(
return model_settings
def _request_headers(
model_name: str, extra_headers: dict[str, str] | None
) -> dict[str, str] | None:
headers: dict[str, str] = {}
if is_openrouter_model(model_name):
headers.update(OPENROUTER_ATTRIBUTION_HEADERS)
if extra_headers:
headers.update(extra_headers)
return headers or None
def _reasoning_settings(
effort: ReasoningEffort,
extra_args: dict[str, Any] | None,
+14 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import contextlib
import io
import json
@@ -293,7 +294,7 @@ async def run_strix_scan(
)
root_agent = build_strix_agent(
name="Strix",
name="Root Agent",
skills=skills,
is_root=True,
scan_mode=scan_mode,
@@ -307,7 +308,7 @@ async def run_strix_scan(
if not is_resume:
await coordinator.register(
root_id,
"Strix",
"Root Agent",
parent_id=None,
task=root_task,
skills=skills,
@@ -429,7 +430,6 @@ async def run_strix_scan(
except BudgetExceededError as exc:
logger.info("Scan %s stopped: %s", scan_id, exc)
if root_id is not None:
await coordinator.cancel_descendants(root_id)
with contextlib.suppress(Exception):
await coordinator.set_status(root_id, "stopped")
return None
@@ -442,19 +442,28 @@ async def run_strix_scan(
scan_id,
)
if root_id is not None:
await coordinator.cancel_descendants(root_id)
with contextlib.suppress(Exception):
await coordinator.set_status(root_id, "stopped")
return None
except (asyncio.CancelledError, KeyboardInterrupt):
logger.info("Scan %s interrupted by the user", scan_id)
if root_id is not None:
with contextlib.suppress(Exception):
await coordinator.set_status(root_id, "running")
raise
except BaseException:
logger.exception("Strix scan %s failed", scan_id)
if root_id is not None:
await coordinator.cancel_descendants(root_id)
with contextlib.suppress(Exception):
await coordinator.set_status(root_id, "failed")
raise
finally:
configure_spill_writer(None)
# Settle descendants before closing sessions: on a clean finish a child
# can still be mid-turn, and closing its session underneath it crashes it.
if root_id is not None:
with contextlib.suppress(Exception):
await coordinator.cancel_descendants(root_id)
for s in sessions_to_close:
with contextlib.suppress(Exception):
s.close()
+20 -2
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import asyncio
import logging
import sqlite3
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, cast
from weakref import WeakKeyDictionary
@@ -12,7 +14,7 @@ from agents.memory import SQLiteSession
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Iterator
from pathlib import Path
from agents.items import TResponseInputItem
@@ -22,9 +24,25 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class _PooledConnectionSession(SQLiteSession):
@contextmanager
def _locked_connection(self) -> Iterator[sqlite3.Connection]:
with self._lock:
if self._closed:
raise RuntimeError("SQLiteSession is closed")
if self._is_memory_db:
yield self._shared_connection
return
connection = sqlite3.connect(str(self.db_path), check_same_thread=False)
try:
yield connection
finally:
connection.close()
def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
path.parent.mkdir(parents=True, exist_ok=True)
return SQLiteSession(session_id=agent_id, db_path=path)
return _PooledConnectionSession(session_id=agent_id, db_path=path)
async def seed_initial_input(session: Session, initial_input: Any) -> bool:
+166 -67
View File
@@ -1,8 +1,8 @@
"""`strix auth` — ChatGPT subscription sign-in (login / status / logout).
"""`strix auth` — model-subscription sign-in (login / status / logout).
Signing in only stores OAuth tokens (``~/.strix/subscription-auth.json``); model
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on the
subscription.
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on a
ChatGPT subscription and a ``grok/<model>`` one on a Grok/SuperGrok subscription.
"""
from __future__ import annotations
@@ -12,6 +12,7 @@ import base64
import logging
import threading
import webbrowser
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -21,24 +22,76 @@ from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from strix.config import codex, load_settings
from strix.config import codex, grok, load_settings, subscription_store
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
logger = logging.getLogger(__name__)
_CALLBACK_TIMEOUT_S = 300
# CLI-facing name for the login provider. Internally this is the Codex OAuth
# flow (``codex.PROVIDER``), but users know it as ChatGPT, so that's what the
# command and messaging say. ``codex`` is accepted as an alias.
LOGIN_PROVIDER = "chatgpt"
_ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER})
_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout"
@dataclass(frozen=True)
class _Provider:
"""A model-subscription provider the ``strix auth`` command can sign into.
``module`` is the provider's OAuth module (:mod:`strix.config.codex` or
:mod:`strix.config.grok`); both expose the same login surface. ``error`` is
that module's auth-error class, caught to report a clean failure.
"""
name: str
module: ModuleType
error: type[Exception]
display: str
example_model: str
blurb: str
_PROVIDERS: dict[str, _Provider] = {
"chatgpt": _Provider(
name="chatgpt",
module=codex,
error=codex.CodexAuthError,
display="ChatGPT",
example_model="chatgpt/gpt-5.4",
blurb="This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.",
),
"grok": _Provider(
name="grok",
module=grok,
error=grok.GrokAuthError,
display="Grok",
example_model="grok/grok-4",
blurb="This uses your Grok/SuperGrok plan for inference instead of a metered API key.",
),
}
# Internal OAuth provider ids and common vendor names accepted as aliases.
_PROVIDER_ALIASES: dict[str, str] = {
codex.PROVIDER: "chatgpt",
grok.PROVIDER: "grok",
"xai": "grok",
"supergrok": "grok",
}
_DEFAULT_PROVIDER = "chatgpt"
_USAGE = (
"Usage:\n"
" strix auth login [chatgpt|grok] [--manual]\n"
" strix auth status\n"
" strix auth logout [chatgpt|grok]"
)
def _resolve_provider(name: str) -> _Provider | None:
key = _PROVIDER_ALIASES.get(name.lower(), name.lower())
return _PROVIDERS.get(key)
def run_auth(argv: list[str]) -> int:
@@ -49,20 +102,20 @@ def run_auth(argv: list[str]) -> int:
rest = argv[1:]
if subcommand in ("-h", "--help", "help"):
console.print(_USAGE)
console.print(_USAGE, markup=False)
return 0
handlers: dict[str, Callable[[], int]] = {
"login": lambda: _login(console, rest),
"status": lambda: _status(console),
"logout": lambda: _logout(console),
"logout": lambda: _logout(console, rest),
}
handler = handlers.get(subcommand)
if handler is not None:
return handler()
console.print(f"[red]Unknown auth command:[/] {subcommand}\n")
console.print(_USAGE)
console.print(_USAGE, markup=False)
return 2
@@ -71,8 +124,8 @@ def _login(console: Console, argv: list[str]) -> int:
parser.add_argument(
"provider",
nargs="?",
default=LOGIN_PROVIDER,
help="Model provider to sign in with (default: chatgpt).",
default=_DEFAULT_PROVIDER,
help="Model provider to sign in with (chatgpt or grok; default: chatgpt).",
)
parser.add_argument(
"--manual",
@@ -84,39 +137,42 @@ def _login(console: Console, argv: list[str]) -> int:
except SystemExit as exc: # argparse already printed the message
return int(exc.code or 2)
if args.provider.lower() not in _ACCEPTED_PROVIDERS:
console.print(
f"[red]Unsupported provider:[/] {args.provider}. "
f"Only '{LOGIN_PROVIDER}' (ChatGPT subscription) is supported."
)
provider = _resolve_provider(args.provider)
if provider is None:
supported = ", ".join(f"'{name}'" for name in _PROVIDERS)
console.print(f"[red]Unsupported provider:[/] {args.provider}. Supported: {supported}.")
return 2
verifier, challenge = codex.generate_pkce()
state = codex.create_state()
authorize_url = codex.build_authorize_url(challenge, state)
module = provider.module
verifier, challenge = module.generate_pkce()
state = module.create_state()
authorize_url = module.build_authorize_url(challenge, state)
console.print()
console.print("[bold]Signing in with ChatGPT[/] [dim](provider: chatgpt)[/]")
console.print(
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
f"[bold]Signing in with {provider.display}[/] [dim](provider: {provider.name})[/]"
)
console.print(f"[dim]{provider.blurb}[/]")
console.print()
try:
record = _run_oauth_flow(console, authorize_url, verifier, state, manual=args.manual)
except codex.CodexAuthError as exc:
record = _run_oauth_flow(
console, provider, authorize_url, verifier, state, manual=args.manual
)
except provider.error as exc:
return _fail(console, exc)
except KeyboardInterrupt:
console.print("\n[yellow]Sign-in cancelled.[/]")
return 130
codex.save_record(record)
_print_success(console)
module.save_record(record)
_print_success(console, provider)
return 0
def _run_oauth_flow(
console: Console,
provider: _Provider,
authorize_url: str,
verifier: str,
state: str,
@@ -124,7 +180,10 @@ def _run_oauth_flow(
manual: bool,
) -> dict[str, Any]:
"""Drive the browser (or manual) OAuth flow and return a token record."""
server = None if manual else _try_start_callback_server()
module = provider.module
server = (
None if manual else _try_start_callback_server(module.CALLBACK_PORT, module.CALLBACK_PATH)
)
console.print("Open this URL in your browser to authorize:")
console.print(f"[cyan]{authorize_url}[/]")
@@ -142,8 +201,8 @@ def _run_oauth_flow(
if result is not None:
code, returned_state, error = result
if error:
raise codex.CodexAuthError("oauth_error", error)
return _finish(code, returned_state, verifier, state, require_state=True)
raise provider.error("oauth_error", error)
return _finish(provider, code, returned_state, verifier, state, require_state=True)
console.print("[yellow]Timed out waiting for the browser. Falling back to manual paste.[/]")
# Manual fallback: the user completes sign-in and pastes the redirect URL
@@ -153,12 +212,13 @@ def _run_oauth_flow(
try:
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
except EOFError as exc:
raise codex.CodexAuthError("no_input", "no redirect URL provided") from exc
code, returned_state = codex.parse_redirect_input(pasted)
return _finish(code, returned_state, verifier, state, require_state=False)
raise provider.error("no_input", "no redirect URL provided") from exc
code, returned_state = module.parse_redirect_input(pasted)
return _finish(provider, code, returned_state, verifier, state, require_state=False)
def _finish(
provider: _Provider,
code: str | None,
returned_state: str | None,
verifier: str,
@@ -167,16 +227,17 @@ def _finish(
require_state: bool,
) -> dict[str, Any]:
if not code:
raise codex.CodexAuthError("no_code", "no authorization code found in the redirect")
# The loopback callback from OpenAI always carries state, so a missing or
# mismatched value there is forged (CSRF) and must be rejected. Manual paste
# is user-initiated (the user copies their own redirect), so state is only
# validated when the pasted value includes it.
raise provider.error("no_code", "no authorization code found in the redirect")
# The loopback callback from the provider always carries state, so a missing
# or mismatched value there is forged (CSRF) and must be rejected. Manual
# paste is user-initiated (the user copies their own redirect), so state is
# only validated when the pasted value includes it.
if require_state and returned_state is None:
raise codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
raise provider.error("state_mismatch", "missing state in callback; possible CSRF")
if returned_state is not None and returned_state != expected_state:
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
return codex.exchange_code(code, verifier)
raise provider.error("state_mismatch", "state did not match; possible CSRF")
record: dict[str, Any] = provider.module.exchange_code(code, verifier)
return record
class _CallbackServer:
@@ -203,7 +264,7 @@ class _CallbackServer:
self._httpd.server_close()
def _try_start_callback_server() -> _CallbackServer | None:
def _try_start_callback_server(port: int, path: str) -> _CallbackServer | None:
event = threading.Event()
holder: dict[str, Any] = {}
@@ -213,7 +274,7 @@ def _try_start_callback_server() -> _CallbackServer | None:
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path != codex.CALLBACK_PATH:
if parsed.path != path:
self.send_response(404)
self.end_headers()
return
@@ -230,9 +291,9 @@ def _try_start_callback_server() -> _CallbackServer | None:
event.set()
try:
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
httpd = HTTPServer(("127.0.0.1", port), Handler)
except OSError:
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
logger.debug("could not bind callback port %d", port, exc_info=True)
return None
return _CallbackServer(httpd, event, holder)
@@ -243,30 +304,67 @@ def _first(query: dict[str, list[str]], key: str) -> str | None:
def _status(console: Console) -> int:
record = codex.read_record()
if record is None:
console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] to sign in.")
return 1
settings = load_settings()
console.print("[green]Signed in[/] with a ChatGPT subscription.")
console.print(f" Account: [bold]{record.get('account_id')}[/]")
if codex.subscription_model(settings.llm.model):
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{settings.llm.model}[/]).")
else:
active_model = settings.llm.model
signed_in_any = False
for provider in _PROVIDERS.values():
record = provider.module.read_record()
if record is None:
continue
signed_in_any = True
console.print(f"[green]Signed in[/] with a {provider.display} subscription.")
account_id = record.get("account_id")
if account_id:
console.print(f" Account: [bold]{account_id}[/]")
if provider.module.subscription_model(active_model):
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{active_model}[/]).")
else:
console.print(
f" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. "
f"[cyan]{provider.example_model}[/] to run on this subscription."
)
if not signed_in_any:
console.print(
" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. [cyan]chatgpt/gpt-5.4[/] "
"to run on the subscription."
"[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] "
"or [cyan]strix auth login grok[/] to sign in."
)
return 1
return 0
def _logout(console: Console) -> int:
codex.logout()
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
def _logout(console: Console, argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="strix auth logout", add_help=True)
parser.add_argument(
"provider",
nargs="?",
default=None,
help="Provider to sign out of (chatgpt or grok; default: all).",
)
try:
args = parser.parse_args(argv)
except SystemExit as exc:
return int(exc.code or 2)
if args.provider is None:
# Hold the store lock across every provider so a concurrent save/refresh
# can't slip a credential back in between removals (logout-all is atomic).
with subscription_store.guard(codex.AUTH_PATH):
for provider in _PROVIDERS.values():
provider.module.logout()
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
return 0
target = _resolve_provider(args.provider)
if target is None:
supported = ", ".join(f"'{name}'" for name in _PROVIDERS)
console.print(f"[red]Unsupported provider:[/] {args.provider}. Supported: {supported}.")
return 2
target.module.logout()
console.print(f"[green]Signed out of {target.display}.[/] Stored credentials removed.")
return 0
def _fail(console: Console, exc: codex.CodexAuthError) -> int:
def _fail(console: Console, exc: Exception) -> int:
error_text = Text()
error_text.append("SIGN-IN FAILED", style="bold red")
error_text.append("\n\n", style="white")
@@ -284,17 +382,18 @@ def _fail(console: Console, exc: codex.CodexAuthError) -> int:
return 1
def _print_success(console: Console) -> None:
def _print_success(console: Console, provider: _Provider) -> None:
prefix = provider.module.SUBSCRIPTION_PREFIX
text = Text()
text.append("Signed in with your ChatGPT subscription", style="bold #22c55e")
text.append(f"Signed in with your {provider.display} subscription", style="bold #22c55e")
text.append("\n\n", style="white")
text.append("Set ", style="white")
text.append("STRIX_LLM", style="bold white")
text.append(" to a ", style="white")
text.append("chatgpt/", style="bold cyan")
text.append(prefix, style="bold cyan")
text.append(" model (e.g. ", style="white")
text.append("chatgpt/gpt-5.4", style="bold cyan")
text.append(") — runs are billed to your ChatGPT plan.", style="white")
text.append(provider.example_model, style="bold cyan")
text.append(f") — runs are billed to your {provider.display} plan.", style="white")
text.append("\n\n", style="white")
text.append("Run a scan as usual, e.g. ", style="white")
text.append("strix --target https://example.com", style="bold cyan")
+4 -3
View File
@@ -328,10 +328,11 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
args.targets_info = state.get("targets_info") or []
# A target-less run has no targets_info at all: it works in a mounted
# directory, driven by its instruction.
# A target-less run has no targets_info at all. It is driven by its
# instruction, over a mounted working directory or over nothing when the
# mount was declined, so either of those is enough to resume it.
workspace_mount = state.get("workspace_mount") or None
if not args.targets_info and not workspace_mount:
if not args.targets_info and not workspace_mount and not state.get("user_instruction"):
parser.error(f"--resume {args.resume}: run.json has no targets_info")
for target in args.targets_info:
+11 -1
View File
@@ -8,7 +8,7 @@ from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from strix.config import codex, load_settings
from strix.config import codex, grok, load_settings
from strix.interface.utils import (
check_docker_connection,
image_exists,
@@ -37,6 +37,16 @@ def validate_environment() -> None:
logger.info("Environment OK (ChatGPT subscription)")
return
if grok.subscription_model(settings.llm.model):
if not grok.is_authenticated():
console.print(
f"[red]STRIX_LLM={settings.llm.model} uses your Grok subscription, "
"but you're not signed in.[/] Run [cyan]strix auth login grok[/] first."
)
sys.exit(1)
logger.info("Environment OK (Grok subscription)")
return
if not settings.llm.model:
missing_required_vars.append("STRIX_LLM")
+11 -1
View File
@@ -224,6 +224,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=settings.dedupe.extra_headers,
has_tools=False,
)
if deduper_extra:
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
@@ -435,7 +436,16 @@ def main() -> None:
start_background_check()
if not args.non_interactive and prompt_update_if_available(Console()):
if is_binary_install() and sys.platform != "win32":
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
# The PyInstaller onefile bootloader passes its state to the child
# process via environment variables; if they leak into the re-exec,
# the new binary reuses the old extracted application instead of
# unpacking itself, so the pre-update version runs again.
env = {
key: value
for key, value in os.environ.items()
if not key.startswith("_PYI_") and key != "_MEIPASS2"
}
os.execve(sys.executable, sys.argv, env) # noqa: S606 # nosec B606
sys.exit(0)
check_docker_installed()
+6 -3
View File
@@ -14,7 +14,7 @@ import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from strix.config import Settings, codex, load_settings
from strix.config import Settings, load_settings, subscription
from strix.core.paths import run_dir_for
from strix.interface.utils import (
assign_workspace_subdirs,
@@ -78,6 +78,7 @@ async def preflight_model_connection(
request_timeout=resolved_settings.llm.timeout,
prompt_cache=False,
extra_headers=resolved_settings.llm.extra_headers,
has_tools=False,
)
await asyncio.wait_for(
model.get_response(
@@ -225,7 +226,7 @@ def telemetry_start(args: argparse.Namespace) -> None:
model = load_settings().llm.model
kwargs = {
"model": model,
"auth_mode": codex.auth_mode(model),
"auth_mode": subscription.auth_mode(model),
"scan_mode": args.scan_mode,
"is_whitebox": is_whitebox_scan(args.targets_info),
"interactive": not args.non_interactive,
@@ -240,13 +241,15 @@ def _persist_run_record(args: argparse.Namespace) -> None:
run_dir = run_dir_for(args.run_name)
run_dir.mkdir(parents=True, exist_ok=True)
model = load_settings().llm.model
run_record = {
"run_id": args.run_name,
"run_name": args.run_name,
"status": "running",
"start_time": datetime.now(UTC).isoformat(),
"end_time": None,
"auth_mode": codex.auth_mode(load_settings().llm.model),
"auth_mode": subscription.auth_mode(model),
"subscription_provider": subscription.provider_label(model),
"targets_info": args.targets_info,
"scan_mode": args.scan_mode,
"instruction": args.instruction,
+10 -15
View File
@@ -24,7 +24,7 @@ from strix.interface.tui.backend.projection import (
sanitize_terminal_text,
terminal_projection,
)
from strix.interface.utils import is_subscription_run
from strix.interface.utils import is_subscription_run, subscription_label
if TYPE_CHECKING:
@@ -138,13 +138,6 @@ class TuiController:
self.error = detail
self.notify_changed()
def enter_setup(self) -> None:
"""Return a session to the start screen, e.g. on a declined mount."""
self.setup_mode = True
self.scan_started = False
self.scan_state = "setup"
self.notify_changed()
def add_message(self, text: str, level: str = "info") -> None:
self._append_message(text, level)
self.notify_changed()
@@ -169,8 +162,11 @@ class TuiController:
if self.report_state is not None:
usage = dict(self.report_state.get_total_llm_usage())
subscription = False
subscription_name = ""
with contextlib.suppress(Exception):
subscription = is_subscription_run(self.report_state)
if subscription:
subscription_name = subscription_label(self.report_state)
model_warning = ""
if model and not is_recommended_or_frontier_model(model):
model_warning = (
@@ -207,6 +203,7 @@ class TuiController:
],
"usage": terminal_projection(usage, max_string=256, max_items=20),
"subscription": subscription,
"subscription_label": terminal_projection(subscription_name, max_string=64),
"viewer_status": self.viewer_status,
"viewer_url": terminal_projection(self.viewer_url, max_string=1024),
"error": terminal_projection(self.error, max_string=2 * 1024),
@@ -356,14 +353,12 @@ class TuiController:
if not isinstance(approved, bool):
raise TypeError("approved must be a boolean")
self.pending_workspace_mount = None
if not approved:
# Nothing was prepared, so return to the start screen untouched.
self.workspace_mount = None
self.enter_setup()
return {"approved": False}
self.workspace_mount = mount
# Declining skips the mount, it does not abandon the scan. The prompt is
# the whole of the input either way; the working directory is only an
# extra the agent may look at, so the run goes ahead without one.
self.workspace_mount = mount if approved else None
await self._begin_scan(self._pending_verify)
return {"approved": True}
return {"approved": approved}
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
agent_id = self._required_string(payload, "agent_id")
@@ -175,6 +175,7 @@ def bounded_state_projection(state: dict[str, Any]) -> dict[str, Any]:
"messages": [],
"usage": {},
"subscription": state["subscription"],
"subscription_label": state["subscription_label"],
"viewer_status": state["viewer_status"],
"viewer_url": None,
"error": terminal_projection(state["error"], max_string=256),
@@ -0,0 +1,299 @@
package app
import (
"encoding/json"
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"github.com/usestrix/strix/tui/internal/protocol"
)
func findingsModel(t *testing.T, titles ...string) Model {
t.Helper()
m := New(nil)
m.width, m.height = 130, 30
m.showSplash = false
m.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
items := make([]json.RawMessage, 0, len(titles))
for i, title := range titles {
items = append(items, rawJSON(t, map[string]any{
"id": string(rune('a' + i)), "title": title, "severity": "high",
}))
}
m.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap",
Payload: rawJSON(t, protocol.CollectionBootstrap{
Collection: "vulnerabilities", Revision: 1, Cursor: 0,
NextCursor: len(items), Done: true, Items: items,
})})
m.resizeViewport()
return m
}
// The list scrolls by row, not by finding. Stepping a whole entry at a time is
// what made a list of wrapped titles feel paginated.
func TestFindingsScrollByRow(t *testing.T) {
long := "A deliberately long finding title that wraps across several rows in the sidebar"
m := findingsModel(t, long, long, long)
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) <= 3 {
t.Fatalf("titles did not wrap, so this proves nothing: %d rows", len(rows))
}
total, offset := m.vulnerabilityScrollRows()
if total != len(rows) || offset != 0 {
t.Fatalf("scroll metrics are not in rows: total=%d offset=%d rows=%d", total, offset, len(rows))
}
// One step of the offset moves one row, and the first visible line follows it.
first := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
m.vulnOffset = 1
second := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
if first == second {
t.Fatalf("advancing one row did not move the list: %q", first)
}
// That row still belongs to the first finding, which an item-stepping list
// would have skipped past entirely.
if got := m.vulnerabilityIndexAtRow(0); got != 0 {
t.Fatalf("one row in, the top line belongs to finding %d, want 0", got)
}
}
// Selecting a finding scrolls the least it can, and never past its own start.
func TestSelectingAFindingBringsItIntoView(t *testing.T) {
long := "A deliberately long finding title that wraps across several rows in the sidebar"
m := findingsModel(t, long, long, long, long)
m.selectedVuln = 3
m.ensureVulnerabilityVisible()
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
height := m.vulnerabilityPageSize()
end := min(len(rows), m.vulnOffset+height)
found := false
for _, row := range rows[m.vulnOffset:end] {
if row.index == 3 {
found = true
break
}
}
if !found {
t.Fatalf("the selected finding is not on screen: offset=%d height=%d", m.vulnOffset, height)
}
if m.vulnOffset > len(rows)-height && len(rows) > height {
t.Fatalf("scrolled past the end: offset=%d rows=%d height=%d", m.vulnOffset, len(rows), height)
}
}
func reportModel(t *testing.T, count int) Model {
t.Helper()
titles := make([]string, 0, count)
for i := range count {
titles = append(titles, fmt.Sprintf("Finding number %d", i+1))
}
m := findingsModel(t, titles...)
m.openModal(modalVulnerability)
return m
}
// The open report can be stepped through the list without closing it.
func TestReportStepsBetweenFindings(t *testing.T) {
m := reportModel(t, 3)
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
m = updated.(Model)
if m.selectedVuln != 1 {
t.Fatalf("right moved to %d, want 1", m.selectedVuln)
}
if m.modal != modalVulnerability {
t.Fatal("stepping closed the report")
}
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
m = updated.(Model)
if m.selectedVuln != 0 {
t.Fatalf("left moved to %d, want 0", m.selectedVuln)
}
}
// The ends do not wrap: rolling from the last report to the first would hide
// that you had reached the end.
func TestReportStepsStopAtTheEnds(t *testing.T) {
m := reportModel(t, 3)
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
m = updated.(Model)
if m.selectedVuln != 0 {
t.Fatalf("left from the first report moved to %d, want 0", m.selectedVuln)
}
m.selectedVuln = 2
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
m = updated.(Model)
if m.selectedVuln != 2 {
t.Fatalf("right from the last report moved to %d, want 2", m.selectedVuln)
}
}
// Each direction is offered only when there is a report that way, and a lone
// finding is offered neither.
func TestReportNavigationHintsFollowAvailability(t *testing.T) {
m := reportModel(t, 3)
for _, testCase := range []struct {
index int
wantPrev, wantNext bool
position string
}{
{index: 0, wantNext: true, position: "1/3"},
{index: 1, wantPrev: true, wantNext: true, position: "2/3"},
{index: 2, wantPrev: true, position: "3/3"},
} {
m.selectedVuln = testCase.index
view := ansi.Strip(m.modalView())
if !strings.Contains(view, testCase.position) {
t.Fatalf("report %d does not show %q", testCase.index, testCase.position)
}
if got := strings.Contains(view, reportPrev); got != testCase.wantPrev {
t.Fatalf("report %d prev hint = %v, want %v", testCase.index, got, testCase.wantPrev)
}
if got := strings.Contains(view, reportNext); got != testCase.wantNext {
t.Fatalf("report %d next hint = %v, want %v", testCase.index, got, testCase.wantNext)
}
}
lone := reportModel(t, 1)
view := ansi.Strip(lone.modalView())
if strings.Contains(view, reportPrev) || strings.Contains(view, reportNext) || strings.Contains(view, "1/1") {
t.Fatalf("a lone finding offered navigation:\n%s", view)
}
}
// A new report opens at its top, and the copy state does not carry over.
func TestSteppingResetsTheReportView(t *testing.T) {
m := reportModel(t, 3)
m.vulnerabilityCopied = true
m.vulnViewport.SetYOffset(3)
m.showVulnerability(1)
if m.vulnViewport.YOffset != 0 {
t.Fatalf("the next report opened scrolled to %d", m.vulnViewport.YOffset)
}
if m.vulnerabilityCopied {
t.Fatal("the copy state carried over to another report")
}
}
// Prev and Next are buttons, not just key hints: they can be clicked.
func TestReportStepButtonsAreClickable(t *testing.T) {
m := reportModel(t, 3)
m.selectedVuln = 1
click := func(label string) Model {
t.Helper()
view := m.modalView()
left, top, _, _ := m.centeredViewBounds(view)
for row, line := range strings.Split(view, "\n") {
plain := ansi.Strip(line)
index := strings.Index(plain, label)
if index < 0 {
continue
}
updated, _ := m.updateModalMouse(tea.MouseMsg{
X: left + ansi.StringWidth(plain[:index]) + 1, Y: top + row,
Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
})
return updated.(Model)
}
t.Fatalf("%q was not rendered", label)
return m
}
if got := click(reportNext).selectedVuln; got != 2 {
t.Fatalf("clicking Next selected %d, want 2", got)
}
if got := click(reportPrev).selectedVuln; got != 0 {
t.Fatalf("clicking Prev selected %d, want 0", got)
}
if got := click(reportNext).modal; got != modalVulnerability {
t.Fatalf("clicking Next closed the report: modal=%v", got)
}
}
// Tab walks the whole row, so the step buttons are reachable from the keyboard
// as well, and Enter presses whichever one is focused.
func TestTabReachesTheStepButtons(t *testing.T) {
m := reportModel(t, 3)
m.selectedVuln = 1
if got := m.focusedReportButton(); got != reportDone {
t.Fatalf("the report opened focused on %q, want %q", got, reportDone)
}
seen := map[string]bool{}
for range len(m.reportButtons()) {
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(Model)
seen[m.focusedReportButton()] = true
}
for _, want := range []string{reportPrev, reportNext, reportCopy, reportDone} {
if !seen[want] {
t.Fatalf("tab never reached %q: %v", want, seen)
}
}
// Enter on a focused step button steps.
m.reportFocus = reportNext
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
if got := updated.(Model).selectedVuln; got != 2 {
t.Fatalf("enter on Next selected %d, want 2", got)
}
}
// Stepping to an end drops that button from the row; focus must not be stranded
// on it.
func TestFocusFallsBackWhenAStepButtonDisappears(t *testing.T) {
m := reportModel(t, 2)
m.selectedVuln = 0
m.reportFocus = reportNext
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(Model)
if m.selectedVuln != 1 {
t.Fatalf("enter on Next selected %d, want 1", m.selectedVuln)
}
// Next is gone at the last report, so the focus cannot still be on it.
if got := m.focusedReportButton(); got == reportNext {
t.Fatalf("focus stayed on a button that is no longer shown: %q", got)
}
if got := m.focusedReportButton(); got != reportDone {
t.Fatalf("focus fell back to %q, want %q", got, reportDone)
}
}
// The list must be laid out at one width. Rendering at one and hit-testing at
// another gives two different row counts for the same title, and then a click
// resolves to the wrong finding and the scrollbar reports the wrong length.
func TestFindingsUseOneWidthForRenderAndInteraction(t *testing.T) {
// This title wraps to one row at 21 columns and two at 20, which is exactly
// the pair of widths the two paths used to disagree on.
m := findingsModel(t, "ffffff dddd a a a a", "eeeee eeeee a a a a", "header dddd a a a a")
width := m.vulnerabilityListWidth()
rows := m.vulnerabilityRows(width)
rendered := strings.Split(ansi.Strip(m.vulnerabilitiesView(width, len(rows))), "\n")
if len(rendered) != len(rows) {
t.Fatalf("rendered %d rows, interaction counts %d", len(rendered), len(rows))
}
for row := range rendered {
if got := m.vulnerabilityIndexAtRow(row); got != rows[row].index {
t.Fatalf("row %d shows finding %d but a click resolves to %d",
row, rows[row].index, got)
}
}
if total, _ := m.vulnerabilityScrollRows(); total != len(rendered) {
t.Fatalf("the scrollbar reports %d rows, %d are rendered", total, len(rendered))
}
}
+10 -5
View File
@@ -110,6 +110,7 @@ type Model struct {
agentOffset int
vulnOffset int
modalChoice int
reportFocus string
ready bool
quitting bool
showSplash bool
@@ -157,12 +158,16 @@ const (
treeCursorBg = lipgloss.Color("#0178d4")
)
// Scrollbar thumbs. Each panel keeps its own, and the track stays blank so a
// scrollable panel does not gain a visible rule down its edge.
// Scrollbar thumbs. The track stays blank so a scrollable panel does not gain a
// visible rule down its edge, and the thumb brightens while it is dragged, which
// is the feedback Textual gave through scrollbar-color-active.
//
// One resting color for every panel, rather than the three the stylesheet named.
// The chat pane's was #1a1a1a on black, which is invisible - the bar could not be
// found, let alone grabbed (#1005).
const (
thumbTrace = lipgloss.Color("#1a1a1a")
thumbAgents = lipgloss.Color("#404040")
thumbFindings = lipgloss.Color("#333333")
thumbResting = lipgloss.Color("#3f3f46")
thumbActive = lipgloss.Color("#9ca3af")
)
// Composer placeholders. The launch screen falls back to the short prompt when
+22 -10
View File
@@ -527,7 +527,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
}
model := newModel()
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
// Tab moves between the buttons; the arrows step between reports.
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyTab})
model = updated.(Model)
updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
@@ -560,8 +561,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
})
model = updated.(Model)
if cmd == nil || model.modalChoice != 0 {
t.Fatalf("mouse Copy was not activated: choice=%d cmd=%v", model.modalChoice, cmd)
if cmd == nil || model.reportFocus != reportCopy {
t.Fatalf("mouse Copy was not activated: focus=%q cmd=%v", model.reportFocus, cmd)
}
cmd()
if len(copied) != 2 {
@@ -820,8 +821,8 @@ func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) {
}
func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbAgents)), "\n")
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbAgents)), "\n")
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbResting)), "\n")
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbResting)), "\n")
// The track is blank, so only the thumb is drawn.
if top[0] != "█" || top[5] != " " {
@@ -830,10 +831,10 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
if bottom[0] != " " || bottom[5] != "█" {
t.Fatalf("bottom scrollbar is incorrect: %#v", bottom)
}
if full := verticalScrollbar(4, 4, 4, 0, thumbAgents); full != "" {
if full := verticalScrollbar(4, 4, 4, 0, thumbResting); full != "" {
t.Fatalf("non-overflowing scrollbar should be hidden: %q", full)
}
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbAgents))
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbResting))
if strings.ContainsAny(withoutBar, "█") {
t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar)
}
@@ -841,7 +842,7 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
// The bar takes exactly one column, so a scrolling panel keeps the rest.
func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) {
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbTrace), "\n")
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbResting), "\n")
for _, row := range rows {
if width := ansi.StringWidth(row); width != 12 {
t.Fatalf("scrolling panel row width = %d, want 12", width)
@@ -1066,11 +1067,12 @@ func TestBudgetPauseShowsOneWarningToastUntilResumed(t *testing.T) {
func TestStatsViewShowsSubscription(t *testing.T) {
model := New(nil)
model.snapshot.Model = "gpt-5"
model.snapshot.Model = "grok/grok-4"
model.snapshot.Subscription = true
model.snapshot.SubscriptionLabel = "Grok subscription"
model.snapshot.Usage = map[string]any{"total_tokens": float64(1200), "cost": 3.5}
stats := ansi.Strip(model.statsView())
if !strings.Contains(stats, "ChatGPT subscription") {
if !strings.Contains(stats, "Grok subscription") {
t.Fatalf("stats missing subscription line: %q", stats)
}
if strings.Contains(stats, "$") {
@@ -1078,6 +1080,16 @@ func TestStatsViewShowsSubscription(t *testing.T) {
}
}
func TestStatsViewSubscriptionFallsBackWithoutLabel(t *testing.T) {
model := New(nil)
model.snapshot.Model = "gpt-5"
model.snapshot.Subscription = true
stats := ansi.Strip(model.statsView())
if !strings.Contains(stats, "Subscription") {
t.Fatalf("stats missing generic subscription line: %q", stats)
}
}
func TestVulnerabilityMarkdownReport(t *testing.T) {
report := vulnerabilityMarkdownReport(map[string]any{
"title": "SQLi in login",
+3 -6
View File
@@ -66,13 +66,10 @@ func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) {
}
// answerMountConfirmation replies to the working-directory mount the backend is
// waiting on. Declining returns to the start screen, so the prompt goes back in
// the composer to be edited or given a target instead.
// waiting on. Either answer starts the scan - declining only means it runs
// without the directory - so the prompt stays with the run rather than coming
// back to the composer.
func (m *Model) answerMountConfirmation(approved bool) tea.Cmd {
if !approved && m.pendingPrompt != "" {
m.input.SetValue(m.pendingPrompt)
m.resizeViewport()
}
m.pendingPrompt = ""
return send(m.client, "setup.confirm_mount", map[string]any{"approved": approved})
}
@@ -247,13 +247,10 @@ func TestMountConfirmationAnswers(t *testing.T) {
if payload.Approved != tc.approved {
t.Fatalf("%s: approved=%v, want %v", tc.name, payload.Approved, tc.approved)
}
// Declining returns to the start screen, so the prompt comes back.
want := ""
if !tc.approved {
want = "find auth bugs in the login flow"
}
if got := model.input.Value(); got != want {
t.Fatalf("%s: composer = %q, want %q", tc.name, got, want)
// Either answer launches, so the prompt stays with the run rather than
// coming back to the composer.
if got := model.input.Value(); got != "" {
t.Fatalf("%s: composer = %q, want it cleared", tc.name, got)
}
if model.pendingPrompt != "" {
t.Fatalf("%s: held prompt was not cleared: %q", tc.name, model.pendingPrompt)
@@ -290,3 +287,101 @@ func TestSetupPromptWithTargetLaunches(t *testing.T) {
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
}
}
// The prompt's buttons are buttons: clicking Cancel has to answer the backend,
// which it could not do while the mouse handler had no case for this modal.
func TestMountPromptButtonsAreClickable(t *testing.T) {
for _, testCase := range []struct {
label string
approved bool
}{
{mountConfirmLabel, true},
{mountCancelLabel, false},
} {
connection := &recordingConn{}
model := New(&Client{conn: connection})
model.width, model.height = 130, 40
model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"}
updated, _ := model.submit("find auth bugs in the login flow")
model = updated.(Model)
connection.Reset()
model.snapshot = protocol.Snapshot{
ScanStarted: true, ScanState: "preparing", PendingMount: "/Users/me/code/api",
}
model.syncMountPrompt()
left, top, panel := model.mountPromptBounds()
clicked := false
for row, line := range strings.Split(panel, "\n") {
plain := ansi.Strip(line)
index := strings.Index(plain, testCase.label)
if index < 0 {
continue
}
updated, cmd := model.updateModalMouse(tea.MouseMsg{
X: left + ansi.StringWidth(plain[:index]) + 1, Y: top + row,
Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
})
model = updated.(Model)
envelopes := drainCommands(t, cmd, connection)
if len(envelopes) != 1 || envelopes[0].Type != "setup.confirm_mount" {
t.Fatalf("clicking %s sent %v", testCase.label, commandTypes(envelopes))
}
var payload struct {
Approved bool `json:"approved"`
}
if err := json.Unmarshal(envelopes[0].Payload, &payload); err != nil {
t.Fatal(err)
}
if payload.Approved != testCase.approved {
t.Fatalf("clicking %s answered approved=%v", testCase.label, payload.Approved)
}
clicked = true
break
}
if !clicked {
t.Fatalf("%s was not found in the prompt", testCase.label)
}
}
}
// Skipping the mount runs the scan without a directory. It must not throw the
// session back to the start screen, and it must not hand the prompt back: the
// run has it.
func TestSkippingTheMountKeepsTheScanRunning(t *testing.T) {
connection := &recordingConn{}
model := New(&Client{conn: connection})
model.width, model.height = 130, 40
model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"}
updated, _ := model.submit("find auth bugs in the login flow")
model = updated.(Model)
model.snapshot = protocol.Snapshot{
ScanStarted: true, ScanState: "preparing", PendingMount: "/Users/me/code/api",
}
model.syncMountPrompt()
if model.modal != modalConfirmMount {
t.Fatal("the prompt did not open")
}
model.modalChoice = 1
updated, _ = model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
// The backend answers by starting the scan with no mount.
model.handleEnvelope(stateEnvelope(t, 2, protocol.Snapshot{
ScanStarted: true, ScanState: "running",
}))
if model.modal != modalNone {
t.Fatalf("the prompt is still open: %v", model.modal)
}
if model.snapshot.SetupMode {
t.Fatal("skipping the mount fell back to the start screen")
}
if got := model.input.Value(); got != "" {
t.Fatalf("the prompt came back to the composer: %q", got)
}
if model.pendingPrompt != "" {
t.Fatalf("the held prompt was not released: %q", model.pendingPrompt)
}
}
+105 -35
View File
@@ -219,7 +219,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
m.focus = focusVulnerabilities
m.input.Blur()
m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3)
totalRows, _ := m.vulnerabilityScrollRows()
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3)
m.keepVulnerabilitySelectionInWindow()
}
return m, nil
@@ -318,24 +319,7 @@ func (m *Model) updateMainScrollbarMouse(
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
return false
}
target := scrollbarNone
switch {
case msg.X == chatWidth-2 && msg.Y >= 1 && msg.Y < chatHeight-1 &&
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
target = scrollbarTrace
case showSidebar && msg.X == m.width-3 && msg.Y >= viewerHeight+2 &&
msg.Y < viewerHeight+agentHeight-2 &&
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
target = scrollbarAgents
case showSidebar && vulnHeight > 0 && msg.X == m.width-3 &&
msg.Y >= viewerHeight+agentHeight+1 &&
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
totalRows, _ := m.vulnerabilityScrollRows()
if totalRows > m.vulnerabilityPageSize() {
target = scrollbarFindings
}
}
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight)
if target == scrollbarNone {
return false
}
@@ -344,6 +328,40 @@ func (m *Model) updateMainScrollbarMouse(
return true
}
// scrollbarGrab is how far either side of the bar still counts as grabbing it. A
// one column target is unreasonable to hit with a mouse, and nothing else lives
// in the column beside it.
const scrollbarGrab = 1
func nearColumn(x, column int) bool {
return x >= column-scrollbarGrab && x <= column+scrollbarGrab
}
// scrollbarAt reports which scrollbar, if any, the pointer is over.
func (m Model) scrollbarAt(
msg tea.MouseMsg,
showSidebar bool,
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int,
) scrollbarTarget {
switch {
case nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 &&
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
return scrollbarTrace
case showSidebar && nearColumn(msg.X, m.width-3) && msg.Y >= viewerHeight+2 &&
msg.Y < viewerHeight+agentHeight-2 &&
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
return scrollbarAgents
case showSidebar && vulnHeight > 0 && nearColumn(msg.X, m.width-3) &&
msg.Y >= viewerHeight+agentHeight+1 &&
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
totalRows, _ := m.vulnerabilityScrollRows()
if totalRows > m.vulnerabilityPageSize() {
return scrollbarFindings
}
}
return scrollbarNone
}
func (m *Model) scrollFromMouse(
target scrollbarTarget,
y, chatHeight, viewerHeight, agentHeight int,
@@ -367,10 +385,10 @@ func (m *Model) scrollFromMouse(
case scrollbarFindings:
height := m.vulnerabilityPageSize()
totalRows, _ := m.vulnerabilityScrollRows()
rowOffset := scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
m.focus = focusVulnerabilities
m.input.Blur()
m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset)
// The offset is a row, so dragging moves the list continuously.
m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
m.keepVulnerabilitySelectionInWindow()
}
}
@@ -405,6 +423,22 @@ func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// pressReportButton performs a button of the report row, however it was reached.
func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) {
switch button {
case reportPrev:
m.showVulnerability(m.selectedVuln - 1)
case reportNext:
m.showVulnerability(m.selectedVuln + 1)
case reportCopy:
m.reportFocus = reportCopy
return m, m.startVulnerabilityCopy()
default:
m.closeModal()
}
return m, nil
}
func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modal == modalVulnerability {
view := m.modalView()
@@ -440,14 +474,35 @@ func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
m.modalChoice = 1
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
}
case modalVulnerability:
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
case modalConfirmMount:
left, top, panel := m.mountPromptBounds()
if labelHitAt(panel, mountConfirmLabel, left, top, msg.X, msg.Y) {
m.modalChoice = 0
cmd := m.answerMountConfirmation(true)
return m, cmd
}
if labelHitAt(panel, mountCancelLabel, left, top, msg.X, msg.Y) {
m.modalChoice = 1
cmd := m.answerMountConfirmation(false)
return m, cmd
}
case modalVulnerability:
for _, button := range m.reportButtons() {
if button == reportCopy || button == reportDone {
continue
}
if m.centeredLabelHit(view, button, msg.X, msg.Y) {
m.reportFocus = button
return m.pressReportButton(button)
}
}
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
m.reportFocus = reportCopy
cmd := m.startVulnerabilityCopy()
return m, cmd
}
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
m.modalChoice = 1
m.reportFocus = reportDone
m.closeModal()
}
}
@@ -464,7 +519,14 @@ func (m Model) centeredViewBounds(view string) (left, top, width, height int) {
func (m Model) centeredLabelHit(view, label string, x, y int) bool {
left, top, _, _ := m.centeredViewBounds(view)
for row, line := range strings.Split(view, "\n") {
return labelHitAt(view, label, left, top, x, y)
}
// labelHitAt reports whether a click landed on a label drawn in a panel whose
// top-left corner is at (left, top). The mount prompt is docked in a corner
// rather than centered, so it cannot use the centered bounds.
func labelHitAt(panel, label string, left, top, x, y int) bool {
for row, line := range strings.Split(panel, "\n") {
plain := ansi.Strip(line)
index := strings.Index(plain, label)
if index < 0 || y != top+row {
@@ -516,16 +578,19 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "esc":
m.closeModal()
case "left", "right", "tab", "shift+tab":
m.modalChoice = 1 - m.modalChoice
// The arrows step between reports directly; tab walks the button row.
case "left":
m.showVulnerability(m.selectedVuln - 1)
case "right":
m.showVulnerability(m.selectedVuln + 1)
case "tab":
m.stepReportFocus(1)
case "shift+tab":
m.stepReportFocus(-1)
case "enter":
if m.modalChoice == 0 {
cmd := m.startVulnerabilityCopy()
return m, cmd
}
m.closeModal()
return m.pressReportButton(m.focusedReportButton())
case "c":
m.modalChoice = 0
m.reportFocus = reportCopy
cmd := m.startVulnerabilityCopy()
return m, cmd
case "up":
@@ -547,7 +612,8 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "esc":
if m.modal == modalConfirmMount {
// The backend is waiting on an answer; escape declines it.
return m, m.answerMountConfirmation(false)
cmd := m.answerMountConfirmation(false)
return m, cmd
}
m.closeModal()
return m, nil
@@ -558,7 +624,10 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
modal, choice := m.modal, m.modalChoice
if modal == modalConfirmMount {
// The snapshot closes this prompt once the backend has the answer.
return m, m.answerMountConfirmation(choice == 0)
// Bound to a variable first: the call restores the held prompt into
// the composer, and that has to be in the model being returned.
cmd := m.answerMountConfirmation(choice == 0)
return m, cmd
}
m.closeModal()
if choice == 1 {
@@ -584,6 +653,7 @@ func (m *Model) openModal(mode modalMode) {
m.modalChoice = 1
}
if mode == modalVulnerability {
m.reportFocus = reportDone
m.modalChoice = 1
m.vulnerabilityCopied = false
m.vulnerabilityCopyError = ""
+36 -11
View File
@@ -164,6 +164,15 @@ func wrapBlock(value string, width int) string {
return strings.Join(out, "\n")
}
// scrollbarThumb brightens the bar being dragged so the grab reads as taking
// hold of it.
func (m Model) scrollbarThumb(target scrollbarTarget) lipgloss.Color {
if m.draggingScrollbar == target {
return thumbActive
}
return thumbResting
}
func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string {
if height <= 0 || total <= visible {
return ""
@@ -262,6 +271,23 @@ func (m Model) viewInner() string {
return m.toastOverlay(main)
}
// mountPromptBounds is where the working-directory prompt is drawn. It is placed
// by cornerOverlay rather than centered, so a click has to be tested against
// these bounds and not the ones the other modals use.
func (m Model) mountPromptBounds() (left, top int, panel string) {
panel = m.modalView()
if panel == "" {
return 0, 0, ""
}
_, _, chatWidth, _ := m.layout()
left = max(0, min(chatWidth, m.width)-lipgloss.Width(panel))
statusH := 0
if m.statusVisible() {
statusH = 1
}
return left, max(0, m.inputTop()-statusH-lipgloss.Height(panel)), panel
}
// cornerOverlay splices a panel in directly above the composer, right-aligned
// with it, leaving the rest of the view visible behind it.
func (m Model) cornerOverlay(view, panel string) string {
@@ -424,7 +450,7 @@ func (m Model) renderChatPane(width, height int, border lipgloss.Color) string {
m.viewport.TotalLineCount(),
m.viewport.VisibleLineCount(),
m.viewport.YOffset,
thumbTrace,
m.scrollbarThumb(scrollbarTrace),
)
out := lipgloss.NewStyle().Width(width).Height(height).
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
@@ -489,7 +515,7 @@ func (m Model) sidebarView(width, height int) string {
len(agentEntries),
agentRows,
m.agentOffset,
thumbAgents,
m.scrollbarThumb(scrollbarAgents),
)
parts := []string{
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
@@ -503,13 +529,13 @@ func (m Model) sidebarView(width, height int) string {
vulnRows := max(1, vulnHeight-2)
totalRows, offsetRows := m.vulnerabilityScrollRows()
findings := withVerticalScrollbar(
m.vulnerabilitiesView(max(1, width-5), vulnRows),
m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows),
width-4,
vulnRows,
totalRows,
vulnRows,
offsetRows,
thumbFindings,
m.scrollbarThumb(scrollbarFindings),
)
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
}
@@ -524,12 +550,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) {
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView()))
statsHeight = min(15, statsRows+2)
if len(m.snapshot.Vulnerabilities) > 0 {
rows := 0
width := m.vulnerabilityListWidth()
for i := range m.snapshot.Vulnerabilities {
rows += len(m.vulnerabilityTitleLines(i, width))
}
vulnHeight = min(12, rows+2)
vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
}
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
return
@@ -575,7 +596,11 @@ func (m Model) statsView() string {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(green).Render("ChatGPT subscription"))
label := m.snapshot.SubscriptionLabel
if label == "" {
label = "Subscription"
}
b.WriteString(lipgloss.NewStyle().Foreground(green).Render(label))
}
total := numberValue(m.snapshot.Usage["total_tokens"])
if total > 0 {
@@ -1,6 +1,7 @@
package app
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -13,115 +14,123 @@ var panelSeverityColors = map[string]lipgloss.Color{
"critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue,
}
func (m Model) vulnerabilitiesView(width, height int) string {
var lines []string
start := min(max(0, m.vulnOffset), max(0, len(m.snapshot.Vulnerabilities)-1))
for i := start; i < len(m.snapshot.Vulnerabilities) && len(lines) < height; i++ {
vuln := m.snapshot.Vulnerabilities[i]
severity := strings.ToLower(render.StringValue(vuln["severity"]))
color, ok := panelSeverityColors[severity]
if !ok {
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
// vulnerabilityRow is one rendered line of the findings list. The list scrolls by
// row rather than by finding, so a long title does not make the panel jump a
// whole entry at a time.
type vulnerabilityRow struct {
index int // the finding this line belongs to
text string // one wrapped line of its title
first bool // the line that carries the number and the severity dot
}
// vulnerabilityRows lays every finding out as the lines it will occupy.
func (m Model) vulnerabilityRows(width int) []vulnerabilityRow {
// Wrapped lines sit under the title rather than under the severity dot.
body := max(1, width-2)
rows := make([]vulnerabilityRow, 0, len(m.snapshot.Vulnerabilities))
for i := range m.snapshot.Vulnerabilities {
for line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") {
rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0})
}
marker := lipgloss.NewStyle().Foreground(color).Render("● ")
}
return rows
}
func (m Model) vulnerabilitiesView(width, height int) string {
rows := m.vulnerabilityRows(width)
start := min(max(0, m.vulnOffset), max(0, len(rows)-1))
end := min(len(rows), start+height)
lines := make([]string, 0, max(0, end-start))
for _, row := range rows[start:end] {
style := lipgloss.NewStyle().Foreground(textColor)
if i == m.selectedVuln {
if row.index == m.selectedVuln {
style = style.Bold(true).Foreground(white)
}
for row, titleLine := range m.vulnerabilityTitleLines(i, width) {
if len(lines) >= height {
break
prefix := " "
if row.first {
severity := strings.ToLower(render.StringValue(m.snapshot.Vulnerabilities[row.index]["severity"]))
color, ok := panelSeverityColors[severity]
if !ok {
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
}
prefix := " "
if row == 0 {
prefix = marker
}
lines = append(lines, prefix+style.Render(titleLine))
prefix = lipgloss.NewStyle().Foreground(color).Render(" ")
}
lines = append(lines, prefix+style.Render(row.text))
}
return strings.Join(lines, "\n")
}
// vulnerabilityListWidth is the one width the findings list is laid out at, for
// rendering and for every interaction alike. Wrapping a title at two widths a
// column apart gives two different row counts, and then a click resolves to the
// wrong finding and the scrollbar reports the wrong length.
//
// The panel is sidebarWidth-2 wide with a column of padding either side, and the
// scrollbar takes one more. That last column is reserved whether or not the bar
// is showing, so the layout does not shift as the list grows past the panel.
func (m Model) vulnerabilityListWidth() int {
_, sidebarWidth, _, _ := m.layout()
return max(1, sidebarWidth-6)
return max(1, sidebarWidth-5)
}
func (m Model) vulnerabilityTitleLines(index, width int) []string {
func (m Model) vulnerabilityTitle(index int) string {
title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"])
if title == "" {
title = "Unknown Vulnerability"
}
return strings.Split(wrapBlock(title, max(1, width-2)), "\n")
return title
}
// vulnerabilityScrollRows reports the list length and position in rows, which is
// what the scrollbar needs to move continuously.
func (m Model) vulnerabilityScrollRows() (total, offset int) {
width := m.vulnerabilityListWidth()
for i := range m.snapshot.Vulnerabilities {
rows := len(m.vulnerabilityTitleLines(i, width))
total += rows
if i < m.vulnOffset {
offset += rows
}
}
return total, offset
}
func (m Model) vulnerabilityOffsetAtRow(targetRow int) int {
width := m.vulnerabilityListWidth()
row := 0
for i := range m.snapshot.Vulnerabilities {
row += len(m.vulnerabilityTitleLines(i, width))
if targetRow < row {
return i
}
}
return max(0, len(m.snapshot.Vulnerabilities)-1)
}
func (m Model) vulnerabilityVisibleEnd(start int) int {
height := m.vulnerabilityPageSize()
width := m.vulnerabilityListWidth()
rows := 0
end := min(max(0, start), len(m.snapshot.Vulnerabilities))
for end < len(m.snapshot.Vulnerabilities) {
itemRows := len(m.vulnerabilityTitleLines(end, width))
if rows > 0 && rows+itemRows > height {
break
}
rows += itemRows
end++
if rows >= height {
break
}
}
return end
return len(m.vulnerabilityRows(m.vulnerabilityListWidth())), m.vulnOffset
}
// vulnerabilityIndexAtRow maps a click on a visible row back to its finding.
func (m Model) vulnerabilityIndexAtRow(row int) int {
width := m.vulnerabilityListWidth()
currentRow := 0
for i := m.vulnOffset; i < m.vulnerabilityVisibleEnd(m.vulnOffset); i++ {
currentRow += len(m.vulnerabilityTitleLines(i, width))
if row < currentRow {
return i
}
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
target := m.vulnOffset + row
if target < 0 || target >= len(rows) {
return -1
}
return -1
return rows[target].index
}
// ensureVulnerabilityVisible scrolls the least it can to bring the selected
// finding into view, keeping the whole entry visible where it fits.
func (m *Model) ensureVulnerabilityVisible() {
if len(m.snapshot.Vulnerabilities) == 0 {
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) == 0 {
m.vulnOffset = 0
return
}
if m.selectedVuln < m.vulnOffset {
m.vulnOffset = m.selectedVuln
height := m.vulnerabilityPageSize()
firstRow, lastRow := -1, -1
for row, entry := range rows {
if entry.index != m.selectedVuln {
continue
}
if firstRow < 0 {
firstRow = row
}
lastRow = row
}
for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln {
m.vulnOffset++
if firstRow < 0 {
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
return
}
m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1)
if firstRow < m.vulnOffset {
m.vulnOffset = firstRow
} else if lastRow >= m.vulnOffset+height {
// Prefer showing the whole entry, but never scroll its start out of view.
m.vulnOffset = min(firstRow, lastRow-height+1)
}
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
}
func clampVulnerabilityOffset(offset, total, height int) int {
return min(max(0, offset), max(0, total-height))
}
func (m Model) vulnerabilityPageSize() int {
@@ -129,23 +138,52 @@ func (m Model) vulnerabilityPageSize() int {
return max(1, vulnHeight-2)
}
// vulnerabilityPageItems is how many findings a page step should move by: the
// number of distinct entries currently on screen.
func (m Model) vulnerabilityPageItems() int {
return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset)
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
height := m.vulnerabilityPageSize()
start := min(max(0, m.vulnOffset), max(0, len(rows)))
end := min(len(rows), start+height)
seen := 0
previous := -1
for _, row := range rows[start:end] {
if row.index != previous {
seen++
previous = row.index
}
}
return max(1, seen)
}
func (m *Model) moveVulnerabilitySelection(delta int) {
m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta))
}
// keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding
// still on screen after the list has been scrolled directly.
func (m *Model) keepVulnerabilitySelectionInWindow() {
if len(m.snapshot.Vulnerabilities) == 0 {
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) == 0 {
return
}
if m.selectedVuln < m.vulnOffset {
m.selectedVuln = m.vulnOffset
} else if end := m.vulnerabilityVisibleEnd(m.vulnOffset); m.selectedVuln >= end {
m.selectedVuln = max(m.vulnOffset, end-1)
height := m.vulnerabilityPageSize()
start := min(max(0, m.vulnOffset), max(0, len(rows)-1))
end := min(len(rows), start+height)
visible := rows[start:end]
if len(visible) == 0 {
return
}
for _, row := range visible {
if row.index == m.selectedVuln {
return
}
}
if m.selectedVuln < visible[0].index {
m.selectedVuln = visible[0].index
return
}
m.selectedVuln = visible[len(visible)-1].index
}
// statsView ports build_tui_stats_text + the version line appended in
@@ -182,6 +220,13 @@ func (m Model) confirmView(title string, width int, border, titleColor lipgloss.
return m.confirmDialog(title, "", width, border, titleColor, red, "Yes", "No")
}
// The mount prompt's buttons, named so the renderer and the click test cannot
// drift apart.
const (
mountConfirmLabel = "Mount"
mountCancelLabel = "Skip"
)
// mountConfirmView asks before a target-less scan mounts the working directory.
// It is a compact prompt docked in the corner of the live view: nothing is
// prepared until it is answered, and the directory is a workspace rather than a
@@ -194,8 +239,8 @@ func (m Model) mountConfirmView() string {
}
title := render.Bold(amber).Render("△ Mount working directory?")
body := render.Col(white).Render(truncatePath(dir, width-4)) + "\n" +
render.Dim().Render("writable in the sandbox")
return m.cornerPrompt(title, body, width, "Confirm", "Cancel")
render.Dim().Render("writable in the sandbox · skip to run without it")
return m.cornerPrompt(title, body, width, mountConfirmLabel, mountCancelLabel)
}
// truncatePath keeps the tail of a path visible, which is the part that
@@ -373,25 +418,116 @@ func (m Model) vulnerabilityDetail() string {
inner := max(1, width-8)
// Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons).
rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner)))
copyLabel := "Copy"
if m.vulnerabilityCopied {
copyLabel = "Copied!"
} else if m.vulnerabilityCopyError != "" {
copyLabel = "Copy failed"
focused := m.focusedReportButton()
var stepping, acting []string
for _, button := range m.reportButtons() {
rendered := m.reportButton(button, button == focused)
if button == reportPrev || button == reportNext {
stepping = append(stepping, rendered)
continue
}
acting = append(acting, rendered)
}
copyButton := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252"))
doneButton := lipgloss.NewStyle().Foreground(mid)
if m.modalChoice == 0 {
copyButton = copyButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
} else {
doneButton = doneButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
// Stepping sits on the left behind the position, acting on the right.
right := strings.Join(acting, " ")
left := strings.Join(stepping, " ")
if total := len(m.snapshot.Vulnerabilities); total > 1 {
left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left
}
buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done")
buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons)
room := max(0, inner-lipgloss.Width(right))
buttonRow := rule + "\n" +
lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right
content := m.vulnerabilityScrollView() + "\n" + buttonRow
return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content)
}
// showVulnerability moves the open report to another finding, keeping the list
// behind it in step and starting the new report at its top.
func (m *Model) showVulnerability(index int) {
if index < 0 || index >= len(m.snapshot.Vulnerabilities) || index == m.selectedVuln {
return
}
m.selectedVuln = index
m.ensureVulnerabilityVisible()
// The copy state belongs to the report that was on screen, not this one.
m.vulnerabilityCopied = false
m.vulnerabilityCopyError = ""
m.resizeVulnerabilityViewport()
m.vulnViewport.GotoTop()
}
// The report's buttons. Prev and Next carry their arrows so a click test cannot
// be fooled by the same word appearing in the body of a finding.
const (
reportPrev = " Prev"
reportNext = "Next "
reportCopy = "Copy"
reportDone = "Done"
)
// reportButtons is the row as it stands, left to right. Stepping is offered only
// in the directions that have a report.
func (m Model) reportButtons() []string {
previous, next := m.vulnerabilityNeighbors()
buttons := make([]string, 0, 4)
if previous {
buttons = append(buttons, reportPrev)
}
if next {
buttons = append(buttons, reportNext)
}
return append(buttons, reportCopy, reportDone)
}
// focusedReportButton is the button Enter would press. It falls back to Done when
// the focused one has gone, which happens when stepping to either end drops a
// direction from the row.
func (m Model) focusedReportButton() string {
for _, button := range m.reportButtons() {
if button == m.reportFocus {
return button
}
}
return reportDone
}
// stepReportFocus moves along the row, wrapping at its ends.
func (m *Model) stepReportFocus(delta int) {
buttons := m.reportButtons()
current := 0
for i, button := range buttons {
if button == m.focusedReportButton() {
current = i
}
}
m.reportFocus = buttons[clampCycle(current+delta, len(buttons))]
}
// vulnerabilityNeighbors reports which way the open report can be stepped. The
// ends are not wrapped: a report is one of an ordered list, and rolling from the
// last to the first hides that you reached the end.
func (m Model) vulnerabilityNeighbors() (previous, next bool) {
return m.selectedVuln > 0, m.selectedVuln < len(m.snapshot.Vulnerabilities)-1
}
// reportButton renders one button of the report row. Copy reports the outcome of
// the last attempt in its own label.
func (m Model) reportButton(label string, focused bool) string {
if label == reportCopy {
switch {
case m.vulnerabilityCopied:
label = "Copied!"
case m.vulnerabilityCopyError != "":
label = "Copy failed"
}
}
if focused {
return lipgloss.NewStyle().Background(lipgloss.Color("#363636")).
Foreground(brightWhite).Bold(true).Padding(0, 1).Render(label)
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")).Render(label)
}
func (m *Model) startVulnerabilityCopy() tea.Cmd {
m.vulnerabilityCopied = false
m.vulnerabilityCopyError = ""
@@ -68,6 +68,7 @@ type Snapshot struct {
Vulnerabilities []map[string]any `json:"-"`
Usage map[string]any `json:"usage"`
Subscription bool `json:"subscription"`
SubscriptionLabel string `json:"subscription_label"`
ViewerStatus string `json:"viewer_status"`
ViewerURL *string `json:"viewer_url"`
Error *string `json:"error"`
@@ -57,6 +57,12 @@ func renderDependencyReport(args map[string]any, result any) string {
section("Description", StringValue(args["description"]))
section("Impact", StringValue(args["impact"]))
section("Technical Analysis", StringValue(args["technical_analysis"]))
if reach := StringValue(args["reachability"]); reach != "" && reach != "unknown" {
b.WriteString("\n\n" + Bold(Field).Render("Usage evidence: ") + reach)
if ev := StringValue(args["reachability_evidence"]); ev != "" {
b.WriteString("\n" + ev)
}
}
section("Assumptions", StringValue(args["assumptions"]))
section("Remediation", StringValue(args["remediation_steps"]))
if title == "" {
+1 -1
View File
@@ -431,7 +431,7 @@ _INTERNAL_TURN_PREFIXES = (
"== Inherited context from parent",
# strix.core.execution: the no-tool-call recovery nudge, both modes.
"Your previous message ended a turn without a tool call.",
"Your previous response ended the autonomous Strix run without a lifecycle tool call.",
"Your previous response ended the autonomous run without a lifecycle tool call.",
# strix.core.hooks: budget warnings, the only notices injected unwrapped.
*(
f"[{label}] {subject}"
+23 -5
View File
@@ -262,9 +262,27 @@ def is_subscription_run(report_state: Any) -> bool:
record = getattr(report_state, "run_record", None)
if isinstance(record, dict) and record.get("auth_mode"):
return record.get("auth_mode") == "subscription"
from strix.config import codex
from strix.config import subscription
return codex.auth_mode(load_settings().llm.model) == "subscription"
return subscription.auth_mode(load_settings().llm.model) == "subscription"
def subscription_label(report_state: Any) -> str:
"""Human label for the active model subscription (e.g. "Grok subscription").
Prefers the persisted run record so a resumed run keeps its original provider
even if STRIX_LLM later points at a different one; falls back to current
settings.
"""
record = getattr(report_state, "run_record", None)
if isinstance(record, dict):
provider = record.get("subscription_provider")
if isinstance(provider, str) and provider:
return f"{provider} subscription"
from strix.config import subscription
label = subscription.provider_label(load_settings().llm.model)
return f"{label} subscription" if label else "Subscription"
def _int_stat(usage: dict[str, Any], key: str) -> int:
@@ -368,7 +386,7 @@ def build_live_stats_text(report_state: Any) -> Text:
stats_text.append(str(model), style="white")
if is_subscription_run(report_state):
stats_text.append(" · ", style="dim white")
stats_text.append("ChatGPT subscription", style="#22c55e")
stats_text.append(subscription_label(report_state), style="#22c55e")
stats_text.append("\n")
vuln_count = len(report_state.vulnerability_reports)
@@ -414,7 +432,7 @@ def build_tui_stats_text(report_state: Any) -> Text:
subscription = is_subscription_run(report_state)
if subscription:
stats_text.append("\n")
stats_text.append("ChatGPT subscription", style="#22c55e")
stats_text.append(subscription_label(report_state), style="#22c55e")
usage = _llm_usage(report_state)
if usage and _int_stat(usage, "total_tokens") > 0:
@@ -1102,7 +1120,7 @@ def resolve_diff_scope_context(
def _is_http_git_repo(url: str) -> bool:
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
try:
with requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10) as resp:
with requests.get(check_url, headers={"User-Agent": "git/2.43.0"}, timeout=10) as resp:
if resp.status_code >= 400:
return resp.status_code == 401
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
@@ -101,6 +101,7 @@ export function RunDetails({
const totalTokens = num(usage.total_tokens);
const cost = num(usage.cost);
const subscription = str(raw.auth_mode) === "subscription";
const subscriptionProvider = str(raw.subscription_provider);
const sub = (n: number, word: string) => (
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
@@ -180,7 +181,7 @@ export function RunDetails({
<Field label="Provider">
<span className="inline-flex items-center gap-1.5">
<span className="rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]">
ChatGPT subscription
{subscriptionProvider ? `${subscriptionProvider} subscription` : "Subscription"}
</span>
</span>
</Field>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-DBJ-RJqo.js"></script>
<script type="module" crossorigin src="./assets/index-XDX3roAH.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DKbLYAbP.css">
</head>
<body>
+34 -1
View File
@@ -6,6 +6,7 @@ import json
import logging
from typing import TYPE_CHECKING, Any
from strix.config import subscription
from strix.core.paths import run_record_path
from strix.interface.tui.live_view import TuiLiveView
@@ -57,7 +58,39 @@ def read_run_summary(run_dir: Path) -> dict[str, Any]:
record = {}
status = record.get("status")
finished = status in _TERMINAL_STATUSES and bool(record.get("end_time"))
return {**record, "finished": finished}
summary = {**record, "finished": finished}
_backfill_subscription_provider(summary)
return summary
def _first_recorded_model(record: dict[str, Any]) -> str | None:
"""The first non-empty per-agent model slug in a run record, or None."""
usage = record.get("llm_usage")
if not isinstance(usage, dict):
return None
agents = usage.get("agents")
if not isinstance(agents, list):
return None
for agent in agents:
if isinstance(agent, dict):
model = agent.get("model")
if isinstance(model, str) and model:
return model
return None
def _backfill_subscription_provider(record: dict[str, Any]) -> None:
"""Name the subscription provider for runs recorded before that field
existed, deriving it from the recorded ``provider/model`` slug so the viewer
labels them correctly without a rescan. Newer runs already carry the field.
"""
if record.get("subscription_provider"):
return
if record.get("auth_mode") != "subscription":
return
label = subscription.provider_label(_first_recorded_model(record))
if label:
record["subscription_provider"] = label
def primary_target(record: dict[str, Any]) -> str | None:
+1
View File
@@ -294,6 +294,7 @@ async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=llm.extra_headers,
has_tools=False,
).resolve(ModelSettings(max_tokens=max_tokens))
try:
response = (
+9 -5
View File
@@ -10,7 +10,7 @@ from typing import Any
import litellm
from strix.config import load_settings
from strix.config import load_settings, subscription
logger = logging.getLogger(__name__)
@@ -19,7 +19,6 @@ logger = logging.getLogger(__name__)
# ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup.
_STRIPPABLE_PREFIXES = (
"openai/",
"chatgpt/",
"litellm/",
"any-llm/",
"ollama/",
@@ -30,6 +29,8 @@ _DEFAULT_OUTPUT_TOKENS = 8_192
def _lookup_key(model: str) -> str:
if subscription.provider_for_model(model) is not None:
return subscription.litellm_model_name(model) or model
for prefix in _STRIPPABLE_PREFIXES:
if model.startswith(prefix):
return model[len(prefix) :]
@@ -46,9 +47,12 @@ def _safe_get_model_info(model: str) -> dict[str, Any] | None:
@lru_cache(maxsize=128)
def _model_info(model: str) -> dict[str, int]:
lookup_key = _lookup_key(model)
# Provider-qualified ChatGPT lookups may start a synchronous device-login
# poll. LiteLLM keys the metadata by the underlying model slug.
candidates = (lookup_key,) if model.startswith("chatgpt/") else (model, lookup_key)
# Subscription prefixes are never LiteLLM keys, and a provider-qualified
# ChatGPT lookup may start a synchronous device-login poll: only ask about
# the resolved name.
candidates = (
(lookup_key,) if subscription.provider_for_model(model) is not None else (model, lookup_key)
)
for candidate in candidates:
info = _safe_get_model_info(candidate)
if info is not None:
+21
View File
@@ -62,6 +62,7 @@ def _dedupe_model_settings(
# must never receive the main endpoint's credentials. A dedicated model
# gets its own DEDUPE_LLM_EXTRA_HEADERS instead.
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
has_tools=False,
)
extra = _dedupe_extra_args(dedupe)
if extra:
@@ -183,6 +184,24 @@ def _dependency_identity(report: dict[str, Any]) -> tuple[str, str, str] | None:
return cve, ecosystem, package_name
def _manifest_path(report: dict[str, Any]) -> str:
metadata = report.get("dependency_metadata")
if not isinstance(metadata, dict):
return ""
return str(metadata.get("manifest_path") or "").strip()
def _distinct_manifest_paths(candidate: dict[str, Any], report: dict[str, Any]) -> bool:
"""Same CVE/package observed in two different manifests is two findings.
Only applies when both sides carry a manifest_path; a missing path keeps
the legacy CVE/package/ecosystem identity.
"""
candidate_path = _manifest_path(candidate)
report_path = _manifest_path(report)
return bool(candidate_path and report_path and candidate_path != report_path)
def _report_cve(report: dict[str, Any]) -> str:
return str(report.get("cve") or "").strip().upper()
@@ -228,6 +247,8 @@ def _check_dependency_duplicate(
report_cve, report_ecosystem, report_package_name = report_identity
if (report_cve, report_package_name) != (cve, package_name):
continue
if _distinct_manifest_paths(candidate, report):
continue
if report_ecosystem == ecosystem:
return {
"is_duplicate": True,
+54
View File
@@ -0,0 +1,54 @@
"""LiteLLM model-name resolution for local cost estimates."""
from __future__ import annotations
from functools import lru_cache
from typing import Any, cast
@lru_cache(maxsize=512)
def resolve_litellm_model(model: str) -> str | None:
"""Return a provider-qualified model name that LiteLLM can price."""
try:
import litellm
normalized = model.strip()
for prefix in ("litellm/", "any-llm/", "openai/"):
if normalized.startswith(prefix):
normalized = normalized.removeprefix(prefix)
break
if not normalized:
return None
model_cost = cast(
"dict[str, dict[str, Any]]",
getattr(litellm, "model_cost"), # noqa: B009
)
bare_entry = model_cost.get(normalized)
if "/" not in normalized and isinstance(bare_entry, dict):
provider = bare_entry.get("litellm_provider")
if isinstance(provider, str) and provider:
return f"{provider}/{normalized}"
if "/" in normalized and isinstance(bare_entry, dict):
return normalized
names = [normalized]
if "/" in normalized:
names.append(normalized.rsplit("/", 1)[-1])
for name in names:
matches = sorted(key for key in model_cost if key.endswith(f"/{name}"))
if not matches:
continue
prices = {
(
model_cost[key].get("input_cost_per_token"),
model_cost[key].get("output_cost_per_token"),
)
for key in matches
if isinstance(model_cost.get(key), dict)
}
if len(matches) == 1 or len(prices) == 1:
return matches[0]
return None # noqa: TRY300
except Exception: # noqa: BLE001
return None
+10 -4
View File
@@ -11,9 +11,10 @@ from uuid import uuid4
from agents.usage import Usage
from strix.config import codex
from strix.config import subscription
from strix.config.loader import load_settings
from strix.core.paths import run_dir_for
from strix.report.pricing import resolve_litellm_model
from strix.report.sarif import write_sarif
from strix.report.usage import LLMUsageLedger
from strix.report.writer import (
@@ -122,7 +123,8 @@ class ReportState:
self.scan_results: dict[str, Any] | None = None
self.scan_config: dict[str, Any] | None = None
self._llm_usage = LLMUsageLedger()
auth_mode = codex.auth_mode(load_settings().llm.model)
model = load_settings().llm.model
auth_mode = subscription.auth_mode(model)
self._llm_usage.zero_cost = auth_mode == "subscription"
self.run_record: dict[str, Any] = {
"run_id": self.run_id,
@@ -131,6 +133,7 @@ class ReportState:
"end_time": None,
"status": "running",
"auth_mode": auth_mode,
"subscription_provider": subscription.provider_label(model),
"targets_info": [],
"llm_usage": self._build_llm_usage_record(),
}
@@ -696,10 +699,13 @@ def _estimate_response_cost(kwargs: Any, completion_response: Any) -> float | No
candidates.append(model.rsplit("/", 1)[-1])
for candidate in candidates:
resolved = resolve_litellm_model(candidate)
if not resolved:
continue
try:
value = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=candidate,
completion_response={"model": resolved, "usage": usage_payload},
model=resolved,
)
except Exception: # nosec B112 # noqa: BLE001, S112
continue
+30 -29
View File
@@ -7,6 +7,8 @@ from typing import Any
from agents.usage import Usage, deserialize_usage, serialize_usage
from strix.report.pricing import resolve_litellm_model
logger = logging.getLogger(__name__)
@@ -18,7 +20,9 @@ class LLMUsageLedger:
self._total_usage = Usage()
self._agent_usage: dict[str, Usage] = {}
self._agent_metadata: dict[str, dict[str, str]] = {}
self._total_cost = 0.0
self._observed_cost = 0.0
self._estimated_cost = 0.0
self._has_observed_cost = False
# When True, tokens are still tracked but cost stays $0 — the run is on a
# model subscription, so there is no metered per-token charge to report.
self.zero_cost = False
@@ -44,10 +48,10 @@ class LLMUsageLedger:
if model:
metadata["model"] = model
if not self.zero_cost and not _is_litellm_routed(model):
if not self.zero_cost:
estimated = _estimate_litellm_cost(usage, model)
if estimated:
self._total_cost += estimated
self._estimated_cost += estimated
return True
@@ -55,15 +59,18 @@ class LLMUsageLedger:
if self.zero_cost:
return
if isinstance(cost, int | float) and cost > 0:
self._total_cost += float(cost)
self._observed_cost += float(cost)
self._has_observed_cost = True
@property
def total_cost(self) -> float:
return _round_cost(self._total_cost)
if self.zero_cost:
return 0.0
return _round_cost(self._observed_cost if self._has_observed_cost else self._estimated_cost)
def to_record(self) -> dict[str, Any]:
record = serialize_usage(self._total_usage)
record["cost"] = _round_cost(self._total_cost)
record["cost"] = self.total_cost
record["agents"] = []
agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()}
@@ -72,7 +79,7 @@ class LLMUsageLedger:
usage = self._agent_usage[agent_id]
metadata = self._agent_metadata.get(agent_id, {})
agent_cost = (
self._total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
self.total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
)
agent_record = serialize_usage(usage)
@@ -92,7 +99,9 @@ class LLMUsageLedger:
self._total_usage = Usage()
self._agent_usage.clear()
self._agent_metadata.clear()
self._total_cost = 0.0
self._observed_cost = 0.0
self._estimated_cost = 0.0
self._has_observed_cost = False
if not isinstance(raw_usage, dict):
return
@@ -103,7 +112,9 @@ class LLMUsageLedger:
logger.exception("Failed to hydrate aggregate llm_usage from run.json")
self._total_usage = Usage()
self._total_cost = _float_or_zero(raw_usage.get("cost"))
persisted_cost = _float_or_zero(raw_usage.get("cost"))
self._observed_cost = persisted_cost
self._estimated_cost = persisted_cost
for raw_agent in raw_usage.get("agents") or []:
if not isinstance(raw_agent, dict):
@@ -136,15 +147,6 @@ def _resolve_total_tokens(usage: Usage) -> int:
return prompt + completion
def _is_litellm_routed(model: str | None) -> bool:
if not model:
return False
name = model.strip().lower()
if "/" not in name:
return False
return not name.startswith("openai/")
def _usage_has_activity(usage: Usage) -> bool:
return bool(
usage.requests
@@ -201,24 +203,23 @@ def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
candidates = [model]
if "/" in model:
candidates.append(model.split("/", 1)[-1])
candidates.append(model.rsplit("/", 1)[-1])
cost: Any = None
for candidate in candidates:
resolved = resolve_litellm_model(candidate)
if not resolved:
continue
try:
cost = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=model,
completion_response={"model": resolved, "usage": usage_payload},
model=resolved,
)
break
except Exception: # nosec B112 # noqa: BLE001, S112
continue
if cost is None:
logger.debug("LiteLLM cost estimate unavailable for model %s", model)
return None
return cost if isinstance(cost, int | float) and cost >= 0 else None
if cost > 0:
return float(cost)
logger.debug("LiteLLM cost estimate unavailable for model %s", model)
return None
def _litellm_model_name(model: str | None) -> str | None:
+71 -8
View File
@@ -4,6 +4,9 @@ import threading
from collections import Counter
from collections.abc import Iterator
from pathlib import Path
from typing import TypeGuard
import yaml
from strix.telemetry import posthog, scarf
from strix.utils.resource_paths import get_strix_resource_path
@@ -11,12 +14,17 @@ from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__)
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n", re.DOTALL)
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
_ROOT_SKILL_CATEGORY = "root"
_EXTRA_SKILL_DIRS: list[Path] = []
_SKILL_METADATA_CACHE: dict[tuple[Path, int, int], dict[str, str]] = {}
def _is_frontmatter_mapping(value: object) -> TypeGuard[dict[object, object]]:
return isinstance(value, dict)
def register_skill_dir(path: str | Path) -> None:
@@ -109,13 +117,18 @@ def _get_ambiguous_skill_names() -> set[str]:
return {name for name, count in counts.items() if count > 1}
def _qualified_skill_files(skill_name: str) -> list[Path]:
def _qualified_skill_file_for_name(skill_name: str) -> Path | None:
category, _, name = skill_name.partition("/")
for skills_dir in skill_search_dirs():
candidate = _qualified_skill_file(skills_dir, category, name)
if candidate is not None:
return [candidate]
return []
return candidate
return None
def _qualified_skill_files(skill_name: str) -> list[Path]:
candidate = _qualified_skill_file_for_name(skill_name)
return [candidate] if candidate is not None else []
def _bare_skill_files(skill_name: str) -> list[Path]:
@@ -145,10 +158,59 @@ def _bare_skill_files(skill_name: str) -> list[Path]:
return candidates
def get_available_skills() -> dict[str, list[str]]:
grouped: dict[str, list[str]] = {}
def _parse_skill_content(content: str, source: Path | None = None) -> tuple[dict[str, str], str]:
"""Parse skill frontmatter once and return metadata plus markdown body."""
frontmatter = _FRONTMATTER_PATTERN.match(content)
if frontmatter is None:
return {}, content.lstrip()
try:
parsed: object = yaml.safe_load(frontmatter.group("body"))
except yaml.YAMLError as error:
logger.warning("Failed to parse skill frontmatter %s: %s", source or "<content>", error)
parsed = None
if not _is_frontmatter_mapping(parsed):
logger.warning("Skill frontmatter is not a mapping: %s", source or "<content>")
return {}, content[frontmatter.end() :].lstrip()
metadata = {str(key): "" if value is None else str(value) for key, value in parsed.items()}
return metadata, content[frontmatter.end() :].lstrip()
def _read_skill_metadata(file_path: Path) -> dict[str, str]:
try:
stat = file_path.stat()
except OSError:
logger.warning("Skill file disappeared while reading metadata: %s", file_path)
return {}
cache_key = (file_path, stat.st_mtime_ns, stat.st_size)
cached = _SKILL_METADATA_CACHE.get(cache_key)
if cached is not None:
return cached
try:
content = file_path.read_text(encoding="utf-8")
except (OSError, ValueError):
logger.warning("Failed to read skill metadata: %s", file_path)
return {}
metadata, _ = _parse_skill_content(content, file_path)
_SKILL_METADATA_CACHE[cache_key] = metadata
return metadata
def get_available_skills() -> dict[str, list[dict[str, str]]]:
grouped: dict[str, list[dict[str, str]]] = {}
for category, name in _iter_user_skill_files():
grouped.setdefault(category, []).append(name)
file_path = _qualified_skill_file_for_name(f"{category}/{name}")
if file_path is None:
logger.warning(
"Skill disappeared while gathering available skills: %s/%s",
category,
name,
)
continue
metadata = _read_skill_metadata(file_path)
description = " ".join(metadata.get("description", "").split())
grouped.setdefault(category, []).append({"name": name, "description": description})
return grouped
@@ -228,7 +290,8 @@ def load_skills(skill_names: list[str]) -> dict[str, str]:
continue
var_name = skill_name.split("/")[-1]
skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip()
_, skill_body = _parse_skill_content(content, file_path)
skill_content[var_name] = skill_body
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
_track_skill_loaded(var_name, file_path)
+1 -1
View File
@@ -16,7 +16,7 @@ GCP misconfigurations expose project data, service account keys, and lateral mov
**Storage & Data**
- Cloud Storage (GCS) buckets and objects
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase_firestore` skill)
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase` skill)
- Secret Manager, Cloud KMS keys
**Compute**
+92 -9
View File
@@ -28,7 +28,7 @@ Run from the repo root and store output in the shared artifact directory used by
the source-aware pass:
```bash
ART=/workspace/.strix-source-aware
ART=/workspace/.source-aware
mkdir -p "$ART"
# Record the vuln DB age so a stale DB is a visible signal, not a silent clean scan.
@@ -77,8 +77,10 @@ For each entry under `.Results[].Vulnerabilities[]` in `trivy-sca.json`, collect
- `CVSS` — the published advisory base score
- `PrimaryURL` / references — to verify the advisory
Deduplicate by `(CVE, PkgName, InstalledVersion)`. File one
`create_dependency_report` per CVE — do not batch multiple CVEs into one report.
Deduplicate by `(CVE, PkgName, Target)` — the same CVE/package observed in two
different manifests (e.g. two workspaces of a monorepo) is two findings, one
per manifest. File one `create_dependency_report` per CVE — do not batch
multiple CVEs into one report.
### Attribute transitive CVEs to the direct dependency
@@ -110,15 +112,87 @@ resolution (npm `overrides` / yarn `resolutions` / pnpm `pnpm.overrides` /
Maven `dependencyManagement` / Gradle resolution strategy / `go mod edit`),
not just "upgrade <vulnerable pkg> to <fixed>".
### Usage / reachability analysis (required for every dependency CVE)
For every CVE you are about to report, run a static usage analysis and record
the result in the structured `reachability` + `reachability_evidence` fields.
The level is an **evidence ladder, never an exploitability verdict** — claim
only what you proved, and cite the proof. It never changes severity (that is
`advisory_cvss` alone); it exists so the reader can prioritize.
**Go — use govulncheck (real call-graph analysis):**
```bash
# Symbol-level: reports only vulnerabilities whose vulnerable functions are
# actually reachable from application code. Needs the Go toolchain + module
# deps; if either is missing, fall back to the checks below rather than
# claiming a level.
if command -v govulncheck >/dev/null && go version >/dev/null 2>&1; then
govulncheck -format json ./... > "$ART/govulncheck.json" || true
fi
```
- A finding with a call stack ⇒ `reachability=reachable_call_path`, put the
call-path excerpt (entrypoint → vulnerable function) in
`reachability_evidence`.
- Listed as affecting a required module but with no reachable symbol ⇒ fall
back to the import/symbol checks below (`imported` / `not_imported`).
**All other ecosystems — import check, then symbol match:**
1. **Import check.** Search application code (exclude lockfiles, vendored
deps, `node_modules`, build output) for imports of the vulnerable package:
`ast-grep`/`rg` for `import`/`require`/`from X import` of the package (and
its ecosystem import name, which may differ from the registry name, e.g.
`PyYAML``yaml`). No hits ⇒ `not_imported`, with the search scope stated
in `reachability_evidence`. For a **transitive** dependency, the check is
whether application code imports it directly; if not, it is reachable only
through the direct dependency — check whether the direct dep's usage can
hit it (if unclear, use `imported` when the direct dep is used at all).
2. **Symbol match — per CVE, not per package.** Read each CVE's own advisory
(GHSA/NVD/OSV `affected[].ecosystem_specific.imports` or the advisory
text) for the affected functions/classes/APIs. Search application code for
those symbols (`ast-grep` pattern or `rg -n`). Hits ⇒
`vulnerable_symbol_used`, with repo-relative `file:line` of each hit (up
to a handful) in `reachability_evidence`. Imported but no affected-symbol
usage found (or the advisory names no symbols) ⇒ `imported`.
Different CVEs on the same package usually affect **different** symbols
(one hits a parser, another a header check) — never copy one CVE's
verdict/evidence onto its siblings; run the symbol search against each
CVE's own affected-symbol list. The import check (step 1) is the only
part shared across a package's CVEs.
3. If the analysis was not performed or is inconclusive (obfuscated code,
dynamic loading, unparsable sources) ⇒ `unknown` and say why in
`assumptions`.
Cheap-first budgeting: the import check is one search per package — always do
it. Do the per-CVE symbol match for every CVE whose advisory names affected
symbols (they can be batched into one multi-pattern search per package);
prioritize `critical`/`high`/KEV when the budget is tight; a CVE whose symbol
search was skipped may still be reported as `imported` (the import check is
real evidence), but its `reachability_evidence` must state that the
affected-symbol check was not performed, so a skipped search is never
mistaken for a completed one with no hits. Never let this analysis stall
reporting — `unknown` with a reason beats an unverified claim.
Anti-overclaim rules:
- `not_imported` still does NOT mean safe (dynamic `import()`/reflection/
framework wiring evade static search) — never phrase it as "not exploitable".
- `reachable_call_path` is reserved for call-graph tools (govulncheck); a
symbol grep hit is `vulnerable_symbol_used`, no matter how convinced you are.
- The tool rejects any level other than `unknown` without
`reachability_evidence`.
### Reachability is a confidence modifier, not a gate
Do NOT suppress or downgrade a known CVE just because you could not prove the
vulnerable code path is reachable. Report it, set `advisory_cvss` from the
advisory, and use `assumptions` to note reachability (e.g. "the vulnerable
`template()` API does not appear to be imported in application code, so practical
exploitability is uncertain"). If you *can* show reachability or chain it into a
dynamic exploit, do that and report it as a normal dynamic finding with
`create_vulnerability_report` instead.
advisory, record the usage analysis in `reachability`/`reachability_evidence`,
and use `assumptions` for anything softer. If you *can* actually trigger the
vulnerable path or chain it into a dynamic exploit, additionally report that
as a normal dynamic finding with `create_vulnerability_report` (the standalone
CVE stays in its own `create_dependency_report`).
## Reporting
@@ -139,6 +213,12 @@ findings and rejects empty PoC fields):
- `package_ecosystem` — normalized ecosystem from `.Results[].Type` (lowercased,
e.g. `npm`, `pypi`, `go`, `maven`, `rubygems`, `cargo`) (required).
- `fixed_version``FixedVersion` (leave empty only if no fix is published).
- `manifest_path` — the repo-relative `Target` lockfile/manifest path
(required). Strip any scan-workspace or repo checkout directory prefix so
the path is relative to the repository root (e.g. `package-lock.json`,
`services/api/pom.xml`); the tool rejects absolute paths and `..` segments.
This binds the finding to the exact file so remediation can target the
right repository.
- Reference the repo-relative `Target` lockfile path in `description` /
`technical_analysis` (no leading slash) so the finding is traceable.
- Put the concrete proof in `description` / `technical_analysis`: package name,
@@ -152,7 +232,8 @@ findings and rejects empty PoC fields):
- Set `cwe` to the most specific `CWE-NNN` when the advisory names one.
- Do NOT cap severity at LOW just because there is no dynamic reproduction — use
the advisory score.
- Use `assumptions` for reachability/exploitability caveats.
- Set `reachability` + `reachability_evidence` from the usage analysis above;
use `assumptions` for anything softer (confidence, caveats, analysis limits).
Verify the CVE with `web_search` when available before reporting. Never guess or
hallucinate a CVE id.
@@ -168,3 +249,5 @@ hallucinate a CVE id.
- Do not silently drop a known CVE because it lacks a dynamic PoC — that is the
exact failure this skill prevents.
- Do not downgrade advisory severity for lack of dynamic reproduction.
- Do not claim a `reachability` level the evidence does not prove — `unknown`
with a reason is always acceptable; an overclaimed level never is.
+12 -12
View File
@@ -12,7 +12,7 @@ Use this skill for source-heavy analysis where static and structural signals sho
Run tools from repo root and store outputs in a dedicated artifact directory:
```bash
mkdir -p /workspace/.strix-source-aware
mkdir -p /workspace/.source-aware
```
## Baseline Coverage Bundle (Recommended)
@@ -20,7 +20,7 @@ mkdir -p /workspace/.strix-source-aware
Run this baseline once per repository before deep narrowing:
```bash
ART=/workspace/.strix-source-aware
ART=/workspace/.source-aware
mkdir -p "$ART"
semgrep scan --config p/default --config p/golang --config p/secrets \
@@ -30,7 +30,7 @@ python3 - <<'PY'
import json
from pathlib import Path
art = Path("/workspace/.strix-source-aware")
art = Path("/workspace/.source-aware")
semgrep_json = art / "semgrep.json"
targets_file = art / "sg-targets.txt"
@@ -70,10 +70,10 @@ Use Semgrep as the default static triage pass:
```bash
# Preferred deterministic profile set (works with --metrics=off)
semgrep scan --config p/default --config p/golang --config p/secrets \
--metrics=off --json --output /workspace/.strix-source-aware/semgrep.json .
--metrics=off --json --output /workspace/.source-aware/semgrep.json .
# If you choose auto config, do not combine it with --metrics=off
semgrep scan --config auto --json --output /workspace/.strix-source-aware/semgrep-auto.json .
semgrep scan --config auto --json --output /workspace/.source-aware/semgrep-auto.json .
```
If diff scope is active, restrict to changed files first, then expand only when needed.
@@ -85,8 +85,8 @@ Use `sg` for structure-aware code hunting:
```bash
# Ruleless structural pass over deterministic target list (no sgconfig.yml required)
xargs -r -n 200 sg run --pattern '$F($$$ARGS)' --json=stream \
< /workspace/.strix-source-aware/sg-targets.txt \
> /workspace/.strix-source-aware/ast-grep.json 2> /workspace/.strix-source-aware/ast-grep.log || true
< /workspace/.source-aware/sg-targets.txt \
> /workspace/.source-aware/ast-grep.json 2> /workspace/.source-aware/ast-grep.log || true
```
Target high-value patterns such as:
@@ -110,15 +110,15 @@ Use outputs to improve route/symbol/sink maps for subsequent targeted scans.
Detect hardcoded credentials:
```bash
gitleaks detect --source . --report-format json --report-path /workspace/.strix-source-aware/gitleaks.json
trufflehog filesystem --json . > /workspace/.strix-source-aware/trufflehog.json
gitleaks detect --source . --report-format json --report-path /workspace/.source-aware/gitleaks.json
trufflehog filesystem --json . > /workspace/.source-aware/trufflehog.json
```
Run repository-wide dependency and config checks:
```bash
trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \
--format json --output /workspace/.strix-source-aware/trivy-fs.json . || true
--format json --output /workspace/.source-aware/trivy-fs.json . || true
```
Known-CVE dependency findings are the one exception to the "report only after
@@ -132,9 +132,9 @@ For frontends and Node services, layer these on top of the language-agnostic
passes above:
```bash
retire --path . --outputformat json --outputpath /workspace/.strix-source-aware/retire.json || true
retire --path . --outputformat json --outputpath /workspace/.source-aware/retire.json || true
eslint --no-config-lookup --rule '{"no-eval":2,"no-implied-eval":2}' \
-f json -o /workspace/.strix-source-aware/eslint.json . || true
-f json -o /workspace/.source-aware/eslint.json . || true
```
When you hit a minified bundle, run `js-beautify <file>` for a readable
@@ -202,7 +202,7 @@ Confirm with a version/patch check before firing — these are destructive.
## Tooling
**None of the AD tools below ship in the Strix sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
**None of the AD tools below ship in the sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
```
# Python identity toolkit (impacket = GetUserSPNs/GetNPUsers/secretsdump/ntlmrelayx/getST/addcomputer/rbcd)
@@ -1,9 +1,9 @@
---
name: firebase-firestore
description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues
name: firebase
description: Firebase security testing covering Firestore, Storage rules, Realtime Database, Auth, Functions, and client-side trust issues
---
# Firebase / Firestore
# Firebase
Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.
@@ -30,7 +30,17 @@ Security testing for Firebase applications. Focus on Firestore/Realtime Database
**Endpoints**
- Firestore REST: `https://firestore.googleapis.com/v1/projects/<project>/databases/(default)/documents/<path>`
- Realtime DB: `https://<project>.firebaseio.com/.json`
- Storage REST: `https://storage.googleapis.com/storage/v1/b/<bucket>`
- GCS JSON API: `https://storage.googleapis.com/storage/v1/b/<bucket>`
- Firebase Storage rules API: `https://firebasestorage.googleapis.com/v0/b/<bucket>/o`
Cloud Storage has two front doors with different authorization engines:
| Front door | Authorization engine |
| --- | --- |
| `storage.googleapis.com/<bucket>/<object>` and `/storage/v1/b/<bucket>` | GCS IAM and per-object ACLs |
| `firebasestorage.googleapis.com/v0/b/<bucket>/o` | Firebase Storage Security Rules |
A `403` from a GCS URL does not prove that Firebase Storage rules deny access. Always test both doors.
**Auth**
- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`)
@@ -117,9 +127,43 @@ exists(/databases/(default)/documents/orgs/$(org)/members/$(request.auth.uid))
- Public reads on sensitive buckets/paths
- Signed URLs with long TTL, no content-disposition controls, replayable across tenants
- List operations exposed: `/o?prefix=` enumerates object keys
- Firebase Storage rules allowing unauthenticated or overly broad reads and writes
**Firebase Storage rules checks**
Probe the rules door separately from GCS IAM and ACLs:
1. Unauthenticated list: `GET https://firebasestorage.googleapis.com/v0/b/<bucket>/o?prefix=<known-prefix>`
2. Unauthenticated read of a known object path
3. Unauthenticated write/upload to a uniquely named test object
4. Repeat list, read, and write as an anonymous-auth principal when anonymous sign-in is enabled
5. Repeat the same matrix as a low-privilege authenticated user
Write access is as important as read access and is routinely missed. Record status, response body, and object existence after each attempt; clean up only test objects that the test principal created.
Review rules source when present and flag:
- `allow read, write: if request.time < timestamp.date(...)` — the common console test-mode time gate
- `{allPaths=**}` catch-alls
- `request.auth != null` as the sole authorization gate
- Claim-presence checks such as `request.auth.token.roles.size() > 0` without role or tenant validation
Storage rules use OR-across-matches semantics: a later permissive match can reopen a path that an earlier match denied. Review every matching path, not only the most specific-looking deny.
**Bucket discovery**
- Extract `storageBucket` from `firebase.apps[0].options` and `NEXT_PUBLIC_FIREBASE_*` values in JavaScript bundles and source.
- Check `<project>.appspot.com` and `<project>.firebasestorage.app` bucket conventions.
**ACL and IAM checks are separate**
- Sweep object ACLs for `allUsers` and `allAuthenticatedUsers`, including objects made public by Admin SDK `makePublic()` or writers using `public: true`. Per-object public ACLs persist after Firebase rules are tightened and can remain on older prefixes.
- Check bucket IAM for `allUsers` and `allAuthenticatedUsers`.
- Check whether Uniform Bucket-Level Access is disabled; legacy object ACLs matter when it is off.
- Account for CDN caching of previously public objects; cache-bust when verifying a revocation.
**Tests**
- GET gs:// paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
- GET GCS object paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
- Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants
- Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution
@@ -189,12 +233,19 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
## Testing Methodology
1. **Extract config** - Get project config from client bundle
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin
1. **Extract config** - Get project and storage bucket config from client bundles and source
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, and admin where authorized
3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions
4. **SDK vs REST** - Exercise every action via both to detect parity gaps
5. **Seed IDs** - Start from list/query paths to gather document IDs
6. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
4. **Exercise both Storage doors** - Test Firebase Storage rules endpoints separately from GCS IAM/ACL URLs
5. **SDK vs REST** - Exercise every action via both to detect parity gaps
6. **Seed IDs** - Start from list/query paths to gather document and object paths
7. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
## Whitebox Rules Review
- Inspect `firebase.json`, `.firebaserc`, deployment scripts, CI configuration, and infrastructure code for `storage.rules` / `firestore.rules` declarations.
- If `firebase.json` has no `storage` or `firestore` block, or the referenced rules file is absent from the tree, treat the live rules as unmanaged and force the live probe matrix. Absence of rules IaC is itself a finding; never conclude that there is nothing to review.
- Correlate configured rule files with deployed project and bucket identifiers. A source rule file for a different project does not establish live protection.
## Tooling
@@ -206,6 +257,7 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
## Validation Requirements
- Owner vs non-owner Firestore queries showing unauthorized access or metadata leak
- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure)
- Firebase Storage unauthenticated, anonymous, or low-privilege read/list/write beyond intended scope, with minimal reproducible requests and observed deltas
- GCS object ACL or bucket IAM access beyond intended scope, including public object persistence after rules changes
- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`
- Minimal reproducible requests with roles/tokens used and observed deltas
+35 -2
View File
@@ -58,6 +58,26 @@ agent-browser screenshot
The browser stays running across commands so these feel like a single
session. Use `agent-browser close` (or `close --all`) when you're done.
The default session is **shared with every other agent in the sandbox** — if
another agent navigates it, your page and your refs are gone from under you. So
claim your own by passing `--session <your-agent-name>` on **every** command:
```bash
agent-browser --session recon-3 open https://example.com
agent-browser --session recon-3 snapshot -i
agent-browser --session recon-3 close # when done with the target
```
The examples in the rest of this skill omit `--session` to keep them readable;
keep passing yours. Each session is a separate Chromium (~340 MB) on a shared
box, so hold one rather than several, and close it when you're finished.
A browser left idle for 3 minutes is reclaimed automatically to free memory for
the other agents; the next command relaunches it, but the page, tabs, refs and
cookies are gone. If you're authenticated and about to go do something else for a
while, save the state first (see
[Persist session across runs](#persist-session-across-runs)).
## Reading a page
```bash
@@ -307,6 +327,16 @@ agent-browser --session b fill @e1 "bob@test.com"
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
shell.
Use a session named after yourself for your own work — that's what keeps a
concurrent agent from navigating the page out from under you. Every session is a
separate Chromium though, so hold one at a time rather than a collection, and
close each one when its flow is finished:
```bash
agent-browser --session a close
agent-browser --session b close
```
### Mock network requests
```bash
@@ -368,8 +398,11 @@ agent-browser dialog dismiss # cancel
## Readiness & recovery
The first `agent-browser open` in a session launches the headless-Chrome
daemon; later commands reuse it. Distinguish the two failure modes and react
differently — do **not** blindly re-run the same failing command in a loop:
daemon; later commands reuse it. A daemon left idle for 3 minutes shuts itself
down to free memory for the other agents, so an `open` after a long gap is a
fresh browser rather than a resumed one — expect to re-navigate, and re-`state
load` if you were logged in. Distinguish the failure modes and react differently
— do **not** blindly re-run the same failing command in a loop:
- **Daemon / connection failure** (`Failed to connect`, `connection refused`,
socket missing, `browser not running`): the daemon isn't up or has died. Run
+1 -1
View File
@@ -5,7 +5,7 @@ description: Run Python through exec_command in the SDK sandbox. Use the image-b
# Python In The Sandbox
Use `exec_command` for Python. There is no separate Strix Python executor.
Use `exec_command` for Python. There is no separate Python executor.
Prefer writing reusable scripts to a `.py` file and running them with
`python3 <name>.py`. For short one-off transformations, `python3 -c` or a
@@ -80,7 +80,7 @@ Gadget availability depends on package versions — enumerate `node_modules` in
1. **Identify merge points** — Search for extend/merge/defaults/deep copy on user-controlled objects
2. **Baseline probe** — Inject benign pollution marker:
```json
{"__proto__": {"strixPolluted": "yes"}}
{"__proto__": {"pollutionCanary": "yes"}}
```
Verify via response behavior, error messages, or follow-up request reading shared state
3. **Shape variants** — Test `__proto__`, `constructor.prototype`, nested bracket notation
@@ -121,7 +121,7 @@ Gadget availability depends on package versions — enumerate `node_modules` in
## Pro Tips
1. Always verify pollution with a unique canary key (`strixPolluted_<random>`) before attempting RCE gadgets
1. Always verify pollution with a unique canary key (`pollutionCanary_<random>`) before attempting RCE gadgets
2. In white-box scans, grep for `merge`, `extend`, `defaultsDeep`, `assign` with user input
3. Check both request parsing and response template config merges (second-order)
4. Node gadget chains are version-specific — confirm package version before claiming RCE
+25
View File
@@ -14,6 +14,7 @@ from agents import RunContextWrapper, function_tool
from strix.core.agents import Status, coordinator_from_context
from strix.core.execution import notify_parent_on_terminal
from strix.core.hooks import LLM_TURN_KEY
from strix.skills import validate_requested_skills
@@ -224,6 +225,7 @@ _WAIT_DEFAULT_TIMEOUT_S = 300
# ``timeout_seconds`` the model asks for. One second of headroom lets the
# tool's own timeout fire first and return a clean result.
_WAIT_HARD_CEILING_S = _WAIT_DEFAULT_TIMEOUT_S + 1
_WAITED_TURN_KEY = "waited_llm_turn"
@function_tool(timeout=_WAIT_HARD_CEILING_S)
@@ -239,6 +241,11 @@ async def wait_for_agents( # noqa: PLR0911
completion reports. You resume the instant any message arrives, so
size ``timeout_seconds`` to the work you're awaiting.
**Issue exactly one wait, then stop and react to what it returns.**
This call blocks and resumes on its own; it is not a poll you repeat.
Do not write out a wait/check loop ahead of time a second wait in
the same turn returns immediately without waiting.
**This tool is only for waiting on other agents.** Two things it is
NOT for:
@@ -290,6 +297,24 @@ async def wait_for_agents( # noqa: PLR0911
default=str,
)
turn = inner.get(LLM_TURN_KEY)
if turn is not None and inner.get(_WAITED_TURN_KEY) == turn:
return json.dumps(
{
"success": True,
"wait_outcome": "already_waited",
"reason": reason,
"note": (
"You already waited in this turn. A single wait_for_agents blocks and "
"resumes on its own, so queueing more waits only strands you — issue one "
"wait, then react to what it returns."
),
},
ensure_ascii=False,
default=str,
)
inner[_WAITED_TURN_KEY] = turn
async with coordinator._lock:
stopped = coordinator.statuses.get(me) == "stopped"
if stopped:
+2 -2
View File
@@ -1,7 +1,7 @@
"""Bound oversized tool results before they enter agent history.
Oversized results are spilled into the sandbox at
``/workspace/.strix/tool-output/<id>.txt``; the agent sees a head + tail slice
``/workspace/.tool-output/<id>.txt``; the agent sees a head + tail slice
plus the path and reads the rest back with its own file tools. The spill writer
is injected by the runner via :func:`configure_spill_writer`.
"""
@@ -25,7 +25,7 @@ _WORKSPACE_SPILL_NOTICE = (
"in the sandbox; read it with exec_command (e.g. `sed -n`, `grep`, `cat`) ...]"
)
WORKSPACE_SPILL_DIR = "/workspace/.strix/tool-output"
WORKSPACE_SPILL_DIR = "/workspace/.tool-output"
# Longest possible workspace path, used only to reserve notice bytes.
_SAMPLE_WORKSPACE_PATH = f"{WORKSPACE_SPILL_DIR}/{'0' * 32}.txt"
+5 -1
View File
@@ -189,7 +189,11 @@ def build_raw_request(
final_headers = {**headers}
final_headers.setdefault("Host", parsed.netloc)
final_headers.setdefault("User-Agent", "strix")
final_headers.setdefault(
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
)
# Framing headers inherited from the captured request describe the ORIGINAL
# body; once the body is modified for replay they are stale. We always send a
# plain (non-chunked) body with an explicit Content-Length, so drop any
+127 -3
View File
@@ -719,6 +719,36 @@ def _dependency_severity(advisory_cvss: float | None) -> tuple[float, str]:
return score, "none"
_VALID_REACHABILITY = frozenset(
{
"not_imported",
"imported",
"vulnerable_symbol_used",
"reachable_call_path",
"unknown",
}
)
def _validate_manifest_path(manifest_path: str | None) -> str | None:
"""Return an error message when manifest_path is missing or unsafe."""
path = (manifest_path or "").strip()
if not path:
return (
"manifest_path is required: pass the repo-relative path of the "
"lockfile/manifest where the vulnerable version was observed "
"(trivy's Target, e.g. 'package-lock.json' or "
"'services/api/pom.xml'). It binds the finding to its exact file "
"so remediation can target the right repository."
)
if path.startswith("/") or "\\" in path or path.split("/")[0].endswith(":"):
return f"manifest_path must be a relative path within the repository, got {path!r}"
segments = path.split("/")
if any(segment in ("", ".", "..") for segment in segments):
return f"manifest_path must not contain empty, '.', or '..' segments, got {path!r}"
return None
def _build_dependency_metadata(
*,
package_name: str,
@@ -727,6 +757,9 @@ def _build_dependency_metadata(
fixed_version: str | None,
introduced_by: str | None,
dependency_path: str | None,
manifest_path: str | None = None,
reachability: str | None = None,
reachability_evidence: str | None = None,
) -> dict[str, str]:
metadata = {
"package_name": package_name.strip(),
@@ -734,15 +767,33 @@ def _build_dependency_metadata(
}
if package_ecosystem and package_ecosystem.strip():
metadata["package_ecosystem"] = package_ecosystem.strip()
if manifest_path and manifest_path.strip():
metadata["manifest_path"] = manifest_path.strip()
if fixed_version and fixed_version.strip():
metadata["fixed_version"] = fixed_version.strip()
if introduced_by and introduced_by.strip():
metadata["introduced_by"] = introduced_by.strip()
if dependency_path and dependency_path.strip():
metadata["dependency_path"] = dependency_path.strip()
# "unknown" is the absent case — omitting it keeps the jsonb contract clean,
# and evidence without a level would have nothing to qualify.
if reachability and reachability.strip() and reachability.strip() != "unknown":
metadata["reachability"] = reachability.strip()
if reachability_evidence and reachability_evidence.strip():
metadata["reachability_evidence"] = reachability_evidence.strip()
return metadata
_REACHABILITY_EVIDENCE_LABELS = {
"not_imported": "not imported by application code",
"imported": "imported by application code; affected API usage unconfirmed",
"vulnerable_symbol_used": "the advisory's affected API is used in application code",
"reachable_call_path": (
"a call path from application code to the vulnerable function was proven"
),
}
def _build_dependency_evidence(
*,
cve: str,
@@ -751,6 +802,8 @@ def _build_dependency_evidence(
fixed_version: str | None,
introduced_by: str | None,
dependency_path: str | None,
reachability: str | None = None,
reachability_evidence: str | None = None,
) -> str:
evidence = (
f"**Advisory evidence:** `{cve}` applies to `{package_name}` "
@@ -765,6 +818,15 @@ def _build_dependency_evidence(
)
if dependency_path and dependency_path.strip():
evidence += f"\n\n**Dependency chain:** `{dependency_path.strip()}`"
label = _REACHABILITY_EVIDENCE_LABELS.get((reachability or "").strip().lower())
if label:
evidence += f"\n\n**Usage analysis:** {label}."
if reachability_evidence and reachability_evidence.strip():
evidence += f" {reachability_evidence.strip()}"
evidence += (
" This is a prioritization signal from static analysis, not a"
" proof of exploitability or of safety."
)
return evidence
@@ -787,6 +849,9 @@ async def _do_create_dependency( # noqa: PLR0912
fix_effort: str,
introduced_by: str | None = None,
dependency_path: str | None = None,
manifest_path: str | None = None,
reachability: str = "unknown",
reachability_evidence: str | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
) -> dict[str, Any]:
@@ -823,6 +888,22 @@ async def _do_create_dependency( # noqa: PLR0912
f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}"
)
manifest_err = _validate_manifest_path(manifest_path)
if manifest_err:
errors.append(manifest_err)
reachability = (reachability or "unknown").strip().lower()
if reachability not in _VALID_REACHABILITY:
errors.append(
f"Invalid reachability: {reachability!r}. Must be one of: {sorted(_VALID_REACHABILITY)}"
)
elif reachability != "unknown" and not (reachability_evidence or "").strip():
errors.append(
"reachability_evidence is required when reachability is not 'unknown': "
"cite the concrete proof (import file:line, matched symbol usage, or "
"govulncheck call path). Never claim a reachability level without evidence."
)
if advisory_cvss is None:
errors.append(
"advisory_cvss is required: read the published advisory base score "
@@ -843,6 +924,9 @@ async def _do_create_dependency( # noqa: PLR0912
fixed_version=fixed_version,
introduced_by=introduced_by,
dependency_path=dependency_path,
manifest_path=manifest_path,
reachability=reachability,
reachability_evidence=reachability_evidence,
)
evidence = _build_dependency_evidence(
cve=parsed_cve,
@@ -851,6 +935,8 @@ async def _do_create_dependency( # noqa: PLR0912
fixed_version=fixed_version,
introduced_by=introduced_by,
dependency_path=dependency_path,
reachability=reachability,
reachability_evidence=reachability_evidence,
)
try:
@@ -943,12 +1029,15 @@ async def create_dependency_report(
remediation_steps: str,
assumptions: str,
package_ecosystem: str,
manifest_path: str | None = None,
fixed_version: str | None = None,
cwe: str | None = None,
technical_analysis: str | None = None,
fix_effort: str = "low",
introduced_by: str | None = None,
dependency_path: str | None = None,
reachability: str = "unknown",
reachability_evidence: str | None = None,
) -> str:
"""File a known-CVE dependency (SCA) finding — one report per CVE x package.
@@ -973,9 +1062,26 @@ async def create_dependency_report(
- Re-reporting the same CVE/package already filed.
**Reachability**: do NOT silently downgrade or suppress a finding
because the vulnerable code path may be unreachable instead state
reachability as an ``assumptions`` / confidence factor. Report the
finding; let the reader weigh exploitability.
because the vulnerable code path may be unreachable report it, and
record what the usage analysis showed via the structured
``reachability`` + ``reachability_evidence`` fields (see the
dependency-cve-scanning skill for the analysis procedure). The level
is an evidence ladder, never an exploitability verdict:
- ``not_imported`` the package is never imported/required by
application code (strongest de-prioritization signal; still not
proof of safety dynamic loading, reflection, or framework wiring
can evade static search).
- ``imported`` application code imports the package, but usage of
the advisory's affected API was not confirmed.
- ``vulnerable_symbol_used`` the advisory's affected
function/class/API appears in application code.
- ``reachable_call_path`` a call-graph tool (e.g. ``govulncheck``)
proved a path from application code to the vulnerable function.
- ``unknown`` usage analysis was not performed or was inconclusive.
Severity is still derived solely from ``advisory_cvss`` the
reachability level never changes the rating, only prioritization.
**Formatting**: use markdown in text fields (``**bold**``, ``inline
code`` for package/version identifiers, fenced code blocks for
@@ -1010,6 +1116,21 @@ async def create_dependency_report(
to the vulnerable package, joined with `` > `` (e.g.
``express@4.18.1 > body-parser@1.20.0 > qs@6.10.2``). Omit
for direct dependencies.
manifest_path: **Required.** The repo-relative path of the
lockfile/manifest where the vulnerable version was observed
trivy's ``Target`` (e.g. ``package-lock.json``,
``services/api/pom.xml``). Strip any scan-workspace or repo
checkout directory prefix so the path is relative to the
repository root. This binds the finding to its exact file so
remediation can target the right repository.
reachability: Usage-evidence level from static analysis one of
``not_imported`` / ``imported`` / ``vulnerable_symbol_used`` /
``reachable_call_path`` / ``unknown``. Claim only what the
evidence proves; when in doubt use ``unknown``.
reachability_evidence: The concrete proof for the claimed level
(required for any level other than ``unknown``): repo-relative
``file:line`` of the import or symbol usage, the matched
advisory symbols, or the govulncheck call-path excerpt.
"""
agent_id, agent_name = _caller_identity(ctx)
@@ -1031,6 +1152,9 @@ async def create_dependency_report(
fix_effort=fix_effort,
introduced_by=introduced_by,
dependency_path=dependency_path,
manifest_path=manifest_path,
reachability=reachability,
reachability_evidence=reachability_evidence,
agent_id=agent_id,
agent_name=agent_name,
)
+5 -1
View File
@@ -15,7 +15,7 @@ def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
@function_tool
async def respond_to_user(ctx: RunContextWrapper, message: str) -> str:
async def respond_to_user(ctx: RunContextWrapper, message: str = "") -> str:
"""Answer the user and hand control back to them.
This is the ONLY way to yield to the user. Delivering the message and
@@ -45,6 +45,10 @@ async def respond_to_user(ctx: RunContextWrapper, message: str) -> str:
have followed the tool calls that led here. Lead with the
answer or the decision you need, and if you are blocked, say
exactly what you need from them.
Omit it when you have just said your piece as plain text and
only need to wait: that text has already reached them, and
repeating it makes them read the same answer twice.
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
+27 -6
View File
@@ -110,12 +110,19 @@ def _get_agent_todos(agent_id: str) -> dict[str, dict[str, Any]]:
def _normalize_priority(priority: str | None, default: str = "normal") -> str:
candidate = (priority or default or "normal").lower()
candidate = str(priority or default or "normal").strip().lower()
if candidate not in VALID_PRIORITIES:
raise ValueError(f"Invalid priority. Must be one of: {', '.join(VALID_PRIORITIES)}")
return candidate
def _coerce_priority(priority: str | None, default: str = "normal") -> str:
try:
return _normalize_priority(priority, default)
except ValueError:
return default
def _sorted_todos(agent_id: str) -> list[dict[str, Any]]:
todos_list = [
{**todo, "todo_id": todo_id} for todo_id, todo in _get_agent_todos(agent_id).items()
@@ -285,11 +292,16 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
- ``description`` (str, optional): extra context or
acceptance criteria.
- ``priority`` (str, optional): one of ``"low"`` /
``"normal"`` / ``"high"`` / ``"critical"``. Defaults to
``"normal"``.
``"normal"`` / ``"high"`` / ``"critical"``. Anything else,
including omitting it, falls back to ``"normal"`` rather
than failing.
Example: ``[{"title": "Probe /admin", "priority": "high"},
{"title": "Check JWT alg=none"}]``.
A title already on the list, or repeated within this call, is
skipped rather than duplicated; skipped titles come back under
``skipped``.
"""
agent_id = _agent_id_from(ctx)
try:
@@ -302,13 +314,21 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
)
agent_todos = _get_agent_todos(agent_id)
seen = {todo["title"].strip().lower() for todo in agent_todos.values()}
created: list[dict[str, Any]] = []
skipped: list[dict[str, str]] = []
for task in tasks:
task_priority = _normalize_priority(task.get("priority"))
title = task["title"]
key = title.lower()
if key in seen:
skipped.append({"title": title, "reason": "duplicate title"})
continue
seen.add(key)
task_priority = _coerce_priority(task.get("priority"))
todo_id = str(uuid.uuid4())[:6]
timestamp = datetime.now(UTC).isoformat()
agent_todos[todo_id] = {
"title": task["title"],
"title": title,
"description": task.get("description"),
"priority": task_priority,
"status": "pending",
@@ -316,7 +336,7 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
"updated_at": timestamp,
"completed_at": None,
}
created.append({"todo_id": todo_id, "title": task["title"], "priority": task_priority})
created.append({"todo_id": todo_id, "title": title, "priority": task_priority})
except (ValueError, TypeError) as e:
return json.dumps(
{"success": False, "error": f"Failed to create todo: {e}"},
@@ -330,6 +350,7 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
"success": True,
"created": created,
"created_count": len(created),
"skipped": skipped,
"todos": _sorted_todos(agent_id),
"total_count": len(_get_agent_todos(agent_id)),
},
+23 -1
View File
@@ -70,7 +70,6 @@ async def test_encoded_list_is_decoded_for_an_array_parameter(schema: dict[str,
"auth",
"Endpoint /admin leaks user data, and session tokens never expire",
'"auth"',
"",
],
)
async def test_free_form_strings_are_never_split_into_an_array(value: str) -> None:
@@ -79,6 +78,29 @@ async def test_free_form_strings_are_never_split_into_an_array(value: str) -> No
assert parsed["tags"] == value
@pytest.mark.asyncio
@pytest.mark.parametrize("schema", [_ARRAY, _NULLABLE_ARRAY])
@pytest.mark.parametrize("value", ["", " "])
async def test_empty_string_becomes_an_empty_array(schema: dict[str, Any], value: str) -> None:
parsed = await _roundtrip(schema, {"tags": value})
assert parsed["tags"] == []
@pytest.mark.asyncio
async def test_empty_string_becomes_an_empty_object() -> None:
parsed = await _roundtrip(_OBJECT, {"modifications": ""})
assert parsed["modifications"] == {}
@pytest.mark.asyncio
async def test_empty_string_for_a_string_parameter_is_untouched() -> None:
parsed = await _roundtrip(_STRING, {"todos": ""})
assert parsed["todos"] == ""
@pytest.mark.asyncio
async def test_encoded_mapping_is_decoded_for_an_object_parameter() -> None:
parsed = await _roundtrip(_OBJECT, {"modifications": '{"method": "POST"}'})
+52 -16
View File
@@ -6,23 +6,34 @@ from typing import TYPE_CHECKING, Any
import pytest
from strix.config import codex
from strix.config import codex, grok
from strix.interface import auth_cli
if TYPE_CHECKING:
from pathlib import Path
_CHATGPT = auth_cli._PROVIDERS["chatgpt"]
@pytest.fixture(autouse=True)
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(codex, "AUTH_PATH", tmp_path / "home" / ".strix" / "subscription-auth.json")
store = tmp_path / "home" / ".strix" / "subscription-auth.json"
monkeypatch.setattr(codex, "AUTH_PATH", store)
monkeypatch.setattr(grok, "AUTH_PATH", store)
def test_login_provider_is_chatgpt() -> None:
assert auth_cli.LOGIN_PROVIDER == "chatgpt"
assert codex.PROVIDER in auth_cli._ACCEPTED_PROVIDERS
assert "chatgpt" in auth_cli._ACCEPTED_PROVIDERS
def test_default_provider_is_chatgpt() -> None:
assert auth_cli._DEFAULT_PROVIDER == "chatgpt"
assert set(auth_cli._PROVIDERS) == {"chatgpt", "grok"}
def test_provider_aliases_resolve() -> None:
assert auth_cli._resolve_provider(codex.PROVIDER) is _CHATGPT
assert auth_cli._resolve_provider("ChatGPT") is _CHATGPT
assert auth_cli._resolve_provider("grok") is auth_cli._PROVIDERS["grok"]
assert auth_cli._resolve_provider("xai") is auth_cli._PROVIDERS["grok"]
assert auth_cli._resolve_provider("gemini") is None
def test_unknown_subcommand_returns_usage_error() -> None:
@@ -51,32 +62,32 @@ def test_finish_requires_state_on_loopback(monkeypatch: pytest.MonkeyPatch) -> N
# Loopback (require_state=True): missing or mismatched state is rejected.
with pytest.raises(codex.CodexAuthError) as missing:
auth_cli._finish("code", None, "verifier", "expected", require_state=True)
auth_cli._finish(_CHATGPT, "code", None, "verifier", "expected", require_state=True)
assert missing.value.code == "state_mismatch"
with pytest.raises(codex.CodexAuthError) as mismatch:
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=True)
auth_cli._finish(_CHATGPT, "code", "wrong", "verifier", "expected", require_state=True)
assert mismatch.value.code == "state_mismatch"
# Matching state proceeds to the exchange.
assert auth_cli._finish("code", "expected", "verifier", "expected", require_state=True) == {
"ok": True
}
assert auth_cli._finish(
_CHATGPT, "code", "expected", "verifier", "expected", require_state=True
) == {"ok": True}
def test_finish_manual_paste_allows_absent_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
# Manual paste (require_state=False): a bare code with no state is accepted,
# but a present-and-wrong state is still rejected.
assert auth_cli._finish("code", None, "verifier", "expected", require_state=False) == {
"ok": True
}
assert auth_cli._finish(
_CHATGPT, "code", None, "verifier", "expected", require_state=False
) == {"ok": True}
with pytest.raises(codex.CodexAuthError):
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=False)
auth_cli._finish(_CHATGPT, "code", "wrong", "verifier", "expected", require_state=False)
def test_finish_rejects_missing_code() -> None:
with pytest.raises(codex.CodexAuthError) as exc:
auth_cli._finish(None, "expected", "verifier", "expected", require_state=True)
auth_cli._finish(_CHATGPT, None, "expected", "verifier", "expected", require_state=True)
assert exc.value.code == "no_code"
@@ -84,6 +95,31 @@ def test_model_subcommand_removed() -> None:
assert auth_cli.run_auth(["model", "gpt-5.5"]) == 2
def _sign_in_both() -> None:
codex.save_record({"type": "oauth", "access": "c", "refresh": "r", "account_id": "a"})
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
def test_logout_all_removes_every_provider() -> None:
_sign_in_both()
assert codex.is_authenticated()
assert grok.is_authenticated()
assert auth_cli.run_auth(["logout"]) == 0
assert not codex.is_authenticated()
assert not grok.is_authenticated()
def test_logout_single_provider_leaves_the_other() -> None:
_sign_in_both()
assert auth_cli.run_auth(["logout", "grok"]) == 0
assert codex.is_authenticated()
assert not grok.is_authenticated()
@pytest.mark.parametrize("provider", ["chatgpt", "codex", "ChatGPT"])
def test_login_accepts_provider_aliases(provider: str, monkeypatch: pytest.MonkeyPatch) -> None:
reached = {"flow": False}
+11
View File
@@ -39,6 +39,17 @@ def test_context_window_chatgpt_prefix_skips_provider_auth(
context_budget._model_info.cache_clear()
def test_context_window_grok_prefix_resolves_to_xai() -> None:
# LiteLLM maps xAI models only provider-qualified: neither "grok/grok-4" nor
# bare "grok-4" resolves, so the subscription prefix becomes "xai/".
context_budget._model_info.cache_clear()
try:
assert context_budget.context_window("grok/grok-4") == 256_000
assert context_budget.output_limit("grok/grok-4") == 256_000
finally:
context_budget._model_info.cache_clear()
def test_context_window_unmapped_uses_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
context_budget._model_info.cache_clear()
+1 -1
View File
@@ -143,7 +143,7 @@ def test_cost_callback_estimates_cost_with_bare_model_fallback() -> None:
}
def fake_completion_cost(**kwargs: object) -> float:
if kwargs["model"] == "gpt-4o-mini":
if kwargs["model"] == "openai/gpt-4o-mini":
return 0.025
raise ValueError(kwargs["model"])
+37
View File
@@ -1228,3 +1228,40 @@ async def test_wait_kind_survives_a_snapshot_round_trip() -> None:
assert restored.wait_kinds["root"] == "user"
assert restored.idle_resume_counts["root"] == 1
assert await execution._plain_waiting_timeout(restored, "root") is None
@pytest.mark.asyncio
async def test_interactive_nudge_offers_waiting_without_repeating() -> None:
"""The nudge is the instruction an agent reads when it is stranded here.
It is where the option to wait on what was already said has to be, not only
in the system prompt: an agent that ended a turn on plain text reasons off
this text, and without the clause it restates its answer to reach a tool
call, so the user reads it twice.
The clause holds whatever the turn did, because the agent is the one who
knows whether it spoke this fires for a turn that produced no text at all.
"""
items = await execution._append_tool_required_message(
session=None,
context={"parent_id": None},
attempt=1,
limit=3,
interactive=True,
)
assert "with no message if you have already said it" in items[0]["content"]
@pytest.mark.asyncio
async def test_autonomous_nudge_does_not_offer_the_user() -> None:
"""There is nobody attached to an autonomous run to wait for."""
items = await execution._append_tool_required_message(
session=None,
context={"parent_id": None},
attempt=1,
limit=3,
interactive=False,
)
assert "respond_to_user" not in items[0]["content"]
+267
View File
@@ -0,0 +1,267 @@
"""Tests for Grok (xAI) subscription auth: PKCE, token handling, store."""
from __future__ import annotations
import base64
import hashlib
import json
import time
from typing import TYPE_CHECKING, Any
from unittest import mock
import pytest
import requests
from strix.config import grok
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "home" / ".strix" / "subscription-auth.json"
monkeypatch.setattr(grok, "AUTH_PATH", path)
return path
def test_pkce_challenge_matches_verifier_and_is_unpadded() -> None:
verifier, challenge = grok.generate_pkce()
expected = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
)
assert challenge == expected
assert "=" not in verifier
assert "=" not in challenge
def test_authorize_url_carries_pkce_client_and_grok_scope() -> None:
url = grok.build_authorize_url("chal", "st8")
assert grok.AUTHORIZE_URL in url
assert "code_challenge=chal" in url
assert "code_challenge_method=S256" in url
assert f"client_id={grok.CLIENT_ID}" in url
assert "state=st8" in url
# The Grok-CLI scope is what unlocks subscription inference.
assert "grok-cli%3Aaccess" in url
assert "offline_access" in url
def test_redirect_uri_is_loopback() -> None:
assert grok.REDIRECT_URI == "http://127.0.0.1:56121/callback"
def test_post_form_returns_parsed_body() -> None:
resp = mock.MagicMock()
resp.status_code = 200
resp.content = b'{"access_token": "tok"}'
resp.__enter__.return_value = resp
with mock.patch.object(requests, "post", return_value=resp) as post:
data = grok._post_form({"grant_type": "refresh_token"})
assert data == {"access_token": "tok"}
assert post.call_args.kwargs["timeout"] == grok._TOKEN_TIMEOUT
def test_post_form_raises_on_http_error() -> None:
resp = mock.MagicMock()
resp.status_code = 400
resp.text = "invalid_grant"
resp.__enter__.return_value = resp
with (
mock.patch.object(requests, "post", return_value=resp),
pytest.raises(grok.GrokAuthError) as exc,
):
grok._post_form({"grant_type": "refresh_token"})
assert exc.value.code == "token_http_error"
@pytest.mark.parametrize(
("value", "expected"),
[
("http://127.0.0.1:56121/callback?code=AAA&state=BBB", ("AAA", "BBB")),
("AAA#BBB", ("AAA", "BBB")),
("code=AAA&state=BBB", ("AAA", "BBB")),
("AAA", ("AAA", None)),
("", (None, None)),
],
)
def test_parse_redirect_input(value: str, expected: tuple[str | None, str | None]) -> None:
assert grok.parse_redirect_input(value) == expected
@pytest.mark.parametrize(
("model", "expected"),
[
("grok/grok-4", "grok-4"),
("Grok/Grok-4", "Grok-4"),
(" grok/grok-4 ", "grok-4"),
("xai/grok-4", None), # metered API path
("chatgpt/gpt-5.4", None),
("grok-4", None),
("grok/", None),
("", None),
(None, None),
],
)
def test_subscription_model(model: str | None, expected: str | None) -> None:
assert grok.subscription_model(model) == expected
def test_auth_mode() -> None:
assert grok.auth_mode("grok/grok-4") == "subscription"
assert grok.auth_mode("xai/grok-4") == "api_key"
assert grok.auth_mode("chatgpt/gpt-5.4") == "api_key"
assert grok.auth_mode(None) == "api_key"
def _record(access: str, refresh: str, expires_at: float) -> dict[str, Any]:
return {
"type": "oauth",
"provider": "grok",
"access": access,
"refresh": refresh,
"expires_at": expires_at,
}
def test_store_roundtrip_and_logout() -> None:
assert grok.read_record() is None
assert grok.is_authenticated() is False
grok.save_record(_record("a1", "r1", time.time() + 3600))
record = grok.read_record()
assert record is not None
assert record["access"] == "a1"
assert grok.is_authenticated() is True
grok.logout()
assert grok.read_record() is None
grok.logout() # no-op when already gone
def test_store_file_permissions_are_owner_only(_tmp_store: Path) -> None:
grok.save_record(_record("a1", "r1", time.time() + 3600))
assert (_tmp_store.stat().st_mode & 0o777) == 0o600
def test_store_shares_file_with_other_providers(_tmp_store: Path) -> None:
# Grok must not clobber a co-resident ChatGPT record in the shared store.
_tmp_store.parent.mkdir(parents=True, exist_ok=True)
_tmp_store.write_text(json.dumps({"codex": {"type": "oauth", "access": "x"}}))
grok.save_record(_record("a1", "r1", time.time() + 3600))
on_disk = json.loads(_tmp_store.read_text())
assert on_disk["codex"] == {"type": "oauth", "access": "x"}
assert on_disk["grok"]["access"] == "a1"
grok.logout()
# Removing grok leaves the other provider's record and the file intact.
assert json.loads(_tmp_store.read_text()) == {"codex": {"type": "oauth", "access": "x"}}
def test_read_record_rejects_incomplete_records() -> None:
grok.save_record({"type": "oauth", "access": "a"}) # missing refresh
assert grok.read_record() is None
assert grok.is_authenticated() is False
def test_get_valid_token_returns_stored_when_fresh(monkeypatch: pytest.MonkeyPatch) -> None:
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
msg = "should not refresh a fresh token"
raise AssertionError(msg)
monkeypatch.setattr(grok, "_post_form", _boom)
grok.save_record(_record("access-fresh", "r1", time.time() + 3600))
assert grok.get_valid_token() == "access-fresh"
def test_get_valid_token_refreshes_and_persists_rotation(monkeypatch: pytest.MonkeyPatch) -> None:
calls = {"n": 0}
def _fake_post(payload: dict[str, str]) -> dict[str, Any]:
calls["n"] += 1
assert payload["grant_type"] == "refresh_token"
assert payload["refresh_token"] == "r1"
return {"access_token": "access-new", "refresh_token": "r2", "expires_in": 3600}
monkeypatch.setattr(grok, "_post_form", _fake_post)
grok.save_record(_record("stale", "r1", time.time() - 10)) # already expired
assert grok.get_valid_token() == "access-new"
assert calls["n"] == 1
record = grok.read_record()
assert record is not None
assert record["refresh"] == "r2" # rotated refresh written back
def test_refresh_keeps_old_refresh_when_response_omits_it(monkeypatch: pytest.MonkeyPatch) -> None:
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
return {"access_token": "access-new", "expires_in": 3600} # no refresh_token
monkeypatch.setattr(grok, "_post_form", _fake_post)
grok.save_record(_record("stale", "r1", time.time() - 10))
assert grok.get_valid_token() == "access-new"
record = grok.read_record()
assert record is not None
assert record["refresh"] == "r1" # fell back to the prior refresh token
def test_get_valid_token_uses_token_rotated_by_another_process(
monkeypatch: pytest.MonkeyPatch,
) -> None:
records = [
_record("stale", "r1", time.time() - 10),
_record("fresh-from-other-process", "r2", time.time() + 3600),
]
calls = {"n": 0}
def _fake_read() -> dict[str, Any]:
record = records[min(calls["n"], len(records) - 1)]
calls["n"] += 1
return record
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
msg = "must not refresh a token another process already rotated"
raise AssertionError(msg)
monkeypatch.setattr(grok, "read_record", _fake_read)
monkeypatch.setattr(grok, "_post_form", _boom)
assert grok.get_valid_token() == "fresh-from-other-process"
def test_get_valid_token_recovers_when_refresh_loses_race(
monkeypatch: pytest.MonkeyPatch,
) -> None:
grok.save_record(_record("stale", "r1", time.time() - 10))
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
grok.save_record(_record("fresh-from-peer", "r2", time.time() + 3600))
raise grok.GrokAuthError("token_http_error", "HTTP 400: invalid_grant")
monkeypatch.setattr(grok, "_post_form", _fake_post)
assert grok.get_valid_token() == "fresh-from-peer"
def test_get_valid_token_reraises_refresh_error_without_rotation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
grok.save_record(_record("stale", "r1", time.time() - 10))
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
raise grok.GrokAuthError("token_http_error", "HTTP 400: invalid_grant")
monkeypatch.setattr(grok, "_post_form", _fake_post)
with pytest.raises(grok.GrokAuthError):
grok.get_valid_token()
def test_get_valid_token_raises_when_not_signed_in() -> None:
with pytest.raises(grok.GrokAuthError) as exc:
grok.get_valid_token()
assert exc.value.code == "not_authenticated"
+113
View File
@@ -0,0 +1,113 @@
"""Grok subscription routing through StrixProvider.get_model."""
from __future__ import annotations
import argparse
from unittest import mock
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from strix.config import grok, subscription
from strix.config.models import StrixProvider, _TurnGuardModel
from strix.interface import scan_setup, utils
from strix.report import state as state_mod
def test_grok_prefix_routes_to_chat_completions(monkeypatch) -> None: # type: ignore[no-untyped-def]
client = mock.MagicMock()
monkeypatch.setattr(grok, "get_subscription_client", lambda: client)
model = StrixProvider().get_model("grok/grok-4")
assert isinstance(model, _TurnGuardModel)
assert isinstance(model._inner, OpenAIChatCompletionsModel)
# The provider strips the grok/ prefix and passes xAI's bare model slug.
assert model._inner.model == "grok-4"
def test_non_subscription_model_is_not_hijacked_by_grok(monkeypatch) -> None: # type: ignore[no-untyped-def]
def _boom() -> object:
msg = "grok client must not be built for a non-grok model"
raise AssertionError(msg)
monkeypatch.setattr(grok, "get_subscription_client", _boom)
# A metered xai/* key model must fall through to the normal provider path,
# not the subscription route.
model = StrixProvider().get_model("xai/grok-4")
assert isinstance(model, _TurnGuardModel)
assert not isinstance(model._inner, OpenAIChatCompletionsModel)
def test_provider_label_names_the_subscription() -> None:
assert subscription.provider_label("grok/grok-4") == "Grok"
assert subscription.provider_label("chatgpt/gpt-5.4") == "ChatGPT"
# Metered API-key models are not subscriptions.
assert subscription.provider_label("xai/grok-4") is None
assert subscription.provider_label("openai/gpt-5.4") is None
def test_litellm_model_name_maps_subscription_prefixes() -> None:
# Model metadata (context window, output cap) is keyed "xai/…" for Grok and
# bare for ChatGPT; the routing prefixes themselves are never LiteLLM keys.
assert subscription.litellm_model_name("grok/grok-4") == "xai/grok-4"
assert subscription.litellm_model_name("chatgpt/gpt-5.4") == "gpt-5.4"
# Non-subscription models pass through untouched.
assert subscription.litellm_model_name("xai/grok-4") == "xai/grok-4"
assert subscription.litellm_model_name("openai/gpt-5.4") == "openai/gpt-5.4"
assert subscription.litellm_model_name(None) is None
def test_run_record_reports_grok_provider(monkeypatch) -> None: # type: ignore[no-untyped-def]
settings = mock.MagicMock()
settings.llm.model = "grok/grok-4"
monkeypatch.setattr(state_mod, "load_settings", lambda: settings)
record = state_mod.ReportState(run_name="run-test").run_record
assert record["auth_mode"] == "subscription"
assert record["subscription_provider"] == "Grok"
def test_subscription_label_prefers_persisted_provider(monkeypatch) -> None: # type: ignore[no-untyped-def]
settings = mock.MagicMock()
settings.llm.model = "chatgpt/gpt-5.4" # current settings point at ChatGPT
monkeypatch.setattr(utils, "load_settings", lambda: settings)
# A resumed Grok run keeps its persisted provider even though settings changed.
resumed = mock.MagicMock(
run_record={"auth_mode": "subscription", "subscription_provider": "Grok"}
)
assert utils.subscription_label(resumed) == "Grok subscription"
# With no persisted provider, it derives the label from settings (not a
# hardcoded default).
fresh = mock.MagicMock(run_record={})
assert utils.subscription_label(fresh) == "ChatGPT subscription"
def test_persisted_run_record_carries_provider(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
settings = mock.MagicMock()
settings.llm.model = "grok/grok-4"
monkeypatch.setattr(scan_setup, "load_settings", lambda: settings)
monkeypatch.setattr(scan_setup, "run_dir_for", lambda _name: tmp_path)
captured: dict[str, object] = {}
monkeypatch.setattr(
"strix.report.writer.write_run_record", lambda _dir, rec: captured.update(rec)
)
args = argparse.Namespace(
run_name="run-test",
targets_info=[],
scan_mode="scan",
instruction=None,
non_interactive=True,
local_sources=[],
diff_scope={"active": False},
scope_mode="mode",
diff_base=None,
)
scan_setup._persist_run_record(args)
# The resume/viewer record must carry the provider so resumed runs stay labeled.
assert captured["auth_mode"] == "subscription"
assert captured["subscription_provider"] == "Grok"
+39
View File
@@ -299,6 +299,16 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() ->
assert settings.tool_choice == "required"
def test_make_model_settings_disables_parallel_tool_calls_by_default() -> None:
assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is False
def test_make_model_settings_omits_parallel_tool_calls_without_tools() -> None:
settings = make_model_settings("none", model_name="gpt-4o", has_tools=False)
assert settings.parallel_tool_calls is None
def test_make_model_settings_sets_request_timeout() -> None:
settings = make_model_settings(
"none",
@@ -351,3 +361,32 @@ def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
assert settings.extra_args is not None
assert settings.extra_args["timeout"] == 120.0
def test_openrouter_attribution_rides_on_the_request_headers() -> None:
# litellm.headers is ignored once a request carries any header of its own,
# so the attribution must be part of the per-request headers.
headers = make_model_settings(
None, model_name="openrouter/anthropic/claude-sonnet-4-5"
).extra_headers
assert headers == {
"HTTP-Referer": "https://strix.ai",
"X-Title": "Strix",
"X-OpenRouter-Categories": "cli-agent",
}
def test_openrouter_attribution_absent_for_other_providers() -> None:
assert make_model_settings(None, model_name="anthropic/claude-sonnet-4-5").extra_headers is None
def test_user_headers_override_openrouter_attribution() -> None:
headers = make_model_settings(
None,
model_name="openrouter/anthropic/claude-sonnet-4-5",
extra_headers={"X-Title": "Custom", "X-Tenant": "acme"},
).extra_headers
assert headers is not None
assert headers["X-Title"] == "Custom"
assert headers["X-Tenant"] == "acme"
assert headers["HTTP-Referer"] == "https://strix.ai"
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from unittest.mock import patch
import litellm
from agents.usage import Usage
from strix.report.pricing import resolve_litellm_model
from strix.report.usage import LLMUsageLedger
def test_resolves_common_bare_model_names() -> None:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
assert resolve_litellm_model("openai/deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
assert resolve_litellm_model("grok-4.5") == "xai/grok-4.5"
assert resolve_litellm_model("MiniMax-M3") == "minimax/MiniMax-M3"
def test_resolver_returns_none_for_unresolvable_model() -> None:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("provider/not-a-real-model") is None
def test_ledger_uses_estimate_when_routed_provider_reports_no_cost() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
with patch("litellm.completion_cost", return_value=0.42):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
assert ledger.total_cost == 0.42
def test_ledger_prefers_observed_cost_over_estimate() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
with patch("litellm.completion_cost", return_value=0.42):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
ledger.record_observed_cost(0.17)
assert ledger.total_cost == 0.17
def test_hydrated_estimate_continues_accumulating_new_estimates() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
ledger.hydrate({"cost": 0.42})
with patch("litellm.completion_cost", return_value=0.17):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
assert ledger.total_cost == 0.59
def test_zero_cost_disables_both_observed_and_estimated_costs() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
ledger.zero_cost = True
with patch("litellm.completion_cost", return_value=0.42) as estimate:
ledger.record(agent_id="a", usage=usage, model="deepseek-v4-flash")
ledger.record_observed_cost(1.0)
estimate.assert_not_called()
assert ledger.total_cost == 0.0
def test_resolver_uses_provider_when_bare_entry_has_one() -> None:
original = litellm.model_cost
litellm.model_cost = {
"example": {
"litellm_provider": "example-provider",
"input_cost_per_token": 1.0,
"output_cost_per_token": 2.0,
}
}
try:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("example") == "example-provider/example"
finally:
litellm.model_cost = original
resolve_litellm_model.cache_clear()
def test_resolver_does_not_guess_between_differently_priced_providers() -> None:
original = litellm.model_cost
litellm.model_cost = {
"provider-a/example": {
"input_cost_per_token": 1.0,
"output_cost_per_token": 2.0,
},
"provider-b/example": {
"input_cost_per_token": 3.0,
"output_cost_per_token": 4.0,
},
}
try:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("example") is None
finally:
litellm.model_cost = original
resolve_litellm_model.cache_clear()
+252
View File
@@ -141,6 +141,7 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta
remediation_steps="Upgrade to 4.17.21.",
assumptions="Assumes the template sink is reachable.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="4.17.21",
cwe="CWE-94",
advisory_cvss=7.2,
@@ -160,6 +161,7 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta
"package_name": "lodash",
"installed_version": "4.17.20",
"package_ecosystem": "npm",
"manifest_path": "package-lock.json",
"fixed_version": "4.17.21",
}
@@ -176,6 +178,7 @@ async def test_dependency_report_records_transitive_chain(report_state: ReportSt
remediation_steps="Upgrade express to 4.18.2, which resolves qs 6.11.0.",
assumptions="qs parses all incoming query strings by default.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="6.10.3",
cwe="CWE-1321",
advisory_cvss=7.5,
@@ -213,6 +216,7 @@ async def test_dependency_report_omits_blank_chain_fields(report_state: ReportSt
remediation_steps="Upgrade.",
assumptions="Assumptions.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version=None,
cwe=None,
advisory_cvss=5.0,
@@ -241,6 +245,7 @@ async def test_dependency_report_with_zero_cvss_remains_low_severity(
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=0.0,
@@ -255,6 +260,124 @@ async def test_dependency_report_with_zero_cvss_remains_low_severity(
assert report["cvss"] == 0.0
async def test_dependency_report_records_reachability(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2021-23337 in lodash 4.17.20",
description="Command injection via template.",
target="repo/package.json",
cve="CVE-2021-23337",
package_name="lodash",
installed_version="4.17.20",
impact="Command injection where template is used.",
remediation_steps="Upgrade to 4.17.21.",
assumptions="Assumes the template sink is reachable.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="4.17.21",
cwe=None,
advisory_cvss=7.2,
technical_analysis=None,
fix_effort="low",
reachability="vulnerable_symbol_used",
reachability_evidence="src/render.ts:14 calls `_.template()`.",
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
assert report["dependency_metadata"]["reachability"] == "vulnerable_symbol_used"
assert (
report["dependency_metadata"]["reachability_evidence"]
== "src/render.ts:14 calls `_.template()`."
)
assert "**Usage analysis:**" in report["evidence"]
assert "not a proof of exploitability or of safety" in report["evidence"]
# The level must never influence the rating — that stays advisory_cvss only.
assert report["severity"] == "high"
async def test_dependency_report_rejects_reachability_without_evidence(
report_state: ReportState,
) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Impact.",
remediation_steps="Upgrade.",
assumptions="Assumptions.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=5.0,
technical_analysis=None,
fix_effort="low",
reachability="not_imported",
)
assert result["success"] is False
assert any("reachability_evidence is required" in e for e in result["errors"])
assert not report_state.vulnerability_reports
async def test_dependency_report_rejects_unknown_reachability_level(
report_state: ReportState,
) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Impact.",
remediation_steps="Upgrade.",
assumptions="Assumptions.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=5.0,
technical_analysis=None,
fix_effort="low",
reachability="not_exploitable",
reachability_evidence="vibes",
)
assert result["success"] is False
assert any("Invalid reachability" in e for e in result["errors"])
assert not report_state.vulnerability_reports
async def test_dependency_report_omits_unknown_reachability(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Impact.",
remediation_steps="Upgrade.",
assumptions="Analysis was inconclusive.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=5.0,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is True
metadata = report_state.vulnerability_reports[0]["dependency_metadata"]
assert "reachability" not in metadata
assert "reachability_evidence" not in metadata
async def test_dependency_report_requires_advisory_cvss(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
@@ -267,6 +390,7 @@ async def test_dependency_report_requires_advisory_cvss(report_state: ReportStat
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package ships in deployed builds.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=None,
@@ -322,6 +446,7 @@ async def test_dependency_report_dedupe_candidate_includes_dependency_metadata(
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=0.0,
@@ -339,6 +464,7 @@ async def test_dependency_report_dedupe_candidate_includes_dependency_metadata(
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
"manifest_path": "package-lock.json",
"fixed_version": "1.0.1",
},
"technical_analysis": None,
@@ -357,6 +483,7 @@ async def test_dependency_report_rejects_bad_cve(report_state: ReportState) -> N
remediation_steps="r",
assumptions="a",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version=None,
cwe=None,
advisory_cvss=None,
@@ -379,6 +506,7 @@ async def test_dependency_report_requires_ecosystem(report_state: ReportState) -
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="",
manifest_path="package-lock.json",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=0.0,
@@ -391,6 +519,62 @@ async def test_dependency_report_requires_ecosystem(report_state: ReportState) -
assert not report_state.vulnerability_reports
async def test_dependency_report_requires_manifest_path(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Low-impact dependency advisory.",
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="npm",
manifest_path=None,
fixed_version="1.0.1",
cwe=None,
advisory_cvss=5.0,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is False
assert any("manifest_path is required" in error for error in result["errors"])
assert not report_state.vulnerability_reports
@pytest.mark.parametrize(
"bad_path",
["/etc/passwd", "..\\pom.xml", "services/../pom.xml", "./package.json", "C:/repo/pom.xml"],
)
async def test_dependency_report_rejects_unsafe_manifest_path(
report_state: ReportState, bad_path: str
) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Low-impact dependency advisory.",
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="npm",
manifest_path=bad_path,
fixed_version="1.0.1",
cwe=None,
advisory_cvss=5.0,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is False
assert any("manifest_path" in error for error in result["errors"])
assert not report_state.vulnerability_reports
def test_dedupe_comparison_preserves_cve_identity() -> None:
cleaned = _prepare_report_for_comparison(
{
@@ -469,6 +653,72 @@ async def test_dependency_dedupe_rejects_same_cve_package_identity() -> None:
assert result["confidence"] == 1.0
async def test_dependency_dedupe_keeps_findings_from_distinct_manifests() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in sample",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
"manifest_path": "services/api/package-lock.json",
},
}
]
candidate = {
"title": "CVE-2024-0001 in sample (web)",
"description": "Same advisory observed in a second workspace.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
"manifest_path": "services/web/package-lock.json",
},
}
result = await check_duplicate(candidate, existing)
assert result["is_duplicate"] is False
assert result["confidence"] == 1.0
async def test_dependency_dedupe_rejects_same_manifest_identity() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in sample",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
"manifest_path": "services/api/package-lock.json",
},
}
]
candidate = {
"title": "CVE-2024-0001 in sample re-reported",
"description": "Same advisory, same manifest.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
"manifest_path": "services/api/package-lock.json",
},
}
result = await check_duplicate(candidate, existing)
assert result["is_duplicate"] is True
assert result["duplicate_id"] == "vuln-0001"
async def test_dependency_dedupe_detects_legacy_same_cve_package() -> None:
existing = [
{
@@ -622,6 +872,8 @@ def test_vuln_tool_exposes_new_params() -> None:
dep_props = create_dependency_report.params_json_schema["properties"]
for field in ("package_name", "installed_version", "cve", "advisory_cvss"):
assert field in dep_props
for field in ("reachability", "reachability_evidence", "manifest_path"):
assert field in dep_props
dep_required = create_dependency_report.params_json_schema["required"]
assert "package_ecosystem" in dep_required
assert "advisory_cvss" in dep_required
+28
View File
@@ -64,3 +64,31 @@ async def test_a_message_that_already_arrived_is_taken_instead_of_parking() -> N
assert result["wait_outcome"] == "message_arrived"
assert result["pending_messages"] == 1
assert coordinator.statuses["root"] == "running"
async def _call_without_message(context: dict[str, Any]) -> dict[str, Any]:
ctx = ToolContext(
context=context,
tool_name="respond_to_user",
tool_call_id="call-1",
tool_arguments="{}",
)
raw = await respond_to_user.on_invoke_tool(ctx, "{}")
return json.loads(raw) # type: ignore[no-any-return]
@pytest.mark.asyncio
async def test_parks_without_a_message() -> None:
"""An agent that has already said its piece as plain text can just wait.
The nudge is what leaves it here, and while a message was required the only
way to stop was to send the same answer a second time.
"""
context = await _context(interactive=True)
result = await _call_without_message(context)
assert result["success"] is True
assert result["wait_outcome"] == "waiting"
assert result["message"] == ""
assert context["coordinator"].statuses["root"] == "waiting"
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import asyncio
import types
from typing import Any
import pytest
from agents import ModelSettings
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.core import runner
from strix.core.agents import AgentCoordinator
from strix.runtime import session_manager
def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
settings = types.SimpleNamespace(
llm=types.SimpleNamespace(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
prompt_cache=True,
extra_headers=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
monkeypatch.setattr(
runner, "uses_chat_completions_tool_schema", lambda _model, _settings: False
)
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _state_dir: None)
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _state_dir: None)
async def _create_or_reuse(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
return {"client": object(), "session": object(), "caido_client": None}
async def _cleanup(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr(session_manager, "create_or_reuse", _create_or_reuse)
monkeypatch.setattr(session_manager, "cleanup", _cleanup)
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "")
monkeypatch.setattr(runner, "make_model_settings", lambda *_a, **_k: ModelSettings())
monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
def _root_status(coordinator: AgentCoordinator) -> str:
roots = [aid for aid, parent in coordinator.parent_of.items() if parent is None]
assert len(roots) == 1
return coordinator.statuses[roots[0]]
@pytest.mark.parametrize("interrupt", [KeyboardInterrupt, asyncio.CancelledError])
@pytest.mark.asyncio
async def test_user_interrupt_leaves_the_root_running_for_resume(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, interrupt: type[BaseException]
) -> None:
_wire_runner(monkeypatch, tmp_path)
async def _interrupt(*_args: Any, **_kwargs: Any) -> None:
raise interrupt()
monkeypatch.setattr(runner, "run_agent_loop", _interrupt)
coordinator = AgentCoordinator()
with pytest.raises(interrupt):
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-test",
image="img",
coordinator=coordinator,
)
assert _root_status(coordinator) == "running"
@pytest.mark.asyncio
async def test_a_real_crash_still_marks_root_failed(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
_wire_runner(monkeypatch, tmp_path)
async def _boom(*_args: Any, **_kwargs: Any) -> None:
raise RuntimeError("boom")
monkeypatch.setattr(runner, "run_agent_loop", _boom)
coordinator = AgentCoordinator()
with pytest.raises(RuntimeError, match="boom"):
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-test",
image="img",
coordinator=coordinator,
)
assert _root_status(coordinator) == "failed"
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import asyncio
import types
from typing import Any
import pytest
from agents import ModelSettings
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.core import runner
from strix.core.agents import AgentCoordinator
from strix.runtime import session_manager
def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
settings = _settings()
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _s: None)
monkeypatch.setattr(runner, "uses_chat_completions_tool_schema", lambda _m, _s: False)
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _d: None)
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _d: None)
async def _create_or_reuse(*_a: Any, **_k: Any) -> dict[str, Any]:
return {"client": object(), "session": object(), "caido_client": None}
async def _cleanup(*_a: Any, **_k: Any) -> None:
return None
monkeypatch.setattr(session_manager, "create_or_reuse", _create_or_reuse)
monkeypatch.setattr(session_manager, "cleanup", _cleanup)
monkeypatch.setattr(runner, "build_root_task", lambda _c: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _c: "")
monkeypatch.setattr(runner, "make_model_settings", lambda *_a, **_k: ModelSettings())
monkeypatch.setattr(runner, "build_strix_agent", lambda **_k: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_k: lambda **_kk: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
def _settings() -> Any:
return types.SimpleNamespace(
llm=types.SimpleNamespace(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
prompt_cache=True,
extra_headers=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
@pytest.mark.asyncio
async def test_a_live_child_is_settled_before_sessions_close(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
_wire_runner(monkeypatch, tmp_path)
coordinator = AgentCoordinator()
child_started = asyncio.Event()
child_task: dict[str, asyncio.Task[None]] = {}
async def _root_finishes(**kwargs: Any) -> None:
root_id = kwargs["agent_id"]
async def _child_mid_turn() -> None:
child_started.set()
await asyncio.sleep(3600)
await coordinator.register("child", "Child", parent_id=root_id)
task = asyncio.create_task(_child_mid_turn())
child_task["t"] = task
await coordinator.attach_runtime("child", task=task)
await child_started.wait()
monkeypatch.setattr(runner, "run_agent_loop", _root_finishes)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-test",
image="img",
coordinator=coordinator,
)
task = child_task["t"]
assert task.done(), "the child task was left running past scan teardown"
assert task.cancelled(), "the child was not cancelled cleanly on a finish"
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, cast
import pytest
from strix.core.sessions import open_agent_session
def _count_open_fds() -> int | None:
for path in (Path("/proc/self/fd"), Path("/dev/fd")):
if path.is_dir():
return len(list(path.iterdir()))
return None
@pytest.mark.asyncio
async def test_sessions_hold_no_descriptors_while_parked(tmp_path: Path) -> None:
"""Descriptor use must track live operations, not the number of sessions.
The SDK keeps a connection per (session, pool thread) open for the session's
whole life. An agent parks rather than exits, so its session lives for the
scan, and fan-out multiplies those handles until the process runs out of file
descriptors (#1018). A session that is not mid-operation should hold none.
"""
baseline = _count_open_fds()
if baseline is None:
pytest.skip("no /proc/self/fd or /dev/fd on this platform")
sessions = [open_agent_session(f"a{i}", tmp_path / f"s{i}.db") for i in range(60)]
try:
for _ in range(4):
await asyncio.gather(
*(s.add_items([{"role": "user", "content": "x"}]) for s in sessions)
)
await asyncio.gather(*(s.get_items() for s in sessions))
parked = _count_open_fds()
assert parked is not None
# 60 parked sessions, yet descriptors are back at the baseline.
assert parked - baseline <= 5, f"parked fds grew by {parked - baseline}"
finally:
for s in sessions:
s.close()
@pytest.mark.asyncio
async def test_in_flight_descriptors_track_concurrency_not_session_count(
tmp_path: Path,
) -> None:
baseline = _count_open_fds()
if baseline is None:
pytest.skip("no /proc/self/fd or /dev/fd on this platform")
sessions = [open_agent_session(f"a{i}", tmp_path / f"s{i}.db") for i in range(200)]
peak = baseline
try:
async def sample() -> None:
nonlocal peak
for _ in range(500):
current = _count_open_fds()
if current is not None:
peak = max(peak, current)
await asyncio.sleep(0)
async def load() -> None:
for _ in range(4):
await asyncio.gather(
*(s.add_items([{"role": "user", "content": "x"}]) for s in sessions)
)
await asyncio.gather(load(), sample())
# 200 sessions, but peak is bounded by the thread pool, well under 200.
assert peak - baseline < 100, f"in-flight fds peaked at +{peak - baseline}"
finally:
for s in sessions:
s.close()
@pytest.mark.asyncio
async def test_history_survives_the_per_operation_connection(tmp_path: Path) -> None:
session = open_agent_session("agent-1", tmp_path / "agents.db")
try:
for i in range(30):
await session.add_items([{"role": "user", "content": f"m{i}"}])
items = [cast("dict[str, Any]", i) for i in await session.get_items()]
assert [i["content"] for i in items] == [f"m{i}" for i in range(30)]
finally:
session.close()
@pytest.mark.asyncio
async def test_concurrent_sessions_sharing_one_file_stay_consistent(tmp_path: Path) -> None:
db = tmp_path / "shared.db"
sessions = [open_agent_session(f"a{i}", db) for i in range(10)]
try:
await asyncio.gather(
*(s.add_items([{"role": "user", "content": s.session_id}]) for s in sessions)
)
# Each session sees only its own row despite sharing the file.
for s in sessions:
items = [cast("dict[str, Any]", i) for i in await s.get_items()]
assert [i["content"] for i in items] == [s.session_id]
finally:
for s in sessions:
s.close()
@pytest.mark.asyncio
async def test_a_closed_session_refuses_operations(tmp_path: Path) -> None:
session = open_agent_session("agent-1", tmp_path / "agents.db")
await session.add_items([{"role": "user", "content": "x"}])
session.close()
with pytest.raises(RuntimeError, match="closed"):
await session.add_items([{"role": "user", "content": "y"}])
+121 -7
View File
@@ -1,8 +1,10 @@
from collections.abc import Iterator
from pathlib import Path
import pytest
import strix.skills as skills_mod
from strix.agents.prompt import render_system_prompt
from strix.skills import (
get_all_skill_names,
get_available_skills,
@@ -12,10 +14,11 @@ from strix.skills import (
skill_search_dirs,
validate_requested_skills,
)
from strix.utils.resource_paths import get_strix_resource_path
@pytest.fixture(autouse=True)
def _clear_extra_dirs() -> None:
def _clear_extra_dirs() -> Iterator[None]:
original = list(skills_mod._EXTRA_SKILL_DIRS)
skills_mod._EXTRA_SKILL_DIRS.clear()
try:
@@ -37,9 +40,11 @@ def _write_root_skill(root: Path, name: str, body: str) -> None:
def test_no_registration_leaves_builtin_only() -> None:
assert registered_skill_dirs() == ()
builtin = skills_mod.get_strix_resource_path("skills")
builtin = get_strix_resource_path("skills")
assert skill_search_dirs() == (builtin,)
assert {"nmap", "subfinder"}.issubset(get_available_skills()["tooling"])
assert {"nmap", "subfinder"}.issubset(
{skill["name"] for skill in get_available_skills()["tooling"]}
)
def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None:
@@ -61,16 +66,125 @@ def test_registered_dir_adds_new_skill(tmp_path: Path) -> None:
register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names()
assert get_available_skills()["extra"] == ["widget"]
assert get_available_skills()["extra"] == [{"name": "widget", "description": ""}]
assert load_skills(["widget"]) == {"widget": "widget body"}
def test_available_skill_includes_frontmatter_description(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"widget",
"---\nname: widget\ndescription: Useful widget guidance\n---\nwidget body",
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "widget", "description": "Useful widget guidance"}
]
def test_available_skill_supports_colon_in_description(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"widget",
'---\nname: widget\ndescription: "Useful widget: handles YAML"\n---\nwidget body',
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "widget", "description": "Useful widget: handles YAML"}
]
def test_available_skill_normalizes_quoted_description(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"widget",
'---\nname: widget\ndescription: "Useful: widget guidance"\n---\nwidget body',
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "widget", "description": "Useful: widget guidance"}
]
def test_available_skill_normalizes_multiline_descriptions(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"block",
"---\nname: block\n\ndescription: |\n"
" First paragraph\n\n Second paragraph\n\n---\nblock body",
)
_write_skill(
tmp_path,
"extra",
"plain",
"---\nname: plain\n\ndescription: First line\n Second line\n\n---\nplain body",
)
register_skill_dir(tmp_path)
available = {skill["name"]: skill["description"] for skill in get_available_skills()["extra"]}
assert available == {
"block": "First paragraph Second paragraph",
"plain": "First line Second line",
}
def test_available_skill_supports_block_scalar_trailing_comment(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"commented",
"---\nname: commented\ndescription: | # paragraph\n"
" First line\n Second line\n---\ncommented body",
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "commented", "description": "First line Second line"}
]
def test_malformed_frontmatter_keeps_skill_body(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"broken",
"---\nname: [broken\ndescription: should be empty\n---\nbroken body",
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [{"name": "broken", "description": ""}]
assert load_skills(["extra/broken"]) == {"broken": "broken body"}
def test_system_prompt_renders_skill_descriptions() -> None:
prompt = render_system_prompt(scan_mode="quick", is_root=True)
assert "- technologies/firebase: Firebase security testing covering" in prompt
def test_system_prompt_omits_empty_skill_description(tmp_path: Path) -> None:
_write_skill(tmp_path, "extra", "widget", "---\nname: widget\ndescription:\n---\nwidget body")
register_skill_dir(tmp_path)
prompt = render_system_prompt(scan_mode="quick", is_root=True)
assert "- extra/widget\n" in prompt
assert "- extra/widget: " not in prompt
def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None:
_write_root_skill(tmp_path, "widget", "widget body")
register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names()
assert get_available_skills()["root"] == ["widget"]
assert get_available_skills()["root"] == [{"name": "widget", "description": ""}]
assert validate_requested_skills(["widget"]) is None
assert validate_requested_skills(["root/widget"]) is None
assert load_skills(["widget"]) == {"widget": "widget body"}
@@ -83,8 +197,8 @@ def test_ambiguous_bare_skill_requires_qualified_name(tmp_path: Path) -> None:
register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names()
assert get_available_skills()["alpha"] == ["widget"]
assert get_available_skills()["beta"] == ["widget"]
assert get_available_skills()["alpha"] == [{"name": "widget", "description": ""}]
assert get_available_skills()["beta"] == [{"name": "widget", "description": ""}]
assert validate_requested_skills(["alpha/widget"]) is None
assert validate_requested_skills(["beta/widget"]) is None
+107
View File
@@ -0,0 +1,107 @@
"""Shared subscription credential store: secure writes and cross-provider locking."""
from __future__ import annotations
import fcntl
import stat
from typing import TYPE_CHECKING
import pytest
from strix.config import codex, grok, subscription_store
if TYPE_CHECKING:
from pathlib import Path
def test_write_creates_owner_only_file(tmp_path: Path) -> None:
path = tmp_path / ".strix" / "subscription-auth.json"
subscription_store.write(path, {"grok": {"type": "oauth", "access": "a", "refresh": "r"}})
assert stat.S_IMODE(path.stat().st_mode) == 0o600
# No stray temp file is left behind.
assert not path.with_suffix(".json.tmp").exists()
def test_write_does_not_follow_a_symlink_at_target(tmp_path: Path) -> None:
store_dir = tmp_path / ".strix"
store_dir.mkdir()
outside = tmp_path / "attacker-target.json"
path = store_dir / "subscription-auth.json"
path.symlink_to(outside) # attacker pre-plants a symlink at the store path
subscription_store.write(path, {"grok": {"type": "oauth", "access": "a", "refresh": "r"}})
# The atomic rename replaced the symlink with a real file; nothing was
# written through it to the attacker-chosen location.
assert not path.is_symlink()
assert not outside.exists()
assert subscription_store.read(path)["grok"]["access"] == "a"
def test_providers_share_store_without_clobbering(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
store = tmp_path / ".strix" / "subscription-auth.json"
monkeypatch.setattr(codex, "AUTH_PATH", store)
monkeypatch.setattr(grok, "AUTH_PATH", store)
codex.save_record({"type": "oauth", "access": "c", "refresh": "r", "account_id": "acct"})
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
data = subscription_store.read(store)
assert data["codex"]["access"] == "c"
assert data["grok"]["access"] == "g"
# Logging one provider out leaves the other's credential intact.
grok.logout()
remaining = subscription_store.read(store)
assert "grok" not in remaining
assert remaining["codex"]["access"] == "c"
def test_guard_is_reentrant(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
store = tmp_path / ".strix" / "subscription-auth.json"
monkeypatch.setattr(grok, "AUTH_PATH", store)
# Persisting while already holding the guard must not deadlock — this mirrors
# a token refresh saving its new record inside the refresh critical section.
with subscription_store.guard(store):
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
record = grok.read_record()
assert record is not None
assert record["access"] == "g"
def test_mutation_aborts_when_lock_cannot_be_acquired(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
store = tmp_path / ".strix" / "subscription-auth.json"
monkeypatch.setattr(grok, "AUTH_PATH", store)
def _no_lock(*_args: object, **_kwargs: object) -> None:
raise OSError("no locks available")
monkeypatch.setattr(fcntl, "flock", _no_lock)
# Rather than silently doing an unlocked read-modify-write, the store raises
# and writes nothing.
with pytest.raises(subscription_store.StoreLockError):
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
assert not store.exists()
def test_lock_file_rejects_a_pre_positioned_symlink(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
store_dir = tmp_path / ".strix"
store_dir.mkdir()
store = store_dir / "subscription-auth.json"
monkeypatch.setattr(grok, "AUTH_PATH", store)
# Attacker pre-plants a symlink where the lock file would be created.
outside = tmp_path / "attacker-target"
store.with_suffix(".lock").symlink_to(outside)
with pytest.raises(subscription_store.StoreLockError):
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
# The symlink target was never created/truncated through the lock open.
assert not outside.exists()
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
from typing import Any
import pytest
from agents.tool_context import ToolContext
from strix.tools.todo import tools
from strix.tools.todo.tools import _coerce_priority, create_todo
@pytest.fixture(autouse=True)
def _isolate_store() -> Any:
tools._todos_storage.clear()
yield
tools._todos_storage.clear()
async def _create(todos: list[Any], agent_id: str = "root") -> dict[str, Any]:
ctx = ToolContext(
context={"agent_id": agent_id},
tool_name="create_todo",
tool_call_id="call-1",
tool_arguments="{}",
)
raw = await create_todo.on_invoke_tool(ctx, json.dumps({"todos": json.dumps(todos)}))
return json.loads(raw) # type: ignore[no-any-return]
def test_unknown_priority_falls_back_to_normal() -> None:
assert _coerce_priority("medium") == "normal"
assert _coerce_priority("urgent") == "normal"
assert _coerce_priority("high") == "high"
@pytest.mark.asyncio
async def test_one_bad_priority_no_longer_discards_the_batch() -> None:
result = await _create(
[
{"title": "Recon", "priority": "medium"},
{"title": "Probe /admin", "priority": "sky-high"},
{"title": "Report"},
]
)
assert result["success"] is True
assert result["created_count"] == 3
by_title = {c["title"]: c["priority"] for c in result["created"]}
assert by_title["Recon"] == "normal"
assert by_title["Probe /admin"] == "normal"
assert by_title["Report"] == "normal"
@pytest.mark.asyncio
async def test_duplicate_titles_within_a_batch_are_skipped() -> None:
result = await _create(
[
{"title": "Subdomain enumeration"},
{"title": "Content discovery"},
{"title": "Subdomain enumeration"},
{"title": "content discovery"},
]
)
assert result["created_count"] == 2
assert {c["title"] for c in result["created"]} == {
"Subdomain enumeration",
"Content discovery",
}
assert len(result["skipped"]) == 2
assert all(s["reason"] == "duplicate title" for s in result["skipped"])
@pytest.mark.asyncio
async def test_a_title_already_on_the_list_is_not_created_again() -> None:
await _create([{"title": "Crawl with katana"}])
result = await _create([{"title": "crawl with katana"}, {"title": "JS analysis"}])
assert [c["title"] for c in result["created"]] == ["JS analysis"]
assert [s["title"] for s in result["skipped"]] == ["crawl with katana"]
assert result["total_count"] == 2
def test_coerce_never_raises() -> None:
assert _coerce_priority("nonsense") == "normal"
assert _coerce_priority(None) == "normal"
assert _coerce_priority("high") == "high"
for value in (2, ["high"], {"p": 1}, True):
assert _coerce_priority(value) == "normal" # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_non_string_priority_does_not_fail_the_batch() -> None:
result = await _create(
[
{"title": "Recon", "priority": 2},
{"title": "Probe", "priority": ["high"]},
{"title": "Report"},
]
)
assert result["success"] is True
assert result["created_count"] == 3
assert {c["priority"] for c in result["created"]} == {"normal"}
+52 -11
View File
@@ -110,6 +110,28 @@ def test_state_populates_model_warning_for_non_frontier_model() -> None:
assert "not a recommended frontier model" in warning
def test_snapshot_carries_the_subscription_provider_label() -> None:
os.environ["STRIX_LLM"] = "grok/grok-4"
loader._cached = None
snapshot = TuiController(args()).snapshot()
# The TUI renders this label, so it must name the actual provider rather
# than assuming ChatGPT.
assert snapshot["subscription"] is True
assert snapshot["subscription_label"] == "Grok subscription"
def test_snapshot_has_no_subscription_label_for_api_key_runs() -> None:
os.environ["STRIX_LLM"] = "openai/gpt-5.4"
loader._cached = None
snapshot = TuiController(args()).snapshot()
assert snapshot["subscription"] is False
assert snapshot["subscription_label"] == ""
def test_setup_restores_prepared_cli_targets() -> None:
setup_args = args()
setup_args.targets_info = [
@@ -234,12 +256,11 @@ async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None:
@pytest.mark.asyncio
async def test_declining_the_mount_returns_to_the_start_screen() -> None:
started = False
async def test_declining_the_mount_runs_without_one() -> None:
started: list[bool] = []
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
async def start(verify: bool = True) -> None:
started.append(verify)
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
@@ -250,14 +271,34 @@ async def test_declining_the_mount_returns_to_the_start_screen() -> None:
result = await controller.handle("setup.confirm_mount", {"approved": False})
assert result == {"approved": False}
# Nothing was prepared, so the session goes back to the start screen and can
# be launched again.
assert started is False
# Declining skips the directory; it does not abandon the scan.
assert started == [False]
assert controller.workspace_mount is None
assert controller.pending_workspace_mount is None
assert controller.setup_mode is True
assert controller.scan_started is False
assert controller.scan_state == "setup"
assert controller.setup_mode is False
assert controller.scan_started is True
assert controller.scan_state == "running"
@pytest.mark.asyncio
async def test_approving_the_mount_runs_with_it() -> None:
started: list[bool] = []
async def start(verify: bool = True) -> None:
started.append(verify)
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
result = await controller.handle("setup.confirm_mount", {"approved": True})
assert result == {"approved": True}
assert started == [False]
assert controller.workspace_mount == str(Path.cwd())
assert controller.scan_state == "running"
@pytest.mark.asyncio
+59 -3
View File
@@ -7,19 +7,26 @@ shows what the user actually typed; resuming has to match that.
from __future__ import annotations
import ast
import json
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from strix.core import execution
from strix.core.paths import runtime_state_dir
from strix.interface.tui.backend.live_view import TuiLiveView as GoTuiLiveView
from strix.interface.tui.live_view import TuiLiveView, _is_internal_agent_turn
from strix.interface.tui.live_view import (
_INTERNAL_TURN_PREFIXES,
TuiLiveView,
_is_internal_agent_turn,
)
if TYPE_CHECKING:
from pathlib import Path
from types import ModuleType
def _write_run(run_dir: Path, items: list[dict[str, Any]], agent_id: str = "root") -> None:
@@ -176,11 +183,60 @@ def test_internal_turn_classifier_matches_every_injected_form() -> None:
"[CRITICAL] Turn budget: 480/500 used (96%).",
"== Inherited context from parent (background only) ==",
"Your previous message ended a turn without a tool call.",
"Your previous response ended the autonomous Strix run without a lifecycle tool call.",
"Your previous response ended the autonomous run without a lifecycle tool call.",
):
assert _is_internal_agent_turn(content), content
def _injected_strings(module: ModuleType) -> list[str]:
"""Every string a module can inject, and nothing it merely mentions.
Parsing rather than searching the text keeps comments out of it, so a stale
copy of a message left in a comment cannot pass for the message itself. It
also joins adjacent literals for free, which the line wrapping needs, and
docstrings are dropped because they describe the code rather than run in it.
"""
tree = ast.parse(Path(module.__file__ or "").read_text(encoding="utf-8"))
docstrings = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
continue
first = node.body[0] if node.body else None
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant):
docstrings.add(id(first.value))
literals: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Constant):
if isinstance(node.value, str) and id(node) not in docstrings:
literals.append(node.value)
elif isinstance(node, ast.JoinedStr):
literals.append(
"".join(
part.value
for part in node.values
if isinstance(part, ast.Constant) and isinstance(part.value, str)
)
)
return literals
def test_internal_turn_prefixes_still_match_what_is_injected() -> None:
"""The classifier copies sentences out of another module, so they can drift.
Both nudges are written inline in strix.core.execution, so there is nothing to
import and compare against. Read them back out of what that module can inject.
"""
injected = _injected_strings(execution)
nudges = [prefix for prefix in _INTERNAL_TURN_PREFIXES if prefix.startswith("Your previous")]
assert nudges, "the no-tool-call nudges are no longer in the classifier"
for nudge in nudges:
assert any(nudge in literal for literal in injected), (
f"the classifier expects {nudge!r}, which strix.core.execution no longer "
f"injects. A resumed scan would show that nudge as the user's own message."
)
def test_internal_turn_classifier_keeps_bracketed_user_text() -> None:
"""A leading bracket is not enough: typed text often starts with one."""
for content in (
+47
View File
@@ -70,6 +70,53 @@ def test_read_run_summary_finished_flag(tmp_path: Path) -> None:
assert read_run_summary(partial)["finished"] is False
def _write_record(base: Path, name: str, record: dict[str, object]) -> Path:
run_dir = base / "strix_runs" / name
run_dir.mkdir(parents=True)
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
return run_dir
def test_read_run_summary_backfills_subscription_provider(tmp_path: Path) -> None:
# An older subscription run recorded no provider name; it is derived from
# the recorded provider/model slug so the viewer can label it.
run_dir = _write_record(
tmp_path,
"grok-run",
{
"auth_mode": "subscription",
"llm_usage": {"agents": [{"agent_id": "root", "model": "grok/grok-4"}]},
},
)
assert read_run_summary(run_dir)["subscription_provider"] == "Grok"
def test_read_run_summary_keeps_explicit_provider(tmp_path: Path) -> None:
run_dir = _write_record(
tmp_path,
"chatgpt-run",
{
"auth_mode": "subscription",
"subscription_provider": "ChatGPT",
"llm_usage": {"agents": [{"agent_id": "root", "model": "grok/grok-4"}]},
},
)
# An explicit field is authoritative and never overwritten by the slug.
assert read_run_summary(run_dir)["subscription_provider"] == "ChatGPT"
def test_read_run_summary_ignores_api_key_runs(tmp_path: Path) -> None:
run_dir = _write_record(
tmp_path,
"api-key-run",
{
"auth_mode": "api_key",
"llm_usage": {"agents": [{"agent_id": "root", "model": "openai/gpt-5.4"}]},
},
)
assert "subscription_provider" not in read_run_summary(run_dir)
def test_read_missing_artifacts_return_defaults(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "empty", status="running", end_time=None)
assert read_vulnerabilities(run_dir) == []
+126
View File
@@ -0,0 +1,126 @@
"""Tests for collapsing repeated waits queued inside one model turn.
An orchestrator that writes out its whole poll loop ahead of time queues
many ``wait_for_agents`` calls in a single response. Each one parks for its
full timeout, so the agent stops reacting for hours while its children run
unsupervised. Only the first wait of a turn parks; the rest return at once.
"""
from __future__ import annotations
import asyncio
import json
import time
from typing import TYPE_CHECKING, Any, cast
import pytest
from agents import RunContextWrapper
from agents.tool_context import ToolContext
from strix.core.agents import AgentCoordinator
from strix.core.hooks import LLM_TURN_KEY, ReportUsageHooks
from strix.tools.agents_graph.tools import wait_for_agents
if TYPE_CHECKING:
from collections.abc import Iterator
_WAIT_SECONDS = 2
@pytest.fixture
def _fast_wait(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
# The real ceiling is 300s per wait; the shape of the bug is the same.
monkeypatch.setattr(
"strix.tools.agents_graph.tools._WAIT_DEFAULT_TIMEOUT_S", _WAIT_SECONDS, raising=True
)
yield
async def _context() -> dict[str, Any]:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
return {"agent_id": "root", "coordinator": coordinator}
async def _wait(inner: dict[str, Any]) -> dict[str, Any]:
ctx = ToolContext(
context=inner,
tool_name="wait_for_agents",
tool_call_id="call-1",
tool_arguments="{}",
)
raw: str = await wait_for_agents.on_invoke_tool(
ctx, json.dumps({"reason": "waiting for wave 1", "timeout_seconds": _WAIT_SECONDS})
)
return cast("dict[str, Any]", json.loads(raw))
@pytest.mark.asyncio
async def test_waits_queued_in_one_turn_each_park_without_the_guard(_fast_wait: None) -> None:
# Repro: no turn marker in context (as before the fix) — every queued wait
# parks for its full timeout, so N waits cost N x timeout.
inner = await _context()
started = time.monotonic()
outcomes = [(await _wait(inner))["wait_outcome"] for _ in range(3)]
elapsed = time.monotonic() - started
assert outcomes == ["timeout", "timeout", "timeout"]
assert elapsed >= 3 * _WAIT_SECONDS
@pytest.mark.asyncio
async def test_repeated_waits_in_one_turn_are_collapsed(_fast_wait: None) -> None:
inner = await _context()
inner[LLM_TURN_KEY] = 1
started = time.monotonic()
outcomes = [(await _wait(inner))["wait_outcome"] for _ in range(3)]
elapsed = time.monotonic() - started
assert outcomes == ["timeout", "already_waited", "already_waited"]
assert elapsed < 2 * _WAIT_SECONDS
@pytest.mark.asyncio
async def test_a_wait_in_the_next_turn_still_parks(_fast_wait: None) -> None:
inner = await _context()
inner[LLM_TURN_KEY] = 1
assert (await _wait(inner))["wait_outcome"] == "timeout"
assert (await _wait(inner))["wait_outcome"] == "already_waited"
inner[LLM_TURN_KEY] = 2
assert (await _wait(inner))["wait_outcome"] == "timeout"
@pytest.mark.asyncio
async def test_each_model_turn_bumps_the_turn_marker() -> None:
hooks = ReportUsageHooks(model="gw-model")
context: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={})
agent = cast("Any", None)
await hooks.on_llm_start(context, agent, None, [])
await hooks.on_llm_start(context, agent, None, [])
assert context.context[LLM_TURN_KEY] == 2
@pytest.mark.asyncio
async def test_a_collapsed_wait_still_reports_arriving_messages(_fast_wait: None) -> None:
inner = await _context()
inner[LLM_TURN_KEY] = 1
coordinator = cast("AgentCoordinator", inner["coordinator"])
async def _send() -> None:
await asyncio.sleep(0.1)
await coordinator.send("root", {"type": "information", "content": "child done"})
task = asyncio.create_task(_send())
first = await _wait(inner)
await task
assert first["wait_outcome"] == "message_arrived"
assert (await _wait(inner))["wait_outcome"] == "already_waited"
Generated
+1 -1
View File
@@ -2378,7 +2378,7 @@ wheels = [
[[package]]
name = "strix-agent"
version = "1.4.1"
version = "1.5.3"
source = { editable = "." }
dependencies = [
{ name = "caido-sdk-client" },