Compare commits

..
Author SHA1 Message Date
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
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
42 changed files with 1776 additions and 279 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 -E "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/**
+4 -4
View File
@@ -10,10 +10,10 @@ Install the agent skills for step-by-step workflows:
npx skills add usestrix/strix
```
- `strix-pentest` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below)
- `strix-cloud-api` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed)
- `strix-fix-findings` — remediate findings and re-run Strix to verify
- `strix-ci-setup` — add PR scanning to CI/CD (self-hosted CLI or managed app)
- `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:**
+1 -1
View File
@@ -116,7 +116,7 @@ Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatib
npx skills add usestrix/strix
```
This installs four skills: **strix-pentest** (run headless scans and read results), **strix-cloud-api** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **strix-fix-findings** (remediate + re-scan to verify), and **strix-ci-setup** (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.
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.
---
+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
+7 -7
View File
@@ -15,15 +15,15 @@ npx skills add usestrix/strix
| Skill | What your agent learns |
|-------|------------------------|
| `strix-pentest` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results |
| `strix-cloud-api` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed |
| `strix-fix-findings` | Triage findings, fix root causes, and re-run Strix to verify each fix |
| `strix-ci-setup` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) |
| `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 strix-pentest`, or use one without installing:
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@strix-pentest | claude
npx skills use usestrix/strix@penetration-testing-with-strix | claude
```
## Two ways to run — self-hosted or managed
@@ -31,7 +31,7 @@ npx skills use usestrix/strix@strix-pentest | claude
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 `strix-cloud-api` skill has the full flow.
- **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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.5.0"
version = "1.5.2"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -1,6 +1,6 @@
---
name: strix-ci-setup
description: Wire Strix security scanning into CI/CD — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code. 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, pentesting, or Strix to their CI pipeline or PR workflow.
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
@@ -11,7 +11,7 @@ metadata:
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 **strix-cloud-api** skill.
- **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.
@@ -131,6 +131,6 @@ No workflow file, no Docker, no LLM key. Two ways to use it:
-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 **strix-cloud-api** skill.
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.
@@ -1,6 +1,6 @@
---
name: strix-fix-findings
description: Triage and remediate vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud), then re-run Strix to verify each fix. Use after a Strix scan reports findings, or when the user asks to fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan's vulnerabilities.
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
@@ -18,7 +18,7 @@ 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 **strix-cloud-api** skill for auth.
- **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.
@@ -1,6 +1,6 @@
---
name: strix-cloud-api
description: Drive the managed Strix platform headlessly through the app.strix.ai REST API — create an API token, register domain/repository assets, launch and poll pentest scans, list and triage vulnerabilities, export SARIF, download PDF/DOCX reports (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants Strix without local Docker/LLM infra, or wants scans tracked in a team dashboard, on a schedule, or in CI via API.
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
@@ -9,7 +9,7 @@ metadata:
# 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 **strix-pentest** skill instead — both share the same engine and SARIF output, so you can mix them.
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`
@@ -113,7 +113,7 @@ curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \
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 **strix-fix-findings** skill.
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
@@ -1,6 +1,6 @@
---
name: strix-pentest
description: Run an autonomous AI penetration test with Strix against a codebase, repository, URL, domain, or IP — either self-hosted with the open-source CLI or via the managed app.strix.ai cloud API — and read the validated findings (Markdown, JSON, CSV, SARIF, PoCs). Use when the user asks to pentest, security-scan, or find vulnerabilities in an app, API, website, or repo with Strix.
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
@@ -12,7 +12,7 @@ metadata:
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 **strix-cloud-api** skill.
- **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)
@@ -112,7 +112,7 @@ Artifacts land in `strix_runs/<run-name>/`:
# Option B — Cloud API (managed, no local infra)
Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **strix-cloud-api** skill. Minimal launch-and-poll:
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
@@ -136,7 +136,7 @@ Ask the user to create the token (and register the target as a domain/repository
## 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 **strix-fix-findings** skill. To wire scanning into CI/CD, use the **strix-ci-setup** skill.
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
+3 -1
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:
+6 -2
View File
@@ -39,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.
@@ -490,8 +492,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 %}
+1 -1
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. "
+9
View File
@@ -138,6 +138,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))
+12 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import contextlib
import io
import json
@@ -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:
+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:
+5 -14
View File
@@ -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()
@@ -356,14 +349,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")
@@ -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
@@ -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)
+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 = ""
+31 -10
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
@@ -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 = ""
+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**
@@ -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
+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"}'})
+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"]
+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
+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"}
+30 -11
View File
@@ -234,12 +234,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 +249,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 (
Generated
+1 -1
View File
@@ -2378,7 +2378,7 @@ wheels = [
[[package]]
name = "strix-agent"
version = "1.5.0"
version = "1.5.2"
source = { editable = "." }
dependencies = [
{ name = "caido-sdk-client" },