Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 450eedc6cb chore(deps): bump aiohttp from 3.14.1 to 3.14.3
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.14.1 to 3.14.3.
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.14.1...v3.14.3)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 02:25:29 +00:00
124 changed files with 682 additions and 11381 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 "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/*
+1 -1
View File
@@ -21,7 +21,7 @@ repos:
fastapi,
pytest,
hatchling,
"openai-agents[litellm]>=0.19.0,<0.20",
"openai-agents[litellm]==0.14.6",
]
args: [--install-types, --non-interactive]
-49
View File
@@ -1,49 +0,0 @@
# Strix — Agent Guide
Strix is an open-source autonomous AI pentesting tool. This file is for AI coding agents that want to **use** Strix (run security scans) or **contribute** to it.
## Using Strix from an agent
Install the agent skills for step-by-step workflows:
```bash
npx skills add usestrix/strix
```
- `penetration-testing-with-strix` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below)
- `managed-pentesting-with-strix` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed)
- `fix-security-vulnerabilities-with-strix` — remediate findings and re-run Strix to verify
- `ci-security-scanning-with-strix` — add PR scanning to CI/CD (self-hosted CLI or managed app)
**Two ways to run, same engine — pick per situation:**
- **Open-source CLI (self-hosted):** free, fully local, BYO LLM key, needs Docker. Best for local dev loops, air-gapped/offline, and full control.
```bash
curl -sSL https://strix.ai/install | bash # install
export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id
export LLM_API_KEY="<key>"
strix -n -t ./ --scan-mode quick --max-budget 10 # headless scan; always use -n
```
- Requires Docker running. Scans take minutes (`quick`) to hours (`deep`) — run in the background.
- Exit codes (headless): `0` clean, `1` fatal error, `2` vulnerabilities found. A `0` only covers what was analyzed — check `run.json` (`status`, `llm_usage.cost` vs the budget) before calling a run clean.
- Artifacts in `strix_runs/<run-name>/`: `penetration_test_report.md`, `vulnerabilities/*.md`, `vulnerabilities.json`, `findings.sarif` (SARIF 2.1.0), `run.json`.
- **Managed cloud (app.strix.ai):** no Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Use it when local infra isn't available.
```bash
# token from Settings → API Access; register the target as an asset, then:
curl -sS https://app.strix.ai/api/v1/scans -H "Authorization: Bearer $STRIX_API_TOKEN" \
-H "Content-Type: application/json" -d '{"engagement_type":"live_test","domain_ids":["<uuid>"]}'
```
- API docs: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json).
- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt).
- Only scan targets the user is authorized to test.
## Contributing to this repo
- Python 3.12+, managed with `uv`. Install dev deps: `make dev-install`.
- Lint/format/type-check/security, all in one: `make check-all` (ruff, mypy, bandit).
- Tests: `uv run pytest`.
- Run from source: `uv run strix --target <target>`.
- Layout: `strix/agents` (agent graph + prompts), `strix/tools` (proxy, browser, terminal, scanners), `strix/runtime` (Docker sandbox), `strix/report` (findings, SARIF), `strix/skills` (internal knowledge packs the pentest agents load — different from the consumer skills in `skills/`), `strix/interface` (CLI/TUI), `containers/` (sandbox image).
- Pre-commit hooks: `make pre-commit` (or `uv run pre-commit install`).
+1 -36
View File
@@ -108,18 +108,6 @@ Try the Strix full-stack penetration testing platform at **[app.strix.ai](https:
---
## 🤖 Use Strix from Your Coding Agent
Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatible](https://agentskills.io) agent the ability to run pentests, fix findings, and set up CI scanning:
```bash
npx skills add usestrix/strix
```
This installs four skills: **penetration-testing-with-strix** (run headless scans and read results), **managed-pentesting-with-strix** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **fix-security-vulnerabilities-with-strix** (remediate + re-scan to verify), and **ci-security-scanning-with-strix** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
---
## ✨ Features
### Agentic Pentesting Tools
@@ -197,28 +185,6 @@ strix --target https://github.com/org/repo
strix --target https://your-app.com
```
### API Testing (OpenAPI / Swagger / Postman)
Point Strix at an API contract and it tests every declared endpoint instead of
having to discover them by crawling. Pair the spec with the live base URL so the
agent knows where to send traffic:
```bash
# OpenAPI / Swagger file (.json / .yaml)
strix --target ./openapi.yaml --target https://api.your-app.com
# Postman collection export
strix --target ./collection.postman_collection.json --target https://api.your-app.com
# Postman collection pulled live by id (no manual export)
export POSTMAN_API_KEY="PMAK-..."
strix --target postman://<collection-uuid>
# ...with a Postman environment to resolve {{baseUrl}} / token variables
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
```
### Advanced Testing Scenarios
```bash
@@ -349,7 +315,6 @@ Strix builds on the incredible work of open-source projects like [LiteLLM](https
> [!WARNING]
> **Authorized use only.** Strix actively tests the targets you point it at, so only run it against systems you own or have **explicit, written permission** to test, and stay within the agreed scope. Unauthorized testing is illegal in most jurisdictions.
> You alone are responsible for obtaining authorization and complying with the law. Strix is provided "as is" with no warranty or liability for misuse.
> Only test apps you own or have permission to test. You are responsible for using Strix ethically and legally.
</div>
+1 -3
View File
@@ -16,8 +16,7 @@ RUN mkdir -p /out/bin && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest && \
go install -v golang.org/x/vuln/cmd/govulncheck@latest
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
# ---------------------------------------------------------------------------
# Runtime stage
@@ -54,7 +53,6 @@ RUN apt-get update && \
nmap ncat ndiff \
sqlmap nuclei subfinder naabu ffuf \
nodejs npm pipx \
golang-go \
libcap2-bin \
gdb \
libnss3-tools \
+1 -5
View File
@@ -80,10 +80,6 @@ affecting the agents that do the actual testing.
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
</ParamField>
<ParamField path="POSTMAN_API_KEY" type="string">
Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://<collection-uid>`), and Postman environments (`postman://<collection-uid>?env=<environment-uid>`) to resolve collection variables. Not needed when passing a local collection export file.
</ParamField>
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
</ParamField>
@@ -110,7 +106,7 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
## Docker Configuration
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.3.0" type="string">
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.2.0" type="string">
Docker image to use for the sandbox container.
</ParamField>
+4 -4
View File
@@ -68,10 +68,10 @@ Framework-specific testing patterns.
Third-party service and platform security.
| Skill | Coverage |
| ---------- | ------------------------------------------------------ |
| `supabase` | Supabase RLS bypasses, auth issues |
| `firebase` | Firebase Firestore, Storage rules, Auth, and Functions |
| Skill | Coverage |
| -------------------- | ---------------------------------- |
| `supabase` | Supabase RLS bypasses, auth issues |
| `firebase_firestore` | Firestore rules, Firebase auth |
### Protocols
+1 -2
View File
@@ -46,8 +46,7 @@
"group": "Integrations",
"pages": [
"integrations/github-actions",
"integrations/ci-cd",
"integrations/coding-agents"
"integrations/ci-cd"
]
},
{
-61
View File
@@ -1,61 +0,0 @@
---
title: "Coding Agents"
description: "Use Strix from Claude Code, Cursor, Codex, and other AI agents"
---
Strix is built to be driven by AI coding agents. Install the official agent skills and your agent knows how to run pentests, remediate findings, and wire Strix into CI.
## Install the Skills
Works with any agent that supports the open [SKILL.md standard](https://agentskills.io) — Claude Code, Cursor, Codex, Gemini CLI, OpenCode, and dozens more:
```bash
npx skills add usestrix/strix
```
| Skill | What your agent learns |
|-------|------------------------|
| `penetration-testing-with-strix` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results |
| `managed-pentesting-with-strix` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed |
| `fix-security-vulnerabilities-with-strix` | Triage findings, fix root causes, and re-run Strix to verify each fix |
| `ci-security-scanning-with-strix` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) |
Install a single skill with `npx skills add usestrix/strix --skill penetration-testing-with-strix`, or use one without installing:
```bash
npx skills use usestrix/strix@penetration-testing-with-strix | claude
```
## Two ways to run — self-hosted or managed
Both use the same engine and produce the same validated findings and SARIF, so agents can pick per situation or combine them:
- **Open-source CLI (self-hosted)** — runs locally in a Docker sandbox with your own LLM key. Free, fully local, air-gap capable. Best for local dev loops and full control.
- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `managed-pentesting-with-strix` skill has the full flow.
## Agent-Friendly Interfaces
Everything an agent needs is machine-readable:
- **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found).
- **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes.
- **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs/<run-name>/`; the cloud exposes the same as JSON plus SARIF export.
- **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps.
- **`AGENTS.md`** — the [repository's agent guide](https://github.com/usestrix/strix/blob/main/AGENTS.md) with a quick reference.
- **`llms.txt`** — this documentation is indexed at [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) and fully exported at [docs.strix.ai/llms-full.txt](https://docs.strix.ai/llms-full.txt); every page is also available as Markdown by appending `.md` to its URL.
## Example Prompts
Once the skills are installed, prompts like these just work:
```text
Pentest this repo with Strix (quick mode, $10 budget) and summarize the findings.
```
```text
Fix all critical and high findings from the last Strix run, then re-scan to verify.
```
```text
Add Strix security scanning to our GitHub Actions so every PR gets tested.
```
+1 -13
View File
@@ -12,17 +12,11 @@ strix (--target <target> | --target-list <path>) [options]
## Options
<ParamField path="--target, -t" type="string">
Target to test. Accepts URLs, repositories, local directories, domains, IP addresses, API spec files (OpenAPI/Swagger `.json`/`.yaml`, a Postman collection export), or a live Postman collection by id (`postman://<collection-uuid>`). Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
When the target is an API spec, Strix copies it into the agent's workspace and authorizes the base URLs it declares (including those resolved from a Postman environment) as in-scope hosts - so the agent reads the contract and tests the full declared surface instead of discovering endpoints by crawling. Pair the spec with the deployed base URL (e.g. `--target ./openapi.yaml --target https://api.example.com`) so the agent has a reachable host to attack.
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
<Note>
A local directory is mounted into the sandbox live and **writable**, so the agent edits your real files (`.git` excepted). Commit or stash first.
</Note>
<Note>
Fetching a Postman collection by id requires `POSTMAN_API_KEY`. Add `?env=<environment-uuid>` to also pull a Postman environment, which resolves `{{baseUrl}}` / token variables the collection references (e.g. `postman://<collection-uuid>?env=<environment-uid>`).
</Note>
</ParamField>
<ParamField path="--target-list" type="string">
@@ -134,12 +128,6 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
# Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com
# API spec + live target (OpenAPI/Swagger file or Postman collection)
strix -t ./openapi.yaml -t https://api.example.com
# Postman collection pulled live by id (+ optional environment)
strix -t "postman://<collection-uuid>?env=<environment-uuid>"
# Targets from a file
strix --target-list ./targets.txt
```
+3 -10
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.5.2"
version = "1.4.1"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -33,8 +33,8 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"openai-agents[litellm]>=0.19.0,<0.20",
"openai>=2.45.0,<3",
"openai-agents[litellm]==0.14.6",
"openai>=2.26.0,<2.45",
"litellm",
"pydantic>=2.11.3",
"pydantic-settings>=2.13.0",
@@ -48,7 +48,6 @@ dependencies = [
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
"cryptography>=48.0.1,<49",
"pyyaml>=6.0",
]
[project.optional-dependencies]
@@ -68,7 +67,6 @@ dev = [
"pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'",
"pytest>=8.3",
"pytest-asyncio>=0.24",
"types-requests>=2.32",
]
[tool.pytest.ini_options]
@@ -134,7 +132,6 @@ module = [
"pydantic_settings.*",
"reportlab.*",
"pypdf.*",
"yaml.*",
"pygments.*",
]
ignore_missing_imports = true
@@ -236,10 +233,6 @@ ignore = [
"strix/interface/auth_cli.py" = ["N802"]
"tests/test_codex_streaming.py" = ["N802"]
"tests/test_disable_streaming.py" = ["N802"]
"tests/test_tool_call_ids.py" = ["N802"]
"tests/test_tool_call_limits.py" = ["N802", "SLF001"]
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
"tests/test_unknown_tool_recovery.py" = ["N802"]
"tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
APP=strix
REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.3.0"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.2.0"
MUTED='\033[0;2m'
RED='\033[0;31m'
@@ -1,136 +0,0 @@
---
name: ci-security-scanning-with-strix
description: Add security scanning to CI/CD with Strix — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code before it merges, with results as PR comments and SARIF uploaded to code scanning. Covers both the self-hosted open-source CLI (runs in your runner) and the managed app.strix.ai platform (GitHub/GitLab app or API, no runner infra). Use when the user asks to add security scanning, SAST/DAST, pentesting, vulnerability checks, or automated security review to their CI pipeline, pre-merge gate, or PR workflow.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.strix.ai
---
# Set up Strix in CI/CD
You can gate PRs two ways — pick based on the environment, or combine them:
- **Managed platform (recommended for most teams)** — connect the GitHub/GitLab/Bitbucket app once and Strix reviews every PR with **no workflow file, no runner, no Docker, and no LLM key**. Results post as PR comments and land in the team dashboard. Best when you want zero CI maintenance, central tracking, or your runners lack Docker. See "Managed platform" below and the **managed-pentesting-with-strix** skill.
- **Self-hosted OSS CLI in your runner** — run a diff-scoped scan as a pipeline step. Fully in your infra, free (BYO LLM key), no external account. Requires Docker on the runner. Best for air-gapped/self-hosted CI or when you don't want scans leaving your environment.
Both fail the build on validated findings and both emit SARIF 2.1.0, so you can start with one and add the other later.
---
# Option A — Self-hosted OSS CLI in the runner
Run a diff-scoped Strix scan on every PR: only changed files are tested, `quick` mode keeps it fast, and exit code `2` fails the build when validated vulnerabilities are found.
## GitHub Actions
Create `.github/workflows/security.yml`:
```yaml
name: Security Scan
on:
pull_request:
jobs:
strix-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for diff-scope resolution
- name: Install Strix
run: curl -sSL https://strix.ai/install | bash
- name: Run Security Scan
env:
STRIX_LLM: ${{ secrets.STRIX_LLM }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: strix -n -t ./ --scan-mode quick --max-budget 10
# Don't fail open: a run that hits the hard budget stop exits 0 but leaves
# run.json status "stopped", not "completed". Enforce completion explicitly.
# This does not catch an agent that wrapped up early on a budget *warning*
# (it still calls finish_scan and records "completed"), so size the budget.
- name: Fail unless the scan completed
run: |
run_json=$(ls -t strix_runs/*/run.json | head -1)
status=$(jq -r .status "$run_json")
if [ "$status" != "completed" ]; then
echo "Strix run status is '$status' — the scan did not complete (likely budget exhausted). Raise --max-budget." >&2
exit 1
fi
```
Then tell the user to add two repository secrets: `STRIX_LLM` (model id, e.g. `openai/gpt-5.4`) and `LLM_API_KEY` (the provider key). Do not create these values yourself.
Notes:
- In CI/headless runs Strix automatically scopes to the PR's changed files (`--scope-mode auto`). If diff resolution fails, keep `fetch-depth: 0` or set `--diff-base` to the PR's actual base branch — use `origin/${{ github.base_ref }}` in GitHub Actions rather than a hard-coded `origin/main`, since repos use different default branches.
- Exit codes: `0` pass, `2` vulnerabilities found (fails the job), `1` setup error.
- The runner needs Docker (default GitHub-hosted Ubuntu runners have it).
- **Size the budget so the scan completes — don't let it fail open.** A `0` exit means "no validated vulnerabilities in what was analyzed"; if `--max-budget` is hit before the diff is fully covered, the scan wraps up early and can still exit `0`. The "Fail unless the scan completed" step above narrows the gap: `strix_runs/<run>/run.json` is `"stopped"` when the scan was cut off at the hard budget limit without a final report. It is not a complete guard — the agents get graduated wrap-up warnings before that limit, and a run that wraps up on a warning still calls `finish_scan` and records `"completed"` with partial coverage. So keep that step in any pipeline that gates merges **and** give the scan real headroom (compare `run.json`'s `llm_usage.cost` against `--max-budget`; if it ran right up to the cap, raise it). For a `quick` diff-scoped PR scan `--max-budget 10` is usually ample, raise it for large diffs.
### Optional: upload findings to GitHub code scanning
Strix writes SARIF 2.1.0 to `strix_runs/<run>/findings.sarif`:
```yaml
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: strix_runs
```
## Other CI systems
Any pipeline works the same way — install, set the two env vars, run headless:
```bash
curl -sSL https://strix.ai/install | bash
# Resolve the PR's base branch robustly (use your CI's base-branch variable if it
# has one, e.g. GitHub Actions: origin/${{ github.base_ref }}). Avoid piping the
# git lookup into another command — a failed lookup would otherwise be masked.
BASE_BRANCH="${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" # GitLab MR target
if [ -z "$BASE_BRANCH" ]; then
BASE_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
BASE_BRANCH="${BASE_BRANCH#origin/}"
fi
DIFF_BASE="origin/${BASE_BRANCH:-main}"
# Fail loudly rather than silently narrowing scope (e.g. to HEAD~1, which on a
# multi-commit branch would scan only the last commit and let earlier ones pass).
if ! git rev-parse --verify --quiet "$DIFF_BASE" >/dev/null; then
echo "Cannot resolve diff base '$DIFF_BASE'. Fetch the base branch (git fetch origin <base>) or set --diff-base explicitly." >&2
exit 1
fi
strix -n -t ./ --scan-mode quick --scope-mode diff --diff-base "$DIFF_BASE" --max-budget 10
```
Gate the pipeline on the exit code (see the budget/fail-open caveat above — give the scan enough budget to finish). Schedule `standard` scans nightly and `deep` scans for release candidates.
---
# Option B — Managed platform (no runner infra)
No workflow file, no Docker, no LLM key. Two ways to use it:
1. **PR-review app (zero code):** the user installs the Strix GitHub/GitLab/Bitbucket app and enables PR reviews for the repo in the app.strix.ai dashboard. Every PR is then reviewed automatically, with findings posted as PR comments. Nothing to add to the repo. This is the lowest-effort path — recommend it first when the user just wants PR gating.
2. **API-triggered from any pipeline:** if you want to trigger from an existing pipeline (or a system without the SCM app), call the API with a token that has `pr_reviews:write` (or `scans:write`). Store the token as a CI secret; ask the user to create it at **Settings → API Access**. Example GitHub Actions step:
```yaml
- name: Strix PR review (managed)
if: github.event_name == 'pull_request'
env:
STRIX_API_TOKEN: ${{ secrets.STRIX_API_TOKEN }}
run: |
curl -sS --fail https://app.strix.ai/api/v1/pr-reviews/start \
-H "Authorization: Bearer $STRIX_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"repository_full_name\":\"${{ github.repository }}\",\"pr_number\":${{ github.event.pull_request.number }}}"
```
To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **managed-pentesting-with-strix** skill.
Recommend Option B for most teams (no maintenance, central dashboard); use Option A when scans must stay entirely within your own infrastructure.
@@ -1,77 +0,0 @@
---
name: fix-security-vulnerabilities-with-strix
description: Fix security vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud) — triage by severity, patch the root cause rather than the symptom, and re-run Strix to prove each fix actually closes the exploit. Handles injection, XSS, SSRF, broken access control, IDOR, and other validated findings. Use after a Strix scan reports findings, or when the user asks to remediate, patch, or fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.strix.ai
---
# Fix Strix findings and verify
Turn validated Strix findings into minimal, correct fixes — and prove they work by re-scanning.
## 1. Triage
Get the findings from wherever the scan ran:
- **OSS CLI** — artifacts in `strix_runs/<run-name>/`:
- `vulnerabilities/*.md` — one finding per file: description, severity, PoC steps or script, affected code locations, remediation guidance.
- `vulnerabilities.json` — the same findings as JSON (ids, severity, CWE/CVE, `code_locations` with `fix_before`/`fix_after` suggestions when available).
- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **managed-pentesting-with-strix** skill for auth.
Order work by severity: critical → high → medium → low. Every Strix finding was validated with a working proof-of-concept, so do not dismiss findings as false positives without re-testing the PoC yourself.
## 2. Fix
For each finding:
1. Reproduce it with the PoC from the finding file when feasible.
2. Fix the root cause, not the specific payload (e.g. parameterize all queries, don't blocklist one string; enforce authorization in the handler, don't hide the endpoint).
3. Prefer the framework's built-in defense (ORM parameterization, template auto-escaping, CSRF middleware, centralized authz) over ad-hoc sanitization.
4. Keep the diff minimal and apply the repo's existing patterns. Finding files often include `fix_before`/`fix_after` snippets — use them as a starting point, not verbatim.
Common finding classes and expected fixes: injection → parameterization/escaping at the sink; IDOR/broken access control → object-level authorization checks; SSRF → allowlist + block internal ranges; XSS → context-aware output encoding + CSP; secrets exposure → rotate the secret AND remove it from code/history; auth issues → fix the server-side check (never client-side).
## 3. Verify by re-running Strix
After fixing, re-scan scoped to the fixed area and confirm the finding is gone. Verify in whichever environment you scanned (or both):
**OSS CLI:**
```bash
# Re-test just the changed files (fast). Resolve the repo's real default
# branch instead of assuming origin/main (many repos use master/develop).
# Avoid the current branch's own upstream as the base — its merge base with
# HEAD would be HEAD, giving an empty diff and a falsely clean result.
DIFF_BASE=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
# origin/HEAD can be a dangling symbolic ref — keep it only if its target exists.
git rev-parse --verify --quiet "$DIFF_BASE" >/dev/null 2>&1 || DIFF_BASE=""
if [ -z "$DIFF_BASE" ]; then
for b in origin/main origin/master origin/develop; do
git rev-parse --verify --quiet "$b" >/dev/null && DIFF_BASE="$b" && break
done
fi
# No silent fallback: a guess like HEAD~1 would cover only the last commit of a
# multi-commit fix branch. If no base resolves, ask the user for the base branch
# (or use the focused --instruction verification below, which needs no diff base).
[ -n "$DIFF_BASE" ] || { echo "Set DIFF_BASE to the branch your fix will merge into." >&2; exit 1; }
strix -n -t ./ --scan-mode quick --scope-mode diff --diff-base "$DIFF_BASE" --max-budget 5
# Or re-test with the original finding as focus (no diff base needed)
strix -n -t ./ --instruction "Verify the SQL injection in app/api/search.py is fixed. Original PoC: <poc>" --max-budget 5
```
Exit codes: `2` = findings remain (read the new `strix_runs/<run>/vulnerabilities/` and iterate); `0` = clean **for what was analyzed**. Before trusting a `0`, confirm the run wasn't cut short — check `run.json` for a completed status and compare its `llm_usage.cost` with `--max-budget`: a hard budget stop leaves `status: "stopped"`, but a run that wrapped up on a budget warning records `"completed"` with partial coverage. Give verification enough budget to finish, and prefer re-running the specific PoC as the ground-truth signal.
**Cloud:** rerun with the same config and re-poll, then confirm the finding no longer appears:
```bash
new_id=$(curl -sS "$BASE/scans/$scan_id/rerun" "${auth[@]}" -X POST | jq -r .scan_id)
# poll GET /scans/$new_id until completed, then check its vulnerabilities[]
```
Or, if the cloud scan came from a repo/PR, trigger a fresh PR review on the fix branch (`POST /pr-reviews/start`). The platform also retests a single finding directly: `POST /api/v1/vulnerabilities/{vulnerabilityId}/retest`.
- Also re-run the PoC manually when it is a simple request/script — fastest signal.
- Run the project's own test suite to make sure the fix doesn't break behavior.
## 4. Report
Summarize per finding: severity, root cause, fix applied (file:line), verification result (re-scan clean / PoC no longer reproduces). Never include live secrets in the report; if a secret leaked, state that rotation is required.
@@ -1,152 +0,0 @@
---
name: managed-pentesting-with-strix
description: Run a managed pentest of a web app or API through the app.strix.ai REST API — no local Docker, LLM key, or install needed. Create an API token, register domain/repository assets, launch and poll scans, triage vulnerabilities, export SARIF, download PDF/DOCX pentest reports for SOC 2 and other compliance evidence (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants continuous or scheduled pentesting-as-a-service, an auditor-ready pentest report, scans tracked in a team dashboard, or security testing from a sandboxed agent/CI environment with no infrastructure.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.app.strix.ai
---
# Strix Cloud API (managed, no local infra)
Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **penetration-testing-with-strix** skill instead — both share the same engine and SARIF output, so you can mix them.
Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json`
## Setup
- **Base URL:** `https://app.strix.ai/api/v1`
- **Auth:** every request sends `Authorization: Bearer <token>`. Tokens are **org-scoped**.
- **Get a token:** the user creates one in the dashboard at **Settings → API Access** (app.strix.ai). Ask them for it; never hardcode, log, or commit it. Store it in an env var or the CI secret store.
- **Scopes (least-privilege):** assign only what the integration needs and rotate regularly:
| Scope | Grants |
|---|---|
| `scans:read` / `scans:write` | list/read/report scans · create/rerun/cancel scans |
| `vulnerabilities:read` / `:write` | read findings · update status & notes |
| `assets:read` / `:write` | read domains/repos · register/update them |
| `schedules:read` / `:write` | read schedules · create/trigger recurring scans |
| `pr_reviews:write` | trigger PR security reviews |
| `webhooks:read` / `:write` | manage webhook subscriptions |
| `tokens:write` | create/revoke API tokens |
```bash
export STRIX_API_TOKEN="<token>"
BASE=https://app.strix.ai/api/v1
auth=(-H "Authorization: Bearer $STRIX_API_TOKEN")
```
All examples use `jq` to parse JSON. Handle HTTP errors: `401` bad/expired token, `402` out of credits, `403` scope/plan-tier limit, `422` validation error.
## 1. Register the target as an asset
Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID.
```bash
# Domain (black-box / live target). Requires domain verification before external scanning.
# asset_type must be one of: web_app | api | attack_surface.
curl -sS "$BASE/domains" "${auth[@]}" -H "Content-Type: application/json" \
-d '{"domain":"staging.example.com","asset_type":"web_app"}' | jq '{id:.domain.id, status, reachable, verification}'
# Repository (white-box / code review). `full_name` is "owner/name".
# Send one repository object, or a bare JSON array for several — not an object
# wrapping a "repositories" key (that is rejected with 400).
curl -sS "$BASE/repositories" "${auth[@]}" -H "Content-Type: application/json" \
-d '[{"full_name":"org/app","provider":"github"}]' | jq '.repositories[] | {id, full_name}'
```
Look up existing assets instead of re-adding: `GET /domains`, `GET /repositories` (both `assets:read`, paginated with `?page=&limit=`).
## 2. Launch a scan
`POST /scans` (`scans:write`). Provide at least one target via `domain_ids`, `repository_ids`, or `internal_targets` (internal infra needs a network connector — see docs).
```bash
scan_id=$(curl -sS "$BASE/scans" "${auth[@]}" -H "Content-Type: application/json" -d '{
"engagement_type": "live_test",
"domain_ids": ["<domain-uuid>"],
"focus": "IDOR, auth bypass, SSRF",
"context": "Staging. Test account creds are configured as a test user.",
"notify_on_completion": true
}' | jq -r .scan_id)
echo "$scan_id"
```
Useful `CreateScanRequest` fields:
| Field | Purpose |
|---|---|
| `engagement_type` | `live_test` (default), `code_review`, `internal_infra`, `compliance_pentest` |
| `domain_ids` / `repository_ids` / `internal_targets` | targets (at least one) |
| `domain_paths` / `repository_branches` | narrow to specific paths / branches |
| `credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` |
| `headers` | extra HTTP headers (e.g. API keys) for the target |
| `focus` / `concerns` / `context` | steer the agents |
| `upload_ids` | attach uploaded source/docs archives for white-box context |
| `notify_on_completion` / `notification_emails` | email when done |
Response is `{ scan_id, title, status }` with `status` = `pending`.
## 3. Poll to completion
`GET /scans/{scanId}` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Poll on an interval — scans take minutes to hours; don't block.
```bash
while :; do
s=$(curl -sS "$BASE/scans/$scan_id" "${auth[@]}" | jq -r .status)
echo "status=$s"; [[ "$s" =~ ^(completed|failed|cancelled)$ ]] && break
sleep 60
done
```
## 4. Read findings
The scan-detail response includes `executive_summary`, `methodology`, `recommendations`, a `findings` severity roll-up, and a `vulnerabilities[]` array. Each vulnerability carries `title, severity, status, cvss, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code`, and (for code findings) `code_file`/`code_diff`/`code_before`/`code_after`.
```bash
curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \
| jq '["critical","high","medium","low","info"] as $order
| .vulnerabilities
| sort_by(.severity as $s | $order | index($s))
| .[] | {title, severity, endpoint, cwe}'
```
Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium).
Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill.
## 5. Export & report
```bash
# SARIF 2.1.0 for GitHub code scanning / ASPM ingestion
curl -sS "$BASE/scans/$scan_id/sarif" "${auth[@]}" -o findings.sarif
# Report. The format and file type are query params (`Accept` is ignored):
# format=technical (default) | retest | attestation | executive_summary
# type=pdf (default) | docx
# Any report download requires the Enterprise plan; formats beyond `technical`,
# DOCX, and white-label branding are Enterprise-only too. Scan must be completed.
curl -sS "$BASE/scans/$scan_id/report?format=technical&type=pdf" "${auth[@]}" -o strix-report.pdf
```
## 6. PR reviews
Trigger an automated security review of a pull request (`pr_reviews:write`); results appear as PR comments and in the dashboard:
```bash
curl -sS "$BASE/pr-reviews/start" "${auth[@]}" -H "Content-Type: application/json" \
-d '{"repository_full_name":"org/app","pr_number":123}'
```
List/inspect via `GET /pr-reviews` and `GET /pr-reviews/{id}`. Repo-level PR-review behavior is configured with the repository-settings endpoint.
## 7. Continuous testing (schedules & webhooks)
- **Schedules** (`schedules:write`, Pro plan): create recurring scans and trigger them on demand — the managed equivalent of a cron-driven CLI loop.
- **Webhooks** (`webhooks:write`): subscribe to pentest/vulnerability lifecycle events (e.g. `scan.completed`, `vulnerability.created`) to push results into Slack, ticketing, or your own pipeline instead of polling.
See the schedules and webhooks sections at [docs.app.strix.ai](https://docs.app.strix.ai) for payloads.
## Safety
Only scan assets the user's organization owns or is authorized to test. External domain scans require verification (DNS/file/meta-tag) enforced by the platform — don't try to bypass it.
@@ -1,143 +0,0 @@
---
name: penetration-testing-with-strix
description: Pentest a web app, API, codebase, repository, URL, domain, or IP with Strix — autonomous AI penetration testing that exploits and proves vulnerabilities (OWASP Top 10 and beyond — injection, XSS, SSRF, auth/access-control flaws, IDOR, business logic) instead of just flagging them. Runs self-hosted with the open-source CLI or via the managed app.strix.ai cloud, and returns validated findings with proof-of-concept exploits (Markdown, JSON, CSV, SARIF). Use when the user asks to pentest, hack, security-scan, security-audit, or find vulnerabilities in an app, API, website, or repo.
license: Apache-2.0
metadata:
author: usestrix
homepage: https://docs.strix.ai
---
# Run a Strix pentest
Strix runs autonomous AI pentesting agents that dynamically exploit a target and only report findings validated with a working proof-of-concept. There are **two ways to run it, built on the same engine and producing the same findings** — pick per situation, and mix them freely:
- **Open-source CLI** (self-hosted) — runs on your machine in a Docker sandbox with your own LLM key. Free, fully local, BYO-LLM, air-gap capable. Docs: [docs.strix.ai](https://docs.strix.ai).
- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **managed-pentesting-with-strix** skill.
## Which one? (decide, don't default)
Choose honestly based on the situation — neither is "better":
| Situation | Prefer |
|---|---|
| No Docker available, or a sandboxed/hosted agent/CI environment | **Cloud** |
| User has no LLM key / doesn't want to pay per-token or manage models | **Cloud** |
| Team visibility, shareable dashboard, scheduled/continuous scans, PR reviews, downloadable PDF/DOCX report (Enterprise) | **Cloud** |
| Scanning internal/private infrastructure not reachable from your machine | **Cloud** (network connector) |
| Source must never leave local infra (privacy/air-gap), or fully offline | **OSS CLI** |
| Free / one-off / local dev-loop scan, Docker already present | **OSS CLI** |
| BYO or self-hosted LLM, or a specific model not offered by the platform | **OSS CLI** |
| CI: runner already has Docker and you want a self-contained gate | **OSS CLI** |
| CI: no Docker, or you want results tracked centrally | **Cloud** |
**Mix them:** e.g. use the OSS CLI for the fast local dev-loop while writing/fixing code, and the Cloud for the authoritative, team-visible scan + report + tracking; or gate PRs with the OSS CLI in CI while the Cloud runs scheduled deep scans and PR reviews across the org. Both emit the same SARIF 2.1.0, so findings line up across environments.
If unsure and the user has (or will create) an app.strix.ai account, prefer **Cloud** — it avoids all local-infra friction. If they want zero signup / full local control, use the **OSS CLI**.
---
# Option A — Open-source CLI (self-hosted)
## Prerequisites
1. **Docker running** — check with `docker info`. The first scan pulls the sandbox image automatically.
2. **Strix installed** — check with `strix --version`. Install if missing:
```bash
curl -sSL https://strix.ai/install | bash # or: pipx install strix-agent
```
3. **LLM configured** — two environment variables:
```bash
export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id (openai/..., anthropic/..., openrouter/...)
export LLM_API_KEY="<provider api key>"
```
Ask the user for these if unset. Never hardcode or commit keys.
## Running a scan
Always use `-n` (non-interactive/headless) — the default TUI blocks agents. Always set `--max-budget` unless the user says otherwise.
```bash
# Local code (white-box)
strix -n -t ./ --scan-mode standard --max-budget 10
# Deployed app / API (black-box)
strix -n -t https://staging.example.com --max-budget 20
# Repo + deployed app together (best coverage)
strix -n -t https://github.com/org/app -t https://staging.example.com
# Focused testing with credentials or scope hints
strix -n -t https://app.example.com \
--instruction "Use credentials user@example.com:pass123. Focus on IDOR and auth bypass."
# Large monorepo: bind-mount instead of copying
strix -n --mount ./huge-monorepo
```
Key flags:
| Flag | Meaning |
|---|---|
| `-t, --target` | URL, repo URL, local path, domain, or IP. Repeatable. |
| `-n, --non-interactive` | Headless, exits on completion. Required for agents. |
| `-m, --scan-mode` | `quick` (minutes) / `standard` (~30 min) / `deep` (hours, default). |
| `--instruction` / `--instruction-file` | Credentials, focus areas, scope rules. |
| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. |
| `--max-turns N` | Per-agent turn cap (default 500). |
| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`. |
Scans take minutes (`quick`) to hours (`deep`). Run them in the background and poll for completion rather than blocking.
### Exit codes (headless)
- `0` — finished with no validated vulnerabilities **in what was analyzed**
- `1` — fatal error (missing env vars, Docker down, bad config)
- `2` — vulnerabilities found
A `0` is not proof of full coverage: if `--max-budget`/`--max-turns` is reached before the scan completes, it wraps up early and still exits `0`. When you need assurance the scan finished, give it enough budget and check `strix_runs/<run>/run.json`: a hard budget stop leaves `status: "stopped"`, but an agent that wrapped up early on a budget *warning* still calls `finish_scan` and records `"completed"` — so also sanity-check the run's cost against `--max-budget` and the report's stated coverage before treating a clean result as full coverage.
### Reading results
Artifacts land in `strix_runs/<run-name>/`:
| File | Contents |
|---|---|
| `penetration_test_report.md` | Executive report — read this first. |
| `vulnerabilities/*.md` | One file per validated finding, with PoC and remediation. |
| `vulnerabilities.json` / `vulnerabilities.csv` | All findings as structured JSON / CSV index. |
| `findings.sarif` | SARIF 2.1.0 for GitHub code scanning / ASPM ingestion. |
| `run.json` | Run metadata, status, targets, usage/cost. |
---
# Option B — Cloud API (managed, no local infra)
Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **managed-pentesting-with-strix** skill. Minimal launch-and-poll:
```bash
export STRIX_API_TOKEN="<token>" # org-scoped bearer, from Settings → API Access at app.strix.ai
BASE=https://app.strix.ai/api/v1
# 1. Launch a scan against an already-registered domain/repo asset
scan_id=$(curl -sS "$BASE/scans" \
-H "Authorization: Bearer $STRIX_API_TOKEN" -H "Content-Type: application/json" \
-d '{"engagement_type":"live_test","domain_ids":["<domain-uuid>"]}' | jq -r .scan_id)
# 2. Poll until terminal (pending → running → completed/failed/cancelled)
curl -sS "$BASE/scans/$scan_id" -H "Authorization: Bearer $STRIX_API_TOKEN" | jq '.status'
# 3. Read validated findings from the scan detail's `vulnerabilities[]`, or export SARIF
curl -sS "$BASE/scans/$scan_id/sarif" -H "Authorization: Bearer $STRIX_API_TOKEN" -o findings.sarif
```
Ask the user to create the token (and register the target as a domain/repository asset) if they haven't. If Docker/local prerequisites aren't already satisfied, use this path instead of trying to install infra.
---
## Reporting & next steps
Summarize findings by severity (critical/high/medium/low/info) and include the PoC evidence. To remediate and verify fixes (via either path), use the **fix-security-vulnerabilities-with-strix** skill. To wire scanning into CI/CD, use the **ci-security-scanning-with-strix** skill.
## Safety
Only scan targets the user owns or is authorized to test. The Cloud platform enforces domain verification before external scans; for the OSS CLI, confirm authorization yourself if the target looks like third-party infrastructure.
+2 -20
View File
@@ -25,7 +25,6 @@ from strix.tools.agents_graph.tools import (
view_agent_graph,
wait_for_agents,
)
from strix.tools.coverage.tools import list_coverage, record_coverage, update_coverage
from strix.tools.finish.tool import finish_scan
from strix.tools.load_skill.tool import load_skill
from strix.tools.notes.tools import (
@@ -52,11 +51,6 @@ from strix.tools.reporting.tool import (
)
from strix.tools.respond.tool import respond_to_user
from strix.tools.thinking.tool import think
from strix.tools.threat_model.tools import (
amend_threat_model,
get_threat_model,
save_threat_model,
)
from strix.tools.todo.tools import (
create_todo,
delete_todo,
@@ -166,9 +160,7 @@ def _schema_types(spec: dict[str, Any]) -> set[str]:
def _decode_structured(value: str, types: set[str]) -> Any:
stripped = value.strip()
if not stripped:
# 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 {}
return value
try:
decoded = json.loads(stripped)
except json.JSONDecodeError:
@@ -504,12 +496,6 @@ _BASE_TOOLS: tuple[Tool, ...] = (
get_note,
update_note,
delete_note,
record_coverage,
update_coverage,
list_coverage,
get_threat_model,
save_threat_model,
amend_threat_model,
web_search,
create_vulnerability_report,
create_dependency_report,
@@ -573,12 +559,11 @@ def registered_agent_tools() -> tuple[Tool, ...]:
def build_strix_agent(
*,
name: str = "agent",
name: str = "strix",
skills: list[str] | None = None,
is_root: bool,
scan_mode: str = "deep",
is_whitebox: bool = False,
is_diff_scoped: bool = False,
interactive: bool = False,
chat_completions_tools: bool = False,
system_prompt_context: dict[str, Any] | None = None,
@@ -603,7 +588,6 @@ def build_strix_agent(
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_root=is_root,
is_diff_scoped=is_diff_scoped,
interactive=interactive,
system_prompt_context=system_prompt_context,
)
@@ -659,7 +643,6 @@ def make_child_factory(
*,
scan_mode: str = "deep",
is_whitebox: bool = False,
is_diff_scoped: bool = False,
interactive: bool = False,
chat_completions_tools: bool = False,
system_prompt_context: dict[str, Any] | None = None,
@@ -678,7 +661,6 @@ def make_child_factory(
is_root=False,
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_diff_scoped=is_diff_scoped,
interactive=interactive,
chat_completions_tools=chat_completions_tools,
system_prompt_context=system_prompt_context,
+3 -19
View File
@@ -23,44 +23,30 @@ def _resolve_skills(
scan_mode: str = "deep",
is_whitebox: bool = False,
is_root: bool = False,
is_diff_scoped: bool = False,
) -> list[str]:
"""Build the deduped, ordered skills list for the prompt render.
Order:
1. Whatever the caller asked for, in order.
2. ``scan_modes/<mode>`` (always), plus ``scan_modes/diff`` when the
run is scoped to a change set — diff scope overlays the depth
mode rather than replacing it.
2. ``scan_modes/<mode>`` (always).
3. ``tooling/agent_browser`` (always — every agent has shell + the
agent-browser CLI).
4. ``tooling/python`` (always — Python runs through ``exec_command``;
sandbox scripts can import ``caido_api`` for Caido automation).
5. ``analysis/counterevidence`` and ``analysis/severity_calibration``
(always — closure discipline and severity rubric apply to every
agent that can open or close a candidate, or file a report).
6. ``coordination/root_agent`` for the root agent only — orchestration
5. ``coordination/root_agent`` for the root agent only — orchestration
guidance for delegating to specialist subagents.
7. Whitebox-specific skills if applicable, including
``analysis/fix_verification`` (only whitebox agents can attach an
applyable ``fix_after``) and ``analysis/source_aware_discovery``.
6. Whitebox-specific skills if applicable.
"""
ordered: list[str] = list(requested or [])
ordered.append(f"scan_modes/{scan_mode}")
if is_diff_scoped:
ordered.append("scan_modes/diff")
ordered.append("tooling/agent_browser")
ordered.append("tooling/python")
ordered.append("analysis/counterevidence")
ordered.append("analysis/severity_calibration")
if is_root:
ordered.append("coordination/root_agent")
if is_whitebox:
ordered.append("coordination/source_aware_whitebox")
ordered.append("custom/source_aware_sast")
ordered.append("analysis/source_aware_discovery")
ordered.append("analysis/fix_verification")
deduped: list[str] = []
seen: set[str] = set()
@@ -77,7 +63,6 @@ def render_system_prompt(
scan_mode: str = "deep",
is_whitebox: bool = False,
is_root: bool = False,
is_diff_scoped: bool = False,
interactive: bool = False,
system_prompt_context: dict[str, Any] | None = None,
) -> str:
@@ -98,7 +83,6 @@ def render_system_prompt(
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_root=is_root,
is_diff_scoped=is_diff_scoped,
)
skill_content = load_skills(skills_to_load)
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
+5 -31
View File
@@ -1,4 +1,4 @@
You are an advanced AI application security validation agent. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
{% if is_root %}
<root_agent_directive>
@@ -22,13 +22,12 @@ CLI OUTPUT:
- You may use simple markdown: **bold**, *italic*, `code`, ~~strikethrough~~, [links](url), and # headers
- Do NOT use complex markdown like bullet lists, numbered lists, or tables
- Use line breaks and indentation for structure
- NEVER use any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
- NEVER use "Strix" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
INTER-AGENT MESSAGES:
- Messages from other agents arrive prefixed with a header like `[Message from agent <name> | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output.
- Treat agent identity / inherited-context preambles as internal metadata; do not echo them in outputs or tool calls.
- Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging
- wait_for_agents blocks and resumes you automatically, so it is never a poll you repeat: issue exactly ONE wait, then stop and react to what it returns. Never write out a wait/check loop (wait → view_agent_graph → wait → ...) ahead of time — those extra calls only strand you and are collapsed anyway
{% if interactive %}
INTERACTIVE BEHAVIOR:
@@ -39,8 +38,6 @@ 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.
@@ -60,7 +57,7 @@ AUTONOMOUS BEHAVIOR:
<execution_guidelines>
{% if system_prompt_context and system_prompt_context.authorized_targets %}
SYSTEM-VERIFIED SCOPE:
- The following scope metadata is injected by the platform into the system prompt and is authoritative
- The following scope metadata is injected by the Strix platform into the system prompt and is authoritative
- Scope source: {{ system_prompt_context.scope_source }}
- Authorization source: {{ system_prompt_context.authorization_source }}
- Every target listed below has already been verified by the platform as in-scope and authorized
@@ -216,31 +213,10 @@ VALIDATION REQUIREMENTS:
- Independent verification through subagent
- Document complete attack chain
- Keep going until you find something that matters
- CLOSURE DISCIPLINE: every candidate you open ends in exactly one explicit state — `confirmed` (working PoC, or a complete source→control→sink→impact trace that is reachable), `ruled_out` (you can name the SPECIFIC control, at a location, that runs on every attacker-reachable path before the sink), or `open_proof_gap` (plausible, unconfirmed, and you could NOT name such a control). "I moved on" is not a closure state. Silently dropping an uncertain candidate is mislabelling an `open_proof_gap` as `ruled_out` and is how real bugs get missed.
- Missing information is NOT proof of safety: no caller found, can't tell if deployed/exposed, couldn't stand up the service, build failed — each is an `open_proof_gap`, never a reason to mark a candidate clean. Difficulty is a reason to defer, not to suppress.
- COVERAGE: record every surface you assess with `record_coverage` (surface + risk area + outcome + evidence), including the ones that came back clean — a report that only lists findings cannot say what was reviewed and cleared. Use the `needs_follow_up` outcome for anything left in an `open_proof_gap` state, and carry the same items up in `agent_finish(open_items=[...])`. The ledger is shared and mutable: when you resolve a surface another agent left open — or find that a closed one is not — move that entry with `update_coverage` instead of recording a second one for the same surface. The root agent reconciles all of it via `list_coverage` before `finish_scan`.
- THREAT MODEL: before you start testing, call `get_threat_model` on the target you were pointed at — it is the scan's shared answer to who the attacker is, where the trust boundaries sit, and what counts as critical here, and it is cached per target rather than per scan. Read it instead of re-deriving trust boundaries yourself; where your testing disproves it — a boundary it calls trusted turns out to be attacker-reachable, a role it did not know about, a host or endpoint it never listed — record that with `amend_threat_model` so the agents after you inherit the correction. Amending is not optional politeness: a model nobody corrects turns the first agent's guesses into everyone's assumptions.
- Before filing any report, run the counterevidence pass: argue the strongest case AGAINST the finding, record what you found in the `counterevidence` field, set `confidence` honestly (a static-only trace you couldn't execute is at best `medium`), and state what evidence would change the severity. See the counterevidence and severity-calibration knowledge above.
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
STATE & COORDINATION TOOLS (when and how):
Every one of these tools writes to state the rest of the scan reads. Reaching for the tool is not optional bookkeeping — the agent after you sees your state, not your reasoning, so state you never wrote is context the scan permanently loses.
- PLAN — `think`: use before any non-trivial or multi-step move to reason through approach, uncertainty, or what to do next. NOT for acknowledgements, summaries, or as filler before a final answer.
- SKILLS — `load_skill`: the skills matching your task are already inlined below under `<specialized_knowledge>`; `<available_skills>` lists the rest by name. When you are about to test a vuln class, protocol, tool, or framework whose skill is not already inlined, `load_skill` it FIRST and follow it, rather than guessing payloads or tool syntax from memory.
- TODOS — `create_todo` / `list_todos` / `update_todo` / `mark_todo_done` / `mark_todo_pending` / `delete_todo`: your own working checklist for a multi-step task. Create todos when your task has several distinct steps so nothing is dropped across a long run; mark them done as you finish. This is private working memory — use `notes` for anything another agent needs.
- NOTES — `create_note` / `list_notes` / `get_note` / `update_note` / `delete_note`: the scan's shared scratchpad, visible to every agent. Write a note for a durable cross-agent fact that is not a finding and not coverage — a working credential set, a discovered endpoint inventory, an enumerated tenant list, a rate-limit quirk the next agent needs. `update_note` to keep a living inventory current; `delete_note` only for something now wrong or superseded. Check `list_notes`/`get_note` before recon work so you build on what is already mapped instead of redoing it.
- THREAT MODEL — `get_threat_model` / `amend_threat_model` / `save_threat_model`: covered above. `save_threat_model` REPLACES the whole document and clears amendments, so it is for establishing the baseline or folding amendments in (normally root) — to correct part of an existing model, `amend_threat_model` instead.
- COVERAGE — `record_coverage` / `update_coverage` / `list_coverage`: covered above. One row per surface+risk; correct an existing row with `update_coverage`, never a second `record_coverage`.
- RESEARCH — `web_search`: pull fresh, target-specific external knowledge — latest bypasses, WAF evasions, DB-/framework-specific syntax, CVE and advisory detail — before falling back to memorized payloads, and refresh payload corpora mid-spray.
- SPAWN WORK — `create_agent`: delegate a focused subtask to a specialist child (see the multi-agent rules below for when to spawn and how to scope it). Give it the target to model against and what is already known.
- TRACK CHILDREN — `view_agent_graph`: your live map of every agent and its status. Call it before spawning (to confirm no existing agent already covers the scope) and before finishing (to confirm no child is still running).
- STEER CHILDREN — `send_message_to_agent`: send a running child new information, a course correction, or a request to wrap up, without killing it. Use it to answer a child's question or narrow its scope mid-run.
- BLOCK ON CHILDREN — `wait_for_agents`: block until named children report back when your next move genuinely depends on their results. If you can keep making progress in parallel, keep working instead of waiting.
- CANCEL CHILDREN — `stop_agent`: gracefully cancel a child whose work is redundant, misdirected, or no longer needed. Prefer `send_message_to_agent` to redirect a child that is merely off-track; reserve `stop_agent` for work that should not continue at all.
- FINISH — subagents call `agent_finish` (with `open_items=[...]` for anything left unresolved); the root agent calls `finish_scan` exactly once, only after every child is wrapped up and coverage is reconciled. `agent_finish`/`finish_scan` are handoffs, not reporting channels — a vulnerability is reported only via `create_vulnerability_report`/`create_dependency_report`.
</execution_guidelines>
<vulnerability_focus>
@@ -513,10 +489,8 @@ 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, skills in available_skills | dictsort -%}
{% for skill in skills -%}
- {{ category }}/{{ skill.name }}{% if skill.description %}: {{ skill.description }}{% endif %}
{% endfor -%}
{% for category, names in available_skills | dictsort -%}
- {{ category }}: {{ names | join(', ') }}
{% endfor -%}
</available_skills>
{% endif %}
+8 -3
View File
@@ -24,8 +24,6 @@ from typing import TYPE_CHECKING, Any
import requests
from strix.utils.secret_files import write_secret_text
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -69,7 +67,14 @@ def _read_store() -> dict[str, Any]:
def _write_store(data: dict[str, Any]) -> None:
write_secret_text(AUTH_PATH, json.dumps(data, indent=2))
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = AUTH_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
with contextlib.suppress(OSError):
tmp.chmod(0o600)
tmp.replace(AUTH_PATH)
with contextlib.suppress(OSError):
AUTH_PATH.chmod(0o600)
def read_record() -> dict[str, Any] | None:
+4 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import json
import logging
import os
@@ -11,7 +12,6 @@ from typing import TYPE_CHECKING, Any
from pydantic import AliasChoices, BaseModel
from strix.config.settings import Settings
from strix.utils.secret_files import write_secret_text
if TYPE_CHECKING:
@@ -71,7 +71,9 @@ def persist_current() -> None:
env_block[alias.upper()] = value
break
write_secret_text(target, json.dumps({"env": env_block}, indent=2))
target.write_text(json.dumps({"env": env_block}, indent=2), encoding="utf-8")
with contextlib.suppress(OSError):
target.chmod(0o600)
def _aliases_for(finfo: FieldInfo) -> list[str]:
+7 -194
View File
@@ -2,14 +2,11 @@
from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
import os
import time
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from agents import (
set_default_openai_api,
@@ -27,19 +24,12 @@ from agents.retry import (
RetryPolicyContext,
retry_policies,
)
from openai.types.responses import (
Response,
ResponseCompletedEvent,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
)
from openai.types.responses import Response, ResponseCompletedEvent
from openai.types.responses.response_usage import ResponseUsage
from openai.types.shared import Reasoning
from strix.config import codex
from strix.config.loader import load_settings
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
from strix.config.tool_call_limits import TurnToolCallLimiter
if TYPE_CHECKING:
@@ -58,9 +48,6 @@ if TYPE_CHECKING:
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
logger = logging.getLogger(__name__)
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
if not timeout_s or timeout_s <= 0:
@@ -242,170 +229,6 @@ class _NonStreamingModel(Model):
yield _completed_stream_event(response, getattr(self._inner, "model", None))
class _TurnGuardModel(Model):
"""Keep one turn from corrupting the conversation or running away.
Tool-call ids: providers that number calls per turn (``exec_command:0``,
...) restart the counter each turn, so the same id eventually appears twice
in one conversation and strict providers reject every subsequent request.
Ids that collide with the history are rewritten before the turn is
recorded, and already-corrupted histories are repaired on the way out.
Tool-call volume: a degenerate response can queue hundreds of calls that
the run loop then honours one by one. Only the first
``LLM_MAX_TOOL_CALLS_PER_TURN`` calls of a response are kept.
Stalled streams: a turn that emits a few tokens and then goes silent is
not covered by the request timeout, which resets on any byte (keepalives
included). ``LLM_STREAM_IDLE_TIMEOUT`` bounds the gap between events so the
turn fails instead of hanging, and the existing retry path replays it.
"""
def __init__(
self,
inner: Model,
*,
max_tool_calls_per_turn: int = 0,
stream_idle_timeout: float = 0.0,
) -> None:
self._inner = inner
self._max_tool_calls_per_turn = max_tool_calls_per_turn
self._stream_idle_timeout = stream_idle_timeout
def _limiter(self) -> TurnToolCallLimiter:
return TurnToolCallLimiter(self._max_tool_calls_per_turn)
def _log_dropped(self, limiter: TurnToolCallLimiter) -> None:
if limiter.dropped:
logger.warning(
"dropped %d tool call(s) past the per-response limit of %d",
limiter.dropped,
self._max_tool_calls_per_turn,
)
async def close(self) -> None:
await self._inner.close()
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
return self._inner.get_retry_advice(request)
async def get_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> ModelResponse:
sanitized = dedupe_input(input)
rewriter = TurnCallIdRewriter(sanitized)
response = await self._inner.get_response(
system_instructions,
cast("str | list[TResponseInputItem]", sanitized),
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
limiter = self._limiter()
response.output = limiter.filter_items(rewriter.rewrite_items(list(response.output)))
self._log_dropped(limiter)
return response
async def stream_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> AsyncIterator[TResponseStreamEvent]:
sanitized = dedupe_input(input)
rewriter = TurnCallIdRewriter(sanitized)
limiter = self._limiter()
stream = self._inner.stream_response(
system_instructions,
cast("str | list[TResponseInputItem]", sanitized),
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
async for event in _with_idle_timeout(stream, self._stream_idle_timeout):
guarded = _guard_event(event, rewriter, limiter)
if guarded is not None:
yield guarded
self._log_dropped(limiter)
async def _aclose(stream: AsyncIterator[TResponseStreamEvent]) -> None:
if isinstance(stream, AsyncGenerator):
with contextlib.suppress(Exception):
await stream.aclose()
async def _with_idle_timeout(
stream: AsyncIterator[TResponseStreamEvent], timeout: float
) -> AsyncIterator[TResponseStreamEvent]:
if timeout <= 0:
async for event in stream:
yield event
return
iterator = stream.__aiter__()
while True:
try:
event = await asyncio.wait_for(iterator.__anext__(), timeout)
except StopAsyncIteration:
return
except TimeoutError:
await _aclose(stream)
message = f"model stream produced no event for {timeout:.0f}s"
logger.warning("%s; abandoning the turn", message)
raise TimeoutError(message) from None
yield event
def _guard_event(
event: TResponseStreamEvent, rewriter: TurnCallIdRewriter, limiter: TurnToolCallLimiter
) -> TResponseStreamEvent | None:
if isinstance(event, ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent):
rewritten = rewriter.rewrite_item(event.item)
if not limiter.allow(rewritten):
return None
if rewritten is not event.item:
return event.model_copy(update={"item": rewritten})
return event
if isinstance(event, ResponseCompletedEvent):
original = list(event.response.output)
output = limiter.filter_items(rewriter.rewrite_items(original))
if output != original:
return event.model_copy(
update={"response": event.response.model_copy(update={"output": output})}
)
return event
def _completed_stream_event(
model_response: ModelResponse, model_name: object | None
) -> TResponseStreamEvent:
@@ -471,29 +294,19 @@ class StrixProvider(MultiProvider):
def get_model(self, model_name: str | None) -> Model:
llm = load_settings().llm
slug = codex.subscription_model(model_name)
idle_timeout = float(llm.stream_idle_timeout)
if slug:
# The ChatGPT subscription backend is always streamed; it has no
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
# does not apply here.
model: Model = _CodexResponsesModel(
return _CodexResponsesModel(
slug,
codex.get_subscription_client(),
reasoning_effort=llm.reasoning_effort,
)
else:
model = super().get_model(model_name)
if llm.disable_streaming:
model = _NonStreamingModel(model)
# The wrapper emits its single event only once the whole request
# is done, so an idle gap is meaningless here; the request
# timeout bounds it instead.
idle_timeout = 0.0
return _TurnGuardModel(
model,
max_tool_calls_per_turn=llm.max_tool_calls_per_turn,
stream_idle_timeout=idle_timeout,
)
model = super().get_model(model_name)
if llm.disable_streaming:
return _NonStreamingModel(model)
return model
DEFAULT_MODEL_RETRY = ModelRetrySettings(
+1 -12
View File
@@ -57,12 +57,6 @@ class LlmSettings(BaseSettings):
alias="LLM_DISABLE_STREAMING",
)
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
max_tool_calls_per_turn: int = Field(
default=32,
ge=0,
alias="LLM_MAX_TOOL_CALLS_PER_TURN",
)
class DedupeSettings(BaseSettings):
@@ -106,7 +100,7 @@ class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG
image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.3.0",
default="ghcr.io/usestrix/strix-sandbox:1.2.0",
alias="STRIX_IMAGE",
)
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
@@ -128,11 +122,6 @@ class IntegrationSettings(BaseSettings):
alias="PERPLEXITY_API_KEY",
repr=False,
)
postman_api_key: str | None = Field(
default=None,
alias="POSTMAN_API_KEY",
repr=False,
)
class ViewerSettings(BaseSettings):
-117
View File
@@ -1,117 +0,0 @@
"""Keep tool-call ids unique within a conversation.
Some providers return per-turn tool-call ids (``exec_command:0``,
``exec_command:1``, ...) whose counter restarts on every turn. Once the same
id appears twice in one conversation, the request payload has two assistant
tool calls sharing an id and strict providers reject the whole turn, which
permanently kills the agent because the malformed history is replayed on
every retry. Rewriting duplicates to fresh unique ids keeps the history
valid for any provider.
"""
from __future__ import annotations
from collections import defaultdict, deque
from typing import Any
from uuid import uuid4
from openai.types.responses import ResponseFunctionToolCall
def new_call_id() -> str:
return f"call_{uuid4().hex}"
def collect_call_ids(items: list[Any]) -> set[str]:
used: set[str] = set()
for item in items:
if isinstance(item, dict):
call_id = item.get("call_id")
if isinstance(call_id, str):
used.add(call_id)
elif isinstance(item, ResponseFunctionToolCall):
used.add(item.call_id)
return used
def dedupe_history_call_ids(items: list[Any]) -> tuple[list[Any], bool]:
"""Rewrite duplicate call ids in a conversation history.
Outputs are paired with their call by order, so parallel calls that share
an id keep answering the right call after the rewrite.
"""
used: set[str] = set()
pending: dict[str, deque[str]] = defaultdict(deque)
rebuilt: list[Any] = []
changed = False
for item in items:
if not isinstance(item, dict):
rebuilt.append(item)
continue
call_id = item.get("call_id")
if not isinstance(call_id, str):
rebuilt.append(item)
continue
kind = item.get("type")
if kind == "function_call":
effective = call_id
if call_id in used:
effective = new_call_id()
item = {**item, "call_id": effective} # noqa: PLW2901
changed = True
used.add(effective)
pending[call_id].append(effective)
elif kind == "function_call_output":
queue = pending.get(call_id)
if queue:
effective = queue.popleft()
if effective != call_id:
item = {**item, "call_id": effective} # noqa: PLW2901
changed = True
rebuilt.append(item)
return rebuilt, changed
def dedupe_input(model_input: str | list[Any]) -> str | list[Any]:
if isinstance(model_input, str):
return model_input
rebuilt, changed = dedupe_history_call_ids(model_input)
return rebuilt if changed else model_input
class TurnCallIdRewriter:
"""Rewrite a single turn's tool-call ids that collide with the history.
A turn's items surface several times (streamed item events, then the
completed response), so the same original id must always map to the same
replacement within the turn.
"""
def __init__(self, model_input: str | list[Any]) -> None:
self._used = set() if isinstance(model_input, str) else collect_call_ids(model_input)
self._remap: dict[str, str] = {}
self._settled: set[str] = set()
def rewrite_item(self, item: Any) -> Any:
if not isinstance(item, ResponseFunctionToolCall):
return item
original = item.call_id
if original in self._settled:
return item
replacement = self._remap.get(original)
if replacement is None:
if original not in self._used:
self._used.add(original)
self._settled.add(original)
return item
replacement = new_call_id()
self._remap[original] = replacement
self._used.add(replacement)
self._settled.add(replacement)
return item.model_copy(update={"call_id": replacement})
def rewrite_items(self, items: list[Any]) -> list[Any]:
return [self.rewrite_item(item) for item in items]
-46
View File
@@ -1,46 +0,0 @@
"""Bound how many tool calls one assistant response may queue.
A degenerate generation can emit hundreds or thousands of tool calls in a
single response — typically a poll/wait loop the model writes out ahead of
time instead of issuing one call and yielding. The run loop honours all of
them, so the agent stops reacting to anything for hours. Keeping only the
first ``limit`` calls of a response bounds that blast radius; the model sees
their results on the next turn and can reconsider.
"""
from __future__ import annotations
from typing import Any
from openai.types.responses import ResponseFunctionToolCall
class TurnToolCallLimiter:
"""Decide, once per call, whether a turn's tool call is within the limit."""
def __init__(self, limit: int) -> None:
self._limit = limit
self._decisions: dict[str, bool] = {}
self._kept = 0
self.dropped = 0
@property
def enabled(self) -> bool:
return self._limit > 0
def allow(self, item: Any) -> bool:
if not self.enabled or not isinstance(item, ResponseFunctionToolCall):
return True
decided = self._decisions.get(item.call_id)
if decided is not None:
return decided
allowed = self._kept < self._limit
if allowed:
self._kept += 1
else:
self.dropped += 1
self._decisions[item.call_id] = allowed
return allowed
def filter_items(self, items: list[Any]) -> list[Any]:
return [item for item in items if self.allow(item)]
+5 -9
View File
@@ -780,21 +780,17 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
await coordinator.set_status(agent_id, "failed", error=str(exc))
await notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
if not interactive:
raise
if isinstance(exc, MaxTurnsExceeded):
status: Status = "stopped"
elif isinstance(exc, UserError | AgentsException | APIError):
status = "failed"
else:
status = "crashed"
logger.exception("agent run failed for %s; marking %s", agent_id, status)
# Settle the status and wake the parent before the exception unwinds a
# non-interactive agent's task: a child that dies still owes its parent a
# report, and the parent would otherwise wait out its timeout on a message
# the dead child can no longer send.
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
await notify_parent_on_terminal(coordinator, agent_id, status)
if not interactive:
raise
return None
else:
return cast("RunResultBase | None", stream)
@@ -830,7 +826,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 — with no message if you have already said it. "
"call respond_to_user. "
"If you are blocked waiting for another agent, call wait_for_agents. "
f"If the whole engagement is complete, call {finish_tool}. "
"Otherwise use the appropriate execution or planning tool. "
@@ -838,7 +834,7 @@ async def _append_tool_required_message(
)
else:
message = (
"Your previous response ended the autonomous run without a lifecycle tool "
"Your previous response ended the autonomous Strix run without a lifecycle tool "
"call. That is invalid in non-interactive mode; plain text final answers are "
"ignored. Continue immediately and call exactly one tool. "
f"If your work is complete, call {finish_tool}. "
-3
View File
@@ -20,8 +20,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
LLM_TURN_KEY = "llm_turn"
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
@@ -146,7 +144,6 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
system_prompt: str | None, # noqa: ARG002
input_items: list[TResponseInputItem],
) -> None:
context.context[LLM_TURN_KEY] = int(context.context.get(LLM_TURN_KEY, 0)) + 1
try:
self._maybe_warn_turns(context, input_items)
self._maybe_warn_budget(context, input_items)
+15 -83
View File
@@ -33,50 +33,6 @@ def _accepts_required_tool_choice(model_name: str | None) -> bool:
return name.startswith("openai/") or is_known_openai_bare_model(name)
def _render_diff_scope(diff_scope: dict[str, Any]) -> list[str]:
"""Render pull-request diff-scope constraints as root-task lines."""
if not diff_scope.get("active"):
return []
parts: list[str] = [
"\n\nScope Constraints:",
"- Pull request diff-scope mode is active. Prioritize changed files "
"and use other files only for context.",
]
for repo_scope in diff_scope.get("repos", []) or []:
label = repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
changed = repo_scope.get("analyzable_files_count", 0)
deleted = repo_scope.get("deleted_files_count", 0)
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
if deleted:
parts.append(f"- {label}: {deleted} deleted file(s) are context-only")
return parts
def _render_api_spec(details: dict[str, Any]) -> list[str]:
"""Render an API spec target as root-task lines.
The spec itself is in the workspace, so the task points at the file and lets
the agent read the contract rather than restating a parsed summary of it.
"""
title = details.get("spec_title") or details.get("target_spec", "API")
workspace_path = details.get("workspace_path", "")
lines = [
f"- {title} ({details.get('spec_format', 'api')} specification"
+ (f", available at: {workspace_path}" if workspace_path else "")
+ ")"
]
if base_urls := details.get("base_urls") or []:
lines.append(" - Base URL(s): " + ", ".join(base_urls))
lines.append(
" - Read the specification and test every operation it declares, using "
"its declared parameters, request bodies, and auth. Endpoints in the "
"specification are in scope even when nothing links to them. Load the "
"`api_spec_testing` skill for the methodology, or spawn a specialist "
"with it."
)
return lines
def build_root_task(scan_config: dict[str, Any]) -> str:
targets = scan_config.get("targets", []) or []
diff_scope = scan_config.get("diff_scope") or {}
@@ -87,7 +43,6 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
"Local Codebases": [],
"URLs": [],
"IP Addresses": [],
"API Specifications": [],
}
for target in targets:
@@ -113,8 +68,6 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
sections["URLs"].append(f"- {details.get('target_url', '')}")
elif ttype == "ip_address":
sections["IP Addresses"].append(f"- {details.get('target_ip', '')}")
elif ttype == "api_spec":
sections["API Specifications"].extend(_render_api_spec(details))
parts: list[str] = []
for label, items in sections.items():
@@ -138,17 +91,22 @@ 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))
if diff_scope.get("active"):
parts.append("\n\nScope Constraints:")
parts.append(
"- Pull request diff-scope mode is active. Prioritize changed files "
"and use other files only for context.",
)
for repo_scope in diff_scope.get("repos", []) or []:
label = (
repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
)
changed = repo_scope.get("analyzable_files_count", 0)
deleted = repo_scope.get("deleted_files_count", 0)
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
if deleted:
parts.append(f"- {label}: {deleted} deleted file(s) are context-only")
task = " ".join(parts)
if user_instructions:
@@ -163,7 +121,6 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
"local_code": "target_path",
"web_application": "target_url",
"ip_address": "target_ip",
"api_spec": "target_spec",
}
for target in scan_config.get("targets", []) or []:
ttype = target.get("type", "unknown")
@@ -177,14 +134,6 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
{"type": ttype, "value": value, "workspace_path": workspace_path},
)
# An API spec authorizes the hosts it declares as in-scope web targets
# so the agent can exercise every endpoint without expanding scope.
if ttype == "api_spec":
authorized.extend(
{"type": "web_application", "value": base_url, "workspace_path": ""}
for base_url in details.get("base_urls") or []
)
return {
"scope_source": "system_scan_config",
"authorization_source": "strix_platform_verified_targets",
@@ -193,23 +142,6 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
}
def build_scan_targets(scan_config: dict[str, Any]) -> list[str]:
"""One canonical string per authorized target.
Agents refer to the target in whatever words they were handed, so anything
keyed on a target the model types drifts apart across a run. This is the
scan's own spelling, which target-keyed tools resolve against. A checkout is
named by its workspace path rather than its remote URL, so the local tree —
and its revision — is what gets inspected.
"""
targets: list[str] = []
for target in build_scope_context(scan_config)["authorized_targets"]:
value = target["workspace_path"] or target["value"]
if value and value not in targets:
targets.append(value)
return targets
def make_model_settings(
reasoning_effort: ReasoningEffort | None,
*,
+5 -28
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import contextlib
import io
import json
@@ -36,7 +35,6 @@ from strix.core.execution import (
from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags
from strix.core.inputs import (
build_root_task,
build_scan_targets,
build_scope_context,
make_model_settings,
)
@@ -84,7 +82,6 @@ def _compose_root_instructions_override(
skills: list[str],
scan_mode: str,
is_whitebox: bool,
is_diff_scoped: bool,
interactive: bool,
system_prompt_context: dict[str, Any],
) -> str | None:
@@ -96,7 +93,6 @@ def _compose_root_instructions_override(
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_root=True,
is_diff_scoped=is_diff_scoped,
interactive=interactive,
system_prompt_context=system_prompt_context,
)
@@ -179,13 +175,11 @@ async def run_strix_scan(
coordinator = AgentCoordinator()
coordinator.set_snapshot_path(agents_path)
from strix.tools.coverage.tools import hydrate_coverage_from_disk
from strix.tools.notes.tools import hydrate_notes_from_disk
from strix.tools.todo.tools import hydrate_todos_from_disk
hydrate_todos_from_disk(state_dir)
hydrate_notes_from_disk(state_dir)
hydrate_coverage_from_disk(state_dir)
root_id: str | None = None
if is_resume:
@@ -258,8 +252,6 @@ async def run_strix_scan(
targets = scan_config.get("targets") or []
scan_mode = str(scan_config.get("scan_mode") or "deep")
is_whitebox = any(t.get("type") == "local_code" for t in targets)
diff_scope = scan_config.get("diff_scope")
is_diff_scoped = bool(isinstance(diff_scope, dict) and diff_scope.get("active"))
skills = list(scan_config.get("skills") or [])
root_task = build_root_task(scan_config)
model_settings = make_model_settings(
@@ -276,9 +268,6 @@ async def run_strix_scan(
model_settings=model_settings,
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
trace_include_sensitive_data=False,
# A hallucinated tool name is a recoverable model mistake, not a scan-ending
# error: hand it back as a tool result so the agent can correct itself.
tool_not_found_behavior="return_error_to_model",
)
hooks = ReportUsageHooks(
model=resolved_model,
@@ -296,18 +285,16 @@ async def run_strix_scan(
skills=skills,
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_diff_scoped=is_diff_scoped,
interactive=interactive,
system_prompt_context=root_context,
)
root_agent = build_strix_agent(
name="Root Agent",
name="Strix",
skills=skills,
is_root=True,
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_diff_scoped=is_diff_scoped,
interactive=interactive,
chat_completions_tools=chat_completions_tools,
system_prompt_context=root_context,
@@ -317,7 +304,7 @@ async def run_strix_scan(
if not is_resume:
await coordinator.register(
root_id,
"Root Agent",
"Strix",
parent_id=None,
task=root_task,
skills=skills,
@@ -326,7 +313,6 @@ async def run_strix_scan(
child_agent_builder = make_child_factory(
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_diff_scoped=is_diff_scoped,
interactive=interactive,
chat_completions_tools=chat_completions_tools,
system_prompt_context=scope_context,
@@ -354,7 +340,6 @@ async def run_strix_scan(
"parent_id": None,
"interactive": interactive,
"spawn_child_agent": spawn_child_agent,
"scan_targets": build_scan_targets(scan_config),
"max_context_images": settings.runtime.max_context_images,
}
@@ -441,6 +426,7 @@ 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
@@ -453,28 +439,19 @@ 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()
+2 -20
View File
@@ -4,8 +4,6 @@ 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
@@ -14,7 +12,7 @@ from agents.memory import SQLiteSession
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from collections.abc import Callable
from pathlib import Path
from agents.items import TResponseInputItem
@@ -24,25 +22,9 @@ 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 _PooledConnectionSession(session_id=agent_id, db_path=path)
return SQLiteSession(session_id=agent_id, db_path=path)
async def seed_initial_input(session: Session, initial_input: Any) -> bool:
+5 -16
View File
@@ -65,14 +65,6 @@ Examples:
# Local code analysis
strix --target ./my-project
# API spec test (OpenAPI/Swagger file or Postman collection export)
strix --target ./openapi.yaml --target https://api.example.com
strix --target ./collection.postman_collection.json
# Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment
strix --target postman://<collection-uuid> --target https://api.example.com
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
# Domain penetration test
strix --target example.com
@@ -115,10 +107,8 @@ Examples:
"--target",
type=str,
action="append",
help="Target to test: URL, repository, local directory path, domain name, IP address, "
"an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a "
"Postman collection by id (postman://<collection-uuid>[?env=<environment-uuid>], needs "
"POSTMAN_API_KEY). Local directories are mounted into the sandbox writable. "
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Local directories are mounted into the sandbox writable. "
"Can be specified multiple times for multi-target scans. "
"Fresh runs require --target or --target-list.",
)
@@ -328,11 +318,10 @@ 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 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.
# A target-less run has no targets_info at all: it works in a mounted
# directory, driven by its instruction.
workspace_mount = state.get("workspace_mount") or None
if not args.targets_info and not workspace_mount and not state.get("user_instruction"):
if not args.targets_info and not workspace_mount:
parser.error(f"--resume {args.resume}: run.json has no targets_info")
for target in args.targets_info:
+1 -43
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from strix.config import Settings, codex, load_settings
from strix.core.paths import run_dir_for
@@ -28,18 +28,8 @@ from strix.interface.utils import (
read_target_list_file,
resolve_diff_scope_context,
rewrite_localhost_targets,
stage_api_specs,
write_fetched_collection,
)
from strix.telemetry import posthog, scarf
from strix.utils.api_spec import (
SpecParseError,
fetch_postman_collection,
fetch_postman_environment,
load_spec,
spec_base_urls,
spec_title,
)
if TYPE_CHECKING:
@@ -119,9 +109,6 @@ def build_targets_info(args: argparse.Namespace) -> None:
else:
display_target = target
if target_type == "api_spec":
_resolve_api_spec(target, target_dict)
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
@@ -132,34 +119,6 @@ def build_targets_info(args: argparse.Namespace) -> None:
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
def _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
"""Read the spec up front so bad input fails before the run starts.
Records the declared base URLs (the only thing scope authorization can take
from a spec) and, for a ``postman://`` target, downloads the collection to a
local file so the sandbox never needs the Postman API key.
"""
try:
if details.get("source") == "postman_api":
collection_uid = str(details["collection_uid"])
api_key = load_settings().integrations.postman_api_key or ""
raw = fetch_postman_collection(collection_uid, api_key)
environment_uid = str(details.get("environment_uid") or "")
extra_variables = (
fetch_postman_environment(environment_uid, api_key) if environment_uid else None
)
details["target_spec"] = write_fetched_collection(raw, collection_uid)
else:
raw = load_spec(str(details["target_spec"]))
extra_variables = None
base_urls = spec_base_urls(raw, extra_variables=extra_variables)
except SpecParseError as exc:
raise ValueError(f"Invalid API spec '{target}': {exc}") from None
details["spec_title"] = spec_title(raw)
details["base_urls"] = base_urls
def prepare_run(args: argparse.Namespace) -> None:
"""Resolve the run name, clone repos, compute diff-scope, and persist state.
@@ -180,7 +139,6 @@ def prepare_run(args: argparse.Namespace) -> None:
target_info["details"]["cloned_repo_path"] = cloned_path
args.local_sources = collect_local_sources(args.targets_info)
args.local_sources.extend(stage_api_specs(args.targets_info, args.run_name))
diff_scope = resolve_diff_scope_context(
local_sources=args.local_sources,
scope_mode=args.scope_mode,
+14 -5
View File
@@ -138,6 +138,13 @@ 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()
@@ -349,12 +356,14 @@ class TuiController:
if not isinstance(approved, bool):
raise TypeError("approved must be a boolean")
self.pending_workspace_mount = None
# 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
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
await self._begin_scan(self._pending_verify)
return {"approved": approved}
return {"approved": True}
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
agent_id = self._required_string(payload, "agent_id")
@@ -1,299 +0,0 @@
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))
}
}
+5 -10
View File
@@ -110,7 +110,6 @@ type Model struct {
agentOffset int
vulnOffset int
modalChoice int
reportFocus string
ready bool
quitting bool
showSplash bool
@@ -158,16 +157,12 @@ const (
treeCursorBg = lipgloss.Color("#0178d4")
)
// 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).
// 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.
const (
thumbResting = lipgloss.Color("#3f3f46")
thumbActive = lipgloss.Color("#9ca3af")
thumbTrace = lipgloss.Color("#1a1a1a")
thumbAgents = lipgloss.Color("#404040")
thumbFindings = lipgloss.Color("#333333")
)
// Composer placeholders. The launch screen falls back to the short prompt when
+8 -126
View File
@@ -527,8 +527,7 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
}
model := newModel()
// Tab moves between the buttons; the arrows step between reports.
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyTab})
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
model = updated.(Model)
updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
@@ -561,8 +560,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
})
model = updated.(Model)
if cmd == nil || model.reportFocus != reportCopy {
t.Fatalf("mouse Copy was not activated: focus=%q cmd=%v", model.reportFocus, cmd)
if cmd == nil || model.modalChoice != 0 {
t.Fatalf("mouse Copy was not activated: choice=%d cmd=%v", model.modalChoice, cmd)
}
cmd()
if len(copied) != 2 {
@@ -821,8 +820,8 @@ func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) {
}
func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbResting)), "\n")
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbResting)), "\n")
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbAgents)), "\n")
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbAgents)), "\n")
// The track is blank, so only the thumb is drawn.
if top[0] != "█" || top[5] != " " {
@@ -831,10 +830,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, thumbResting); full != "" {
if full := verticalScrollbar(4, 4, 4, 0, thumbAgents); full != "" {
t.Fatalf("non-overflowing scrollbar should be hidden: %q", full)
}
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbResting))
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbAgents))
if strings.ContainsAny(withoutBar, "█") {
t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar)
}
@@ -842,7 +841,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, thumbResting), "\n")
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbTrace), "\n")
for _, row := range rows {
if width := ansi.StringWidth(row); width != 12 {
t.Fatalf("scrolling panel row width = %d, want 12", width)
@@ -1170,120 +1169,3 @@ func TestChatContentRerendersOnWidthAndExpansionChange(t *testing.T) {
}
}
}
// A model or backend failure can be a wrapped exception hundreds of columns
// wide and several lines long. The status row is one line of the chat column, so
// an oversized one widens the whole column - JoinHorizontal pads every row to the
// widest - which pushed the sidebar off screen and wrapped the frame.
func TestLongErrorDoesNotBreakTheFrame(t *testing.T) {
model := New(nil)
model.width, model.height = 120, 24
model.showSplash = false
model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
bootstrap := protocol.CollectionBootstrap{
Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true,
Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})},
}
model.handleEnvelope(protocol.Envelope{
Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap),
})
model.errorText = "litellm.APIConnectionError: OpenrouterException - Connection error " +
"while calling https://openrouter.ai/api/v1/chat/completions: HTTPSConnectionPool" +
"(host='openrouter.ai', port=443): Max retries exceeded\nTraceback (most recent " +
"call last):\n File \"/x/y.py\", line 42, in send\n raise err"
model.resizeViewport()
lines := strings.Split(model.View(), "\n")
if len(lines) > model.height {
t.Fatalf("frame is %d rows in a %d-row terminal", len(lines), model.height)
}
for i, line := range lines {
if width := ansi.StringWidth(line); width > model.width {
t.Fatalf("row %d is %d columns in a %d-column terminal", i, width, model.width)
}
}
// The sidebar has to survive: its panels are the right edge of the frame.
if !strings.Contains(ansi.Strip(model.View()), "Strix") {
t.Fatal("the agent tree was pushed out of the frame")
}
}
func TestStatusMessageFlattensAndKeepsItsHint(t *testing.T) {
row := ansi.Strip(statusMessage("boom\nsecond line\twith tabs", red, " · Send message to resume", 60))
if strings.Contains(row, "\n") || strings.Contains(row, "\t") {
t.Fatalf("status row is not a single line: %q", row)
}
if !strings.HasSuffix(row, " · Send message to resume") {
t.Fatalf("the hint was lost: %q", row)
}
if !strings.Contains(row, "boom second line with tabs") {
t.Fatalf("the message was mangled: %q", row)
}
// A message far too long for the row keeps the hint readable.
long := ansi.Strip(statusMessage(strings.Repeat("x", 500), red, " · Send message to resume", 60))
if width := ansi.StringWidth(long); width > 60 {
t.Fatalf("status message is %d columns, want at most 60", width)
}
if !strings.HasSuffix(long, " · Send message to resume") {
t.Fatalf("the hint was clipped away: %q", long)
}
}
// The status row must be exactly as wide as the column it sits in, at every
// terminal size. A narrow terminal cannot fit the quit hint alongside any status
// text, and keeping it anyway made the row wider than the terminal.
func TestStatusRowIsExactlyItsWidth(t *testing.T) {
quitHint := lipgloss.NewStyle().Foreground(white).Render("ctrl-q") +
lipgloss.NewStyle().Foreground(dim).Render(" quit")
longMessage := lipgloss.NewStyle().Foreground(red).Render(strings.Repeat("boom ", 40))
for width := 1; width <= 60; width++ {
for _, testCase := range []struct {
name string
left, right string
}{
{"empty", "", ""},
{"hint only", "", quitHint},
{"long message and hint", longMessage, quitHint},
{"long message alone", longMessage, ""},
} {
row := composeStatusRow(testCase.left, testCase.right, width)
if got := ansi.StringWidth(row); got != width {
t.Fatalf("%s at width %d rendered %d columns: %q",
testCase.name, width, got, ansi.Strip(row))
}
if strings.Contains(row, "\n") {
t.Fatalf("%s at width %d spans rows", testCase.name, width)
}
}
}
if row := composeStatusRow("x", "y", 0); row != "" {
t.Fatalf("a zero-width row should be empty, got %q", row)
}
}
// A running scan in a narrow terminal must not wrap the frame.
func TestNarrowTerminalKeepsTheFrameIntact(t *testing.T) {
for _, width := range []int{8, 10, 13, 14, 20, 40} {
model := New(nil)
model.width, model.height = width, 20
model.showSplash = false
model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
bootstrap := protocol.CollectionBootstrap{
Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true,
Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})},
}
model.handleEnvelope(protocol.Envelope{
Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap),
})
model.errorText = strings.Repeat("connection failed ", 20)
model.resizeViewport()
for i, line := range strings.Split(model.View(), "\n") {
if got := ansi.StringWidth(line); got > width {
t.Fatalf("at width %d row %d is %d columns", width, i, got)
}
}
}
}
+9 -8
View File
@@ -66,10 +66,13 @@ func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) {
}
// answerMountConfirmation replies to the working-directory mount the backend is
// 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.
// waiting on. Declining returns to the start screen, so the prompt goes back in
// the composer to be edited or given a target instead.
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})
}
@@ -211,11 +214,8 @@ func (m *Model) setupLogAppend(line string) {
}
// setupMsg appends a styled feedback line (success green, error red, notice dim).
// The log budgets rows by entry, so a message is flattened to one line first: a
// wrapped exception would otherwise render as several rows and push the launch
// column past the bottom of the terminal.
func (m *Model) setupMsg(text string, style lipgloss.Style) {
m.setupLogAppend(style.Render(flattenStatus(text)))
m.setupLogAppend(style.Render(text))
}
// setupLogRows is how many feedback lines the launch column shows before the
@@ -496,7 +496,8 @@ func (m Model) setupHintsView(width int) string {
key := lipgloss.NewStyle().Foreground(white).Render
label := render.Dim().Render
hint := func(k, text string) string { return key(k) + label(" "+text) }
left := hint("enter", "launch scan") + label(" ") + hint("ctrl+c", "quit")
left := hint("enter", "launch scan") + label(" ") + hint("shift+enter", "newline") +
label(" ") + hint("ctrl+c", "quit")
if lipgloss.Width(left) > inner {
left = hint("enter", "launch scan")
}
@@ -93,25 +93,3 @@ func TestFocusedPanelsCarryTheGreenBorder(t *testing.T) {
}
}
}
// A wrapped exception is several lines. The log budgets rows by entry, so it has
// to become one row or the launch column grows past the terminal.
func TestSetupLogKeepsMultiLineErrorsToOneRow(t *testing.T) {
model := New(nil)
model.width, model.height = 100, 26
model.showSplash = false
model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"}))
model.setupMsg("boom\nTraceback (most recent call last):\n File \"x.py\", line 1\n raise", render.Col(red))
model.resizeViewport()
if entries := len(model.setupLog); entries != 1 {
t.Fatalf("one message became %d log entries", entries)
}
if strings.Contains(model.setupLog[0], "\n") {
t.Fatalf("log entry spans rows: %q", model.setupLog[0])
}
lines := strings.Split(model.View(), "\n")
if len(lines) > model.height {
t.Fatalf("start screen is %d rows in a %d-row terminal", len(lines), model.height)
}
}
@@ -247,10 +247,13 @@ func TestMountConfirmationAnswers(t *testing.T) {
if payload.Approved != tc.approved {
t.Fatalf("%s: approved=%v, want %v", tc.name, payload.Approved, tc.approved)
}
// 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)
// 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)
}
if model.pendingPrompt != "" {
t.Fatalf("%s: held prompt was not cleared: %q", tc.name, model.pendingPrompt)
@@ -287,101 +290,3 @@ 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)
}
}
+34 -104
View File
@@ -219,8 +219,7 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
m.focus = focusVulnerabilities
m.input.Blur()
totalRows, _ := m.vulnerabilityScrollRows()
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3)
m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3)
m.keepVulnerabilitySelectionInWindow()
}
return m, nil
@@ -319,7 +318,24 @@ func (m *Model) updateMainScrollbarMouse(
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
return false
}
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight)
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
}
}
if target == scrollbarNone {
return false
}
@@ -328,40 +344,6 @@ 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,
@@ -385,10 +367,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()
// The offset is a row, so dragging moves the list continuously.
m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset)
m.keepVulnerabilitySelectionInWindow()
}
}
@@ -423,22 +405,6 @@ 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()
@@ -474,35 +440,14 @@ func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
m.modalChoice = 1
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
}
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
m.modalChoice = 0
cmd := m.startVulnerabilityCopy()
return m, cmd
}
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
m.reportFocus = reportDone
m.modalChoice = 1
m.closeModal()
}
}
@@ -519,14 +464,7 @@ 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)
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") {
for row, line := range strings.Split(view, "\n") {
plain := ansi.Strip(line)
index := strings.Index(plain, label)
if index < 0 || y != top+row {
@@ -578,19 +516,16 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "esc":
m.closeModal()
// 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 "left", "right", "tab", "shift+tab":
m.modalChoice = 1 - m.modalChoice
case "enter":
return m.pressReportButton(m.focusedReportButton())
if m.modalChoice == 0 {
cmd := m.startVulnerabilityCopy()
return m, cmd
}
m.closeModal()
case "c":
m.reportFocus = reportCopy
m.modalChoice = 0
cmd := m.startVulnerabilityCopy()
return m, cmd
case "up":
@@ -612,8 +547,7 @@ 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.
cmd := m.answerMountConfirmation(false)
return m, cmd
return m, m.answerMountConfirmation(false)
}
m.closeModal()
return m, nil
@@ -624,10 +558,7 @@ 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.
// 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
return m, m.answerMountConfirmation(choice == 0)
}
m.closeModal()
if choice == 1 {
@@ -653,7 +584,6 @@ func (m *Model) openModal(mode modalMode) {
m.modalChoice = 1
}
if mode == modalVulnerability {
m.reportFocus = reportDone
m.modalChoice = 1
m.vulnerabilityCopied = false
m.vulnerabilityCopyError = ""
+17 -76
View File
@@ -164,15 +164,6 @@ 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 ""
@@ -271,23 +262,6 @@ 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 {
@@ -450,7 +424,7 @@ func (m Model) renderChatPane(width, height int, border lipgloss.Color) string {
m.viewport.TotalLineCount(),
m.viewport.VisibleLineCount(),
m.viewport.YOffset,
m.scrollbarThumb(scrollbarTrace),
thumbTrace,
)
out := lipgloss.NewStyle().Width(width).Height(height).
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
@@ -515,7 +489,7 @@ func (m Model) sidebarView(width, height int) string {
len(agentEntries),
agentRows,
m.agentOffset,
m.scrollbarThumb(scrollbarAgents),
thumbAgents,
)
parts := []string{
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
@@ -529,13 +503,13 @@ func (m Model) sidebarView(width, height int) string {
vulnRows := max(1, vulnHeight-2)
totalRows, offsetRows := m.vulnerabilityScrollRows()
findings := withVerticalScrollbar(
m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows),
m.vulnerabilitiesView(max(1, width-5), vulnRows),
width-4,
vulnRows,
totalRows,
vulnRows,
offsetRows,
m.scrollbarThumb(scrollbarFindings),
thumbFindings,
)
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
}
@@ -550,7 +524,12 @@ 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 {
vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
rows := 0
width := m.vulnerabilityListWidth()
for i := range m.snapshot.Vulnerabilities {
rows += len(m.vulnerabilityTitleLines(i, width))
}
vulnHeight = min(12, rows+2)
}
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
return
@@ -675,7 +654,8 @@ func (m Model) statusView(width int) string {
case "waiting":
left = lipgloss.NewStyle().Foreground(dim).Render("Send message to resume")
if msg := agent.ErrorMessage; msg != "" {
left = statusMessage(msg, red, " · Send message to resume", width)
left = lipgloss.NewStyle().Foreground(red).Render(msg) +
lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume")
}
case "budget_paused":
left = lipgloss.NewStyle().Foreground(amber).Render("Budget limit reached") +
@@ -690,54 +670,15 @@ func (m Model) statusView(width int) string {
if msg == "" {
msg = "Agent failed"
}
left = statusMessage(msg, red, " · Send message to resume", width)
left = lipgloss.NewStyle().Foreground(red).Render(msg) +
lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume")
}
}
if m.errorText != "" {
left = statusMessage(m.errorText, red, "", width-lipgloss.Width(right))
left = lipgloss.NewStyle().Foreground(red).Render(m.errorText)
}
return composeStatusRow(left, right, width)
}
// composeStatusRow lays the status text and the corner hint on one row exactly
// width columns wide. A wider row would widen the whole chat column, because
// JoinHorizontal pads every row of a block to its widest, which pushes the
// sidebar off screen and wraps the frame.
func composeStatusRow(left, right string, width int) string {
if width <= 0 {
return ""
}
const leading = 1 // the row is indented one column, like the panels above it
// A terminal can be narrower than the hint itself. Drop the hint rather than
// keep it at the cost of the status, which is the part carrying information;
// ctrl-q works whether or not the row has room to say so.
if lipgloss.Width(right) > 0 && width < lipgloss.Width(right)+leading+2 {
right = ""
}
separator := 0
if lipgloss.Width(right) > 0 {
separator = 1
}
left = truncate(left, max(0, width-leading-lipgloss.Width(right)-separator))
padding := max(0, width-leading-lipgloss.Width(left)-lipgloss.Width(right))
return " " + left + strings.Repeat(" ", padding) + right
}
// statusMessage fits a message and its trailing hint on the one status row. A
// model or backend error can be a wrapped exception several lines long, so it is
// flattened to a single line and clipped, leaving the hint readable.
func statusMessage(message string, color lipgloss.Color, hint string, width int) string {
styledHint := lipgloss.NewStyle().Foreground(dim).Render(hint)
room := max(1, width-2-lipgloss.Width(styledHint))
flat := truncate(flattenStatus(message), room)
return lipgloss.NewStyle().Foreground(color).Render(flat) + styledHint
}
// flattenStatus turns a multi-line message into one line, collapsing the runs of
// whitespace that joining its lines leaves behind.
func flattenStatus(message string) string {
message = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ", "\t", " ").Replace(message)
return strings.Join(strings.Fields(message), " ")
gap := max(1, width-lipgloss.Width(left)-lipgloss.Width(right))
return " " + left + strings.Repeat(" ", max(1, gap-1)) + right
}
func (m Model) sweepView() string {
@@ -74,8 +74,6 @@ func vulnerabilityMarkdownReport(v map[string]any) string {
field("Ecosystem", render.StringValue(dep["package_ecosystem"]))
field("Installed Version", render.StringValue(dep["installed_version"]))
field("Fixed Version", render.StringValue(dep["fixed_version"]))
field("Introduced By", render.StringValue(dep["introduced_by"]))
field("Dependency Chain", render.StringValue(dep["dependency_path"]))
}
field("Endpoint", render.StringValue(v["endpoint"]))
field("Method", render.StringValue(v["method"]))
@@ -1,7 +1,6 @@
package app
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -14,123 +13,115 @@ var panelSeverityColors = map[string]lipgloss.Color{
"critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue,
}
// 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})
}
}
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] {
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")
}
marker := lipgloss.NewStyle().Foreground(color).Render("● ")
style := lipgloss.NewStyle().Foreground(textColor)
if row.index == m.selectedVuln {
if i == m.selectedVuln {
style = style.Bold(true).Foreground(white)
}
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")
for row, titleLine := range m.vulnerabilityTitleLines(i, width) {
if len(lines) >= height {
break
}
prefix = lipgloss.NewStyle().Foreground(color).Render(" ")
prefix := " "
if row == 0 {
prefix = marker
}
lines = append(lines, prefix+style.Render(titleLine))
}
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-5)
return max(1, sidebarWidth-6)
}
func (m Model) vulnerabilityTitle(index int) string {
func (m Model) vulnerabilityTitleLines(index, width int) []string {
title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"])
if title == "" {
title = "Unknown Vulnerability"
}
return title
return strings.Split(wrapBlock(title, max(1, width-2)), "\n")
}
// 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) {
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 {
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
target := m.vulnOffset + row
if target < 0 || target >= len(rows) {
return -1
width := m.vulnerabilityListWidth()
for i := range m.snapshot.Vulnerabilities {
rows := len(m.vulnerabilityTitleLines(i, width))
total += rows
if i < m.vulnOffset {
offset += rows
}
}
return rows[target].index
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
}
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
}
}
return -1
}
// 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() {
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) == 0 {
if len(m.snapshot.Vulnerabilities) == 0 {
m.vulnOffset = 0
return
}
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
if m.selectedVuln < m.vulnOffset {
m.vulnOffset = m.selectedVuln
}
if firstRow < 0 {
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
return
for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln {
m.vulnOffset++
}
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))
m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1)
}
func (m Model) vulnerabilityPageSize() int {
@@ -138,52 +129,23 @@ 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 {
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)
return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset)
}
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() {
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) == 0 {
if len(m.snapshot.Vulnerabilities) == 0 {
return
}
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
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)
}
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
@@ -220,13 +182,6 @@ 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
@@ -239,8 +194,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 · skip to run without it")
return m.cornerPrompt(title, body, width, mountConfirmLabel, mountCancelLabel)
render.Dim().Render("writable in the sandbox")
return m.cornerPrompt(title, body, width, "Confirm", "Cancel")
}
// truncatePath keeps the tail of a path visible, which is the part that
@@ -343,8 +298,6 @@ func vulnerabilityBody(v map[string]any) string {
field("Ecosystem", render.StringValue(dep["package_ecosystem"]))
field("Installed Version", render.StringValue(dep["installed_version"]))
field("Fixed Version", render.StringValue(dep["fixed_version"]))
field("Introduced By", render.StringValue(dep["introduced_by"]))
field("Dependency Chain", render.StringValue(dep["dependency_path"]))
}
field("Endpoint", render.StringValue(v["endpoint"]))
field("Method", render.StringValue(v["method"]))
@@ -418,116 +371,25 @@ 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)))
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)
copyLabel := "Copy"
if m.vulnerabilityCopied {
copyLabel = "Copied!"
} else if m.vulnerabilityCopyError != "" {
copyLabel = "Copy failed"
}
// 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
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)
}
room := max(0, inner-lipgloss.Width(right))
buttonRow := rule + "\n" +
lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right
buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done")
buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons)
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 = ""
@@ -1,194 +0,0 @@
package render
import (
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Coverage ledger (record_coverage / update_coverage / list_coverage)
// ---------------------------------------------------------------------------
// coverageOutcomes maps a ledger outcome to its marker and color. A cleared
// surface and an unresolved one must not look alike at a glance: the whole
// point of the ledger is that a reader can see which surfaces are still open.
var coverageOutcomes = map[string]struct {
marker string
label string
color lipgloss.Color
}{
"reported": {"!", "reported", SevHigh},
"no_issue_found": {"✓", "no issue found", Green},
"ruled_out": {"✓", "ruled out", Mint},
"not_applicable": {"", "not applicable", Slate},
"needs_follow_up": {"?", "needs follow-up", AmberY},
}
func coverageOutcome(outcome string) (string, string, lipgloss.Color) {
if meta, ok := coverageOutcomes[strings.TrimSpace(strings.ToLower(outcome))]; ok {
return meta.marker, meta.label, meta.color
}
if outcome == "" {
return "·", "", Gray
}
return "·", strings.ReplaceAll(outcome, "_", " "), Gray
}
var coverageTitles = map[string]struct {
title string
loading string
errMsg string
}{
"record_coverage": {"Coverage Recorded", "Recording...", "Failed to record coverage"},
"update_coverage": {"Coverage Updated", "Updating...", "Failed to update coverage"},
"list_coverage": {"Coverage", "Loading...", "Unable to list coverage"},
}
func renderCoverage(name string, args map[string]any, result any) string {
meta := coverageTitles[name]
var b strings.Builder
b.WriteString("▣ " + Bold(Cyan).Render(meta.title))
if s, ok := result.(string); ok && strings.TrimSpace(s) != "" {
b.WriteString("\n " + Dim().Render(strings.TrimSpace(s)))
return b.String()
}
m, ok := result.(map[string]any)
if !ok {
coverageArgsPreview(&b, name, args)
b.WriteString("\n " + Dim().Render(meta.loading))
return b.String()
}
if !truthy(m["success"]) {
coverageArgsPreview(&b, name, args)
errMsg := StringValue(m["error"])
if errMsg == "" {
errMsg = meta.errMsg
}
b.WriteString("\n " + Col(Red).Render(errMsg))
return b.String()
}
switch name {
case "list_coverage":
coverageListBody(&b, m)
case "update_coverage":
marker, label, color := coverageOutcome(StringValue(m["outcome"]))
_, previous, previousColor := coverageOutcome(StringValue(m["previous_outcome"]))
b.WriteString("\n " + Col(color).Render(marker) + " " + coverageSubject(args, m))
if previous != "" {
b.WriteString("\n " + Col(previousColor).Render(previous) +
Dim().Render(" → ") + Col(color).Render(label))
} else {
b.WriteString("\n " + Col(color).Render(label))
}
coverageEvidence(&b, StringValue(args["evidence"]))
default:
marker, label, color := coverageOutcome(StringValue(m["outcome"]))
b.WriteString("\n " + Col(color).Render(marker) + " " + coverageSubject(args, m))
b.WriteString("\n " + Col(color).Render(label))
coverageEvidence(&b, StringValue(args["evidence"]))
}
return b.String()
}
// coverageSubject names the surface being recorded, falling back to the entry
// id when only the id is known (an update carries no surface in its args).
func coverageSubject(args map[string]any, result map[string]any) string {
surface := strings.TrimSpace(StringValue(args["surface"]))
risk := strings.TrimSpace(StringValue(args["risk_area"]))
switch {
case surface != "" && risk != "":
return surface + Dim().Render(" · "+risk)
case surface != "":
return surface
case risk != "":
return risk
}
if id := StringValue(result["entry_id"]); id != "" {
return Dim().Render("entry " + id)
}
return Dim().Render("(unnamed surface)")
}
func coverageEvidence(b *strings.Builder, evidence string) {
if strings.TrimSpace(evidence) != "" {
b.WriteString("\n " + Dim().Render(psanitize(strings.TrimSpace(evidence), 160)))
}
}
func coverageArgsPreview(b *strings.Builder, name string, args map[string]any) {
if name == "list_coverage" {
return
}
if subject := coverageSubject(args, map[string]any{}); subject != "" {
b.WriteString("\n " + subject)
}
}
func coverageListBody(b *strings.Builder, result map[string]any) {
entries, _ := result["entries"].([]any)
total, _ := NumericValue(result["total_count"])
if len(entries) == 0 {
if int(total) == 0 {
b.WriteString("\n " + Dim().Render("No surfaces recorded yet"))
} else {
b.WriteString("\n " + Dim().Render("No surfaces match this filter"))
}
return
}
if counts, ok := result["outcome_counts"].(map[string]any); ok && len(counts) > 0 {
var parts []string
for _, outcome := range []string{
"reported", "no_issue_found", "ruled_out", "not_applicable", "needs_follow_up",
} {
count, ok := NumericValue(counts[outcome])
if !ok || count == 0 {
continue
}
_, label, color := coverageOutcome(outcome)
parts = append(parts, Col(color).Render(label+": "+strconv.Itoa(int(count))))
}
if len(parts) > 0 {
b.WriteString("\n " + strings.Join(parts, Dim().Render(" ")))
}
}
for _, e := range entries {
entry, _ := e.(map[string]any)
marker, label, color := coverageOutcome(StringValue(entry["outcome"]))
surface := strings.TrimSpace(StringValue(entry["surface"]))
if surface == "" {
surface = "(unnamed surface)"
}
b.WriteString("\n " + Col(color).Render(marker) + " " + surface)
if risk := strings.TrimSpace(StringValue(entry["risk_area"])); risk != "" {
b.WriteString(Dim().Render(" · " + risk))
}
b.WriteString("\n " + Col(color).Render(label))
// A row that moved states carries its own history; showing it keeps a
// closed surface from reading as one that was never in question.
if previous, ok := entry["previous_outcomes"].([]any); ok && len(previous) > 0 {
var was []string
for _, p := range previous {
if _, label, _ := coverageOutcome(StringValue(p)); label != "" {
was = append(was, label)
}
}
if len(was) > 0 {
b.WriteString(Dim().Render(" (was " + strings.Join(was, " → ") + ")"))
}
}
// Whose row this is matters for reconciliation: an agent needs to see
// at a glance which surfaces it owns and which came from a sibling.
if truthy(entry["by_you"]) {
b.WriteString(Dim().Render(" · you"))
} else if who := strings.TrimSpace(StringValue(entry["agent_name"])); who != "" {
b.WriteString(Dim().Render(" · " + who))
}
coverageEvidence(b, StringValue(entry["evidence"]))
}
}
@@ -1,204 +0,0 @@
package render
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestRecordCoverageRendersSurfaceAndOutcome(t *testing.T) {
out := ansi.Strip(Tool(tool("record_coverage",
map[string]any{
"surface": "POST /api/v1/invoices",
"risk_area": "object-level authorization",
"evidence": "tenant B token returns 403 on tenant A invoice ids",
},
map[string]any{"success": true, "entry_id": "a1b2c3", "outcome": "ruled_out"},
"completed")))
requireContains(t, out,
"Coverage Recorded",
"POST /api/v1/invoices",
"object-level authorization",
"ruled out",
"tenant B token returns 403",
)
}
func TestUpdateCoverageShowsStateTransition(t *testing.T) {
out := ansi.Strip(Tool(tool("update_coverage",
map[string]any{"entry_id": "a1b2c3", "evidence": "reproduced with a second tenant"},
map[string]any{
"success": true,
"entry_id": "a1b2c3",
"previous_outcome": "needs_follow_up",
"outcome": "reported",
},
"completed")))
requireContains(t, out, "Coverage Updated", "needs follow-up", "→", "reported")
}
func TestListCoverageRendersCountsHistoryAndAuthor(t *testing.T) {
out := ansi.Strip(Tool(tool("list_coverage", nil,
map[string]any{
"success": true,
"entries": []any{
map[string]any{
"entry_id": "a1b2c3",
"surface": "/admin/export",
"risk_area": "IDOR",
"outcome": "no_issue_found",
"agent_name": "AuthzAgent",
"previous_outcomes": []any{"needs_follow_up"},
"evidence": "org id is server-derived from the session",
},
map[string]any{
"entry_id": "d4e5f6",
"surface": "/graphql",
"risk_area": "injection",
"outcome": "needs_follow_up",
"by_you": true,
"evidence": "introspection disabled; needs an authenticated schema dump",
},
},
"total_count": 2,
"outcome_counts": map[string]any{"no_issue_found": 1, "needs_follow_up": 1},
},
"completed")))
requireContains(t, out,
"/admin/export", "IDOR", "no issue found",
"was needs follow-up", "AuthzAgent",
"/graphql", "needs follow-up", "you",
"no issue found: 1", "needs follow-up: 1",
)
}
func TestListCoverageEmptyLedgerReadsAsUnrecorded(t *testing.T) {
out := ansi.Strip(Tool(tool("list_coverage", nil,
map[string]any{"success": true, "entries": []any{}, "total_count": 0}, "completed")))
requireContains(t, out, "No surfaces recorded yet")
filtered := ansi.Strip(Tool(tool("list_coverage",
map[string]any{"outcome": "reported"},
map[string]any{"success": true, "entries": []any{}, "total_count": 4}, "completed")))
requireContains(t, filtered, "No surfaces match this filter")
}
func TestCoverageDuplicateRejectionSurfacesTheError(t *testing.T) {
out := ansi.Strip(Tool(tool("record_coverage",
map[string]any{"surface": "/login", "risk_area": "XSS"},
map[string]any{
"success": false,
"error": "'/login' (XSS) already has coverage entry a1b2c3",
"existing_entry_id": "a1b2c3",
},
"completed")))
requireContains(t, out, "/login", "already has coverage entry a1b2c3")
}
func TestGetThreatModelRendersStalenessAndAmendments(t *testing.T) {
out := ansi.Strip(Tool(tool("get_threat_model",
map[string]any{"target": "https://app.example.com"},
map[string]any{
"success": true,
"found": true,
"stale": true,
"cached_revision": "0123456789abcdef",
"content": "# Overview\nMulti-tenant billing app.\n\n" +
"## Trust Boundaries and Assumptions\n\n## Attack Surface\n",
"amendments": []any{
map[string]any{
"agent_name": "ReconAgent",
"content": "staging host shares the production database",
},
},
},
"completed")))
requireContains(t, out,
"Threat Model", "https://app.example.com",
"stale", "01234567",
"1 amendment(s)", "ReconAgent", "staging host shares the production database",
"Multi-tenant billing app.", "Overview", "Trust Boundaries and Assumptions",
)
}
func TestGetThreatModelMissingModelIsExplicit(t *testing.T) {
out := ansi.Strip(Tool(tool("get_threat_model",
map[string]any{"target": "10.0.0.5"},
map[string]any{"success": true, "found": false}, "completed")))
requireContains(t, out, "No model cached for this target yet")
}
func TestSaveThreatModelWarnsWhenAmendmentsAreCleared(t *testing.T) {
out := ansi.Strip(Tool(tool("save_threat_model",
map[string]any{"target": "app.example.com", "content": "# Overview\nA thing.\n"},
map[string]any{
"success": true,
"revision": "unversioned",
"amendments_cleared": 2,
},
"completed")))
requireContains(t, out, "Threat Model Saved", "saved", "cleared 2 amendment(s)")
// An unversioned target has no revision worth printing.
if strings.Contains(out, "unversioned") {
t.Fatalf("unversioned revision should not be rendered:\n%s", out)
}
}
func TestAmendThreatModelRendersAddendum(t *testing.T) {
out := ansi.Strip(Tool(tool("amend_threat_model",
map[string]any{
"target": "app.example.com",
"addendum": "The admin role is assignable by any org member via PATCH /members.",
},
map[string]any{"success": true, "amendment_count": 3}, "completed")))
requireContains(t, out, "Threat Model Amended", "amendment recorded", "(3 total)",
"admin role is assignable")
}
func TestCoverageAndThreatModelToolsAreNotGeneric(t *testing.T) {
// The generic fallback dumps raw arg keys; these tools must not reach it.
for _, name := range []string{
"record_coverage", "update_coverage", "list_coverage",
"get_threat_model", "save_threat_model", "amend_threat_model",
} {
out := ansi.Strip(Tool(tool(name, map[string]any{"target": "x", "surface": "y"}, nil, "running")))
if strings.Contains(out, "Using tool") {
t.Fatalf("%s fell through to the generic renderer:\n%s", name, out)
}
}
}
func TestOutputHeavyCoverageToolsCollapse(t *testing.T) {
for _, name := range []string{"list_coverage", "get_threat_model"} {
if ToolPreviewLines(name) == 0 {
t.Fatalf("%s should collapse; its output is unbounded", name)
}
}
for _, name := range []string{"record_coverage", "amend_threat_model"} {
if ToolPreviewLines(name) != 0 {
t.Fatalf("%s should not collapse", name)
}
}
}
func TestVulnerabilityReportRendersCalibrationFields(t *testing.T) {
out := ansi.Strip(Tool(tool("create_vulnerability_report",
map[string]any{
"title": "IDOR in invoice export",
"confidence": "medium",
"confidence_rationale": "traced statically; no authenticated instance to replay against",
"counterevidence": "the gateway may strip the id parameter before it reaches the handler",
"severity_change_conditions": "critical if the export includes other tenants' bank details",
"fix_verification": "unit tests executed; bypass review reasoned only",
"description": "The handler trusts a client-supplied invoice id.",
},
map[string]any{"success": true, "severity": "high", "cvss_score": 7.5},
"completed")))
requireContains(t, out,
"Confidence", "MEDIUM", "no authenticated instance to replay against",
"Counterevidence", "gateway may strip the id parameter",
"Severity Would Change If", "other tenants' bank details",
"Fix Verification", "bypass review reasoned only",
)
}
@@ -57,12 +57,6 @@ func renderDependencyReport(args map[string]any, result any) string {
section("Description", StringValue(args["description"]))
section("Impact", StringValue(args["impact"]))
section("Technical Analysis", StringValue(args["technical_analysis"]))
if reach := StringValue(args["reachability"]); reach != "" && reach != "unknown" {
b.WriteString("\n\n" + Bold(Field).Render("Usage evidence: ") + reach)
if ev := StringValue(args["reachability_evidence"]); ev != "" {
b.WriteString("\n" + ev)
}
}
section("Assumptions", StringValue(args["assumptions"]))
section("Remediation", StringValue(args["remediation_steps"]))
if title == "" {
@@ -82,10 +82,6 @@ func Tool(data map[string]any) string {
return renderNote(name, args, result)
case "create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo":
return renderTodo(name, result)
case "record_coverage", "update_coverage", "list_coverage":
return renderCoverage(name, args, result)
case "get_threat_model", "save_threat_model", "amend_threat_model":
return renderThreatModel(name, args, result)
case "view_agent_graph", "create_agent", "send_message_to_agent", "agent_finish", "wait_for_agents", "stop_agent":
return renderAgentGraphTool(name, args, result)
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
@@ -107,8 +103,7 @@ const outputPreviewLines = 10
func ToolPreviewLines(name string) int {
switch name {
case "exec_command", "write_stdin", "apply_patch",
"view_request", "repeat_request", "view_sitemap_entry",
"list_coverage", "get_threat_model":
"view_request", "repeat_request", "view_sitemap_entry":
return outputPreviewLines
}
return 0
@@ -50,31 +50,15 @@ func renderVulnerabilityReport(args map[string]any, result any) string {
b.WriteString("\n\n" + Bold(Field).Render(label) + "\n" + value)
}
}
if confidence := StringValue(args["confidence"]); confidence != "" {
b.WriteString("\n\n" + Bold(Field).Render("Confidence: ") +
lipgloss.NewStyle().Bold(true).Foreground(confidenceColor(confidence)).
Render(strings.ToUpper(confidence)))
if rationale := StringValue(args["confidence_rationale"]); rationale != "" {
b.WriteString("\n" + Dim().Render(rationale))
}
}
section("Description", StringValue(args["description"]))
section("Impact", StringValue(args["impact"]))
section("Technical Analysis", StringValue(args["technical_analysis"]))
// The case against the finding travels with the case for it: a reader
// triaging this needs both to judge whether to act.
section("Counterevidence", StringValue(args["counterevidence"]))
section("Severity Would Change If", StringValue(args["severity_change_conditions"]))
renderCodeLocations(&b, args["code_locations"])
section("PoC Description", StringValue(args["poc_description"]))
if poc := StringValue(args["poc_script_code"]); poc != "" {
b.WriteString("\n\n" + Bold(Field).Render("PoC Code") + "\n" + Col(Text).Render(poc))
}
section("Remediation", StringValue(args["remediation_steps"]))
// Any applyable fix above is one click from the user's codebase, so how it
// was verified belongs next to it rather than in the artifact alone.
section("Fix Verification", StringValue(args["fix_verification"]))
if title == "" {
b.WriteString("\n " + Dim().Render("Creating report..."))
@@ -82,20 +66,6 @@ func renderVulnerabilityReport(args map[string]any, result any) string {
return "\n\n" + b.String() + "\n\n"
}
// confidenceColor grades how firm the agent's own call is. Anything below
// high is a claim the reader has to check, and should not read as settled.
func confidenceColor(confidence string) lipgloss.Color {
switch strings.ToLower(strings.TrimSpace(confidence)) {
case "high":
return Green
case "medium":
return SevMed
case "low":
return SevHigh
}
return Gray
}
var cvssKeys = [][2]string{
{"attack_vector", "AV"}, {"attack_complexity", "AC"}, {"privileges_required", "PR"},
{"user_interaction", "UI"}, {"scope", "S"}, {"confidentiality", "C"},
@@ -1,138 +0,0 @@
package render
import (
"strconv"
"strings"
)
// ---------------------------------------------------------------------------
// Threat model (get_threat_model / save_threat_model / amend_threat_model)
// ---------------------------------------------------------------------------
var threatModelTitles = map[string]struct {
title string
loading string
errMsg string
}{
"get_threat_model": {"Threat Model", "Loading...", "Unable to read threat model"},
"save_threat_model": {"Threat Model Saved", "Saving...", "Failed to save threat model"},
"amend_threat_model": {"Threat Model Amended", "Amending...", "Failed to amend threat model"},
}
func renderThreatModel(name string, args map[string]any, result any) string {
meta := threatModelTitles[name]
var b strings.Builder
b.WriteString("⌖ " + Bold(InfoBlue).Render(meta.title))
if target := strings.TrimSpace(StringValue(args["target"])); target != "" {
b.WriteString(Dim().Render(" " + target))
}
if s, ok := result.(string); ok && strings.TrimSpace(s) != "" {
b.WriteString("\n " + Dim().Render(strings.TrimSpace(s)))
return b.String()
}
m, ok := result.(map[string]any)
if !ok {
b.WriteString("\n " + Dim().Render(meta.loading))
return b.String()
}
if !truthy(m["success"]) {
errMsg := StringValue(m["error"])
if errMsg == "" {
errMsg = meta.errMsg
}
b.WriteString("\n " + Col(Red).Render(errMsg))
return b.String()
}
switch name {
case "get_threat_model":
threatModelReadBody(&b, m)
case "amend_threat_model":
b.WriteString("\n " + Col(Green).Render("✓ amendment recorded"))
if count, ok := NumericValue(m["amendment_count"]); ok {
b.WriteString(Dim().Render(" (" + strconv.Itoa(int(count)) + " total)"))
}
threatModelBody(&b, StringValue(args["addendum"]))
default:
b.WriteString("\n " + Col(Green).Render("✓ saved"))
if revision := shortRevision(StringValue(m["revision"])); revision != "" {
b.WriteString(Dim().Render(" at " + revision))
}
// Saving folds amendments away, so the count that vanished is worth
// stating: it is the one destructive thing this tool does.
if cleared, ok := NumericValue(m["amendments_cleared"]); ok && cleared > 0 {
b.WriteString("\n " + Col(AmberY).Render("⚠ cleared "+
strconv.Itoa(int(cleared))+" amendment(s)"))
}
threatModelBody(&b, StringValue(args["content"]))
}
return b.String()
}
func threatModelReadBody(b *strings.Builder, result map[string]any) {
if !truthy(result["found"]) {
b.WriteString("\n " + Dim().Render("No model cached for this target yet"))
return
}
if truthy(result["stale"]) {
b.WriteString("\n " + Col(AmberY).Render("⚠ stale"))
if cached := shortRevision(StringValue(result["cached_revision"])); cached != "" {
b.WriteString(Dim().Render(" (written at " + cached + ")"))
}
}
if amendments, ok := result["amendments"].([]any); ok && len(amendments) > 0 {
b.WriteString("\n " + Col(Gold).Render("+ "+strconv.Itoa(len(amendments))+
" amendment(s)") + Dim().Render(" — later statements win"))
for _, a := range amendments {
amendment, _ := a.(map[string]any)
who := strings.TrimSpace(StringValue(amendment["agent_name"]))
if who == "" {
who = "unknown agent"
}
b.WriteString("\n - " + Dim().Render(who+": ") +
psanitize(strings.TrimSpace(StringValue(amendment["content"])), 120))
}
}
threatModelBody(b, StringValue(result["content"]))
}
// threatModelBody previews the document. The full text is a page or more, so
// only its section headings and opening line are shown here; the trace can be
// expanded for the rest.
func threatModelBody(b *strings.Builder, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
var headings []string
summary := ""
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "#"):
headings = append(headings, strings.TrimSpace(strings.TrimLeft(line, "# ")))
case summary == "" && line != "":
summary = line
}
}
if summary != "" {
b.WriteString("\n " + Dim().Render(psanitize(summary, 160)))
}
if len(headings) > 0 {
if len(headings) > 8 {
headings = headings[:8]
}
b.WriteString("\n " + Dim().Render(strings.Join(headings, " · ")))
}
}
// shortRevision abbreviates a git sha; "unversioned" targets have no revision
// worth showing.
func shortRevision(revision string) string {
revision = strings.TrimSpace(revision)
if revision == "" || revision == "unversioned" {
return ""
}
return firstN(revision, 8)
}
+1 -1
View File
@@ -431,7 +431,7 @@ _INTERNAL_TURN_PREFIXES = (
"== Inherited context from parent",
# strix.core.execution: the no-tool-call recovery nudge, both modes.
"Your previous message ended a turn without a tool call.",
"Your previous response ended the autonomous run without a lifecycle tool call.",
"Your previous response ended the autonomous Strix run without a lifecycle tool call.",
# strix.core.hooks: budget warnings, the only notices injected unwrapped.
*(
f"[{label}] {subject}"
+2 -95
View File
@@ -11,7 +11,7 @@ import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from urllib.parse import urlparse
import docker
import requests
@@ -21,7 +21,6 @@ from rich.panel import Panel
from rich.text import Text
from strix.config import load_settings
from strix.utils.api_spec import detect_spec_format
logger = logging.getLogger(__name__)
@@ -485,15 +484,6 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None)
if target_type == "ip_address":
return str(details.get("target_ip", original) or original)
if target_type == "api_spec":
if details.get("source") == "postman_api":
return "postman-collection"
spec_path = details.get("target_spec", original)
try:
return str(Path(spec_path).stem or spec_path)
except Exception:
return str(spec_path)
return str(original or "pentest")
@@ -1102,7 +1092,7 @@ def resolve_diff_scope_context(
def _is_http_git_repo(url: str) -> bool:
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
try:
with requests.get(check_url, headers={"User-Agent": "git/2.43.0"}, timeout=10) as resp:
with requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10) as resp:
if resp.status_code >= 400:
return resp.status_code == 401
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
@@ -1123,24 +1113,6 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
return "repository", {"target_repo": target}
parsed = urlparse(target)
if parsed.scheme == "postman":
collection_uid = f"{parsed.netloc}{parsed.path}".strip("/")
if not collection_uid:
raise ValueError(
f"Missing Postman collection id in '{target}' (expected postman://<collection-uid>)"
)
details = {
"target_spec": target,
"spec_format": "postman",
"source": "postman_api",
"collection_uid": collection_uid,
}
query = parse_qs(parsed.query)
env_uid = (query.get("env") or query.get("environment") or [""])[0].strip()
if env_uid:
details["environment_uid"] = env_uid
return "api_spec", details
if parsed.scheme in ("http", "https"):
if parsed.username or parsed.password:
return "repository", {"target_repo": target}
@@ -1166,12 +1138,6 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
if path.is_dir():
check_mountable_dir(path)
return "local_code", {"target_path": str(path.resolve())}
spec_format = detect_spec_format(path)
if spec_format is not None:
return "api_spec", {
"target_spec": str(path.resolve()),
"spec_format": spec_format,
}
raise ValueError(f"Path exists but is not a directory: {target}")
except (OSError, RuntimeError) as e:
raise ValueError(f"Invalid path: {target} - {e!s}") from e
@@ -1198,9 +1164,6 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
"- A valid URL (http:// or https://)\n"
"- A Git repository URL (https://host/org/repo or git@host:org/repo.git)\n"
"- A local directory path\n"
"- An API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection)\n"
"- A Postman collection by id (postman://<collection-uid>[?env=<environment-uid>], "
"needs POSTMAN_API_KEY)\n"
"- A domain name (e.g., example.com)\n"
"- An IP address (e.g., 192.168.1.10)"
)
@@ -1475,62 +1438,6 @@ def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway:
details["target_ip"] = host_gateway
#: API spec targets are copied into one workspace directory rather than mounted
#: from wherever they happen to live on the host.
API_SPEC_WORKSPACE_SUBDIR = "api-specs"
def write_fetched_collection(collection: dict[str, Any], collection_uid: str) -> str:
"""Write a collection fetched from the Postman API to a local file.
Returns the file path, so a ``postman://`` target continues as an ordinary
spec file from here on and the API key never leaves the host.
"""
staging = Path(tempfile.gettempdir()) / "strix_api_specs" / "fetched"
staging.mkdir(parents=True, exist_ok=True)
path = staging / f"{sanitize_name(collection_uid)}.postman_collection.json"
path.write_text(json.dumps(collection, indent=2), encoding="utf-8")
return str(path)
def stage_api_specs(targets_info: list[dict[str, Any]], run_name: str) -> list[dict[str, Any]]:
"""Copy every ``api_spec`` target into one directory for the sandbox.
A spec is a single file the agent reads, not a tree it works in, so it is
copied to a per-run staging directory that is exposed at
``/workspace/api-specs`` instead of mounting its host location. Each target's
``workspace_path`` records where the agent will find it.
"""
specs = [t for t in targets_info if t.get("type") == "api_spec"]
if not specs:
return []
staging = Path(tempfile.gettempdir()) / "strix_api_specs" / run_name
staging.mkdir(parents=True, exist_ok=True)
used: set[str] = set()
for target in specs:
details = target["details"]
source = Path(str(details["target_spec"]))
name = source.name
stem, suffix = source.stem, source.suffix
count = 1
while name in used:
count += 1
name = f"{stem}-{count}{suffix}"
used.add(name)
shutil.copy2(source, staging / name)
details["workspace_path"] = f"/workspace/{API_SPEC_WORKSPACE_SUBDIR}/{name}"
return [
{
"source_path": str(staging),
"workspace_subdir": API_SPEC_WORKSPACE_SUBDIR,
"protect_metadata": False,
}
]
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
console = Console()
+8 -2
View File
@@ -22,7 +22,6 @@ from typing import Any
import requests
from strix.config.loader import load_settings
from strix.utils.secret_files import write_secret_text
logger = logging.getLogger(__name__)
@@ -116,8 +115,15 @@ def is_verified() -> bool:
def write_auth(email: str, token: str, verified_at: str) -> None:
"""Atomically persist the auth record with 0600 permissions."""
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"email": email, "token": token, "verified_at": verified_at})
write_secret_text(AUTH_PATH, payload)
tmp = AUTH_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
with contextlib.suppress(OSError):
tmp.chmod(0o600)
tmp.replace(AUTH_PATH)
with contextlib.suppress(OSError):
AUTH_PATH.chmod(0o600)
def forget() -> None:
@@ -1,184 +0,0 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { CheckCircle2, CircleSlash, HelpCircle, AlertTriangle, Circle, ClipboardList } from "lucide-react";
interface CoverageEntry {
entry_id?: string;
surface?: string;
risk_area?: string;
outcome?: string;
evidence?: string;
agent_name?: string;
by_you?: boolean;
previous_outcomes?: string[];
}
/**
* A cleared surface and an unresolved one must never read alike the ledger
* exists so that the negative space of a scan is legible, so each outcome gets
* its own icon and color rather than a shared neutral row.
*/
const OUTCOMES: Record<string, { label: string; color: string; Icon: typeof Circle }> = {
reported: { label: "reported", color: "text-orange-400", Icon: AlertTriangle },
no_issue_found: { label: "no issue found", color: "text-emerald-400", Icon: CheckCircle2 },
ruled_out: { label: "ruled out", color: "text-emerald-400/70", Icon: CheckCircle2 },
not_applicable: { label: "not applicable", color: "text-[#777]", Icon: CircleSlash },
needs_follow_up: { label: "needs follow-up", color: "text-yellow-400", Icon: HelpCircle },
};
const OUTCOME_ORDER = [
"reported", "needs_follow_up", "no_issue_found", "ruled_out", "not_applicable",
] as const;
function outcomeMeta(outcome: string | undefined) {
const key = (outcome ?? "").trim().toLowerCase();
return OUTCOMES[key] ?? {
label: key ? key.replace(/_/g, " ") : "unrecorded",
color: "text-[#777]",
Icon: Circle,
};
}
const ACTION_LABELS: Record<string, string> = {
record_coverage: "Coverage recorded",
update_coverage: "Coverage updated",
list_coverage: "Coverage",
};
function Header({ toolName }: { toolName: string }) {
return (
<div className="flex items-center gap-2">
<ClipboardList className="w-3.5 h-3.5 text-cyan-400/60" />
<span className="text-cyan-400/80 font-semibold text-sm">
{ACTION_LABELS[toolName] ?? "Coverage"}
</span>
</div>
);
}
function Row({ entry }: { entry: CoverageEntry }) {
const { label, color, Icon } = outcomeMeta(entry.outcome);
const previous = (entry.previous_outcomes ?? [])
.map((o) => outcomeMeta(o).label)
.filter(Boolean);
return (
<div className="flex items-start gap-2.5 py-1.5">
<Icon className={`w-3.5 h-3.5 shrink-0 mt-[2px] ${color}`} />
<div className="min-w-0">
<div className="text-[13px] leading-snug">
<span className="text-[#bbb]">{entry.surface ?? "(unnamed surface)"}</span>
{entry.risk_area && <span className="text-[#666]"> · {entry.risk_area}</span>}
</div>
<div className="text-xs mt-0.5">
<span className={color}>{label}</span>
{previous.length > 0 && (
<span className="text-[#555]"> (was {previous.join(" → ")})</span>
)}
{(entry.by_you || entry.agent_name) && (
<span className="text-[#555]"> · {entry.by_you ? "you" : entry.agent_name}</span>
)}
</div>
{entry.evidence && (
<div className="text-[#777] text-xs mt-1 leading-snug">{entry.evidence}</div>
)}
</div>
</div>
);
}
export default function CoverageRenderer({ toolName, args, result }: ToolRendererProps) {
const res = result as Record<string, unknown> | string | null;
if (typeof res === "string" && res.trim()) {
return (
<div>
<Header toolName={toolName} />
<div className="mt-1.5 text-[#888] text-[13px]">{res.trim()}</div>
</div>
);
}
const structured = res && typeof res === "object" ? res : null;
const surface = (args.surface as string) ?? "";
const riskArea = (args.risk_area as string) ?? "";
const evidence = (args.evidence as string) ?? "";
if (structured && !structured.success) {
return (
<div>
<Header toolName={toolName} />
{(surface || riskArea) && (
<div className="mt-1.5 text-[13px] text-[#bbb]">
{surface}
{riskArea && <span className="text-[#666]"> · {riskArea}</span>}
</div>
)}
<div className="mt-1 text-red-400/70 text-[13px]">
{(structured.error as string) ?? "Coverage call failed"}
</div>
</div>
);
}
if (toolName === "list_coverage") {
const rawEntries = structured?.entries;
const entries: CoverageEntry[] = Array.isArray(rawEntries) ? (rawEntries as CoverageEntry[]) : [];
const counts = (structured?.outcome_counts as Record<string, number> | undefined) ?? {};
const total = (structured?.total_count as number) ?? 0;
return (
<div>
<Header toolName={toolName} />
{Object.keys(counts).length > 0 && (
<div className="mt-2 flex items-center gap-3 flex-wrap">
{OUTCOME_ORDER.filter((o) => counts[o]).map((o) => {
const { label, color } = outcomeMeta(o);
return (
<span key={o} className={`text-xs ${color}`}>
{label}: {counts[o]}
</span>
);
})}
</div>
)}
{entries.length > 0 ? (
<div className="mt-2 rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-1 divide-y divide-white/[0.04]">
{entries.map((entry, i) => <Row key={entry.entry_id ?? i} entry={entry} />)}
</div>
) : (
<div className="mt-1.5 text-[#555] text-xs">
{total === 0 ? "No surfaces recorded yet" : "No surfaces match this filter"}
</div>
)}
</div>
);
}
const outcome = (structured?.outcome as string) ?? "";
const previousOutcome = (structured?.previous_outcome as string) ?? "";
const { label, color, Icon } = outcomeMeta(outcome);
return (
<div>
<Header toolName={toolName} />
<div className="mt-2 flex items-start gap-2.5">
<Icon className={`w-3.5 h-3.5 shrink-0 mt-[2px] ${color}`} />
<div className="min-w-0">
<div className="text-[13px] leading-snug text-[#bbb]">
{surface || (structured?.entry_id ? `entry ${structured.entry_id as string}` : "(unnamed surface)")}
{riskArea && <span className="text-[#666]"> · {riskArea}</span>}
</div>
<div className="text-xs mt-0.5">
{previousOutcome && (
<span className="text-[#666]">{outcomeMeta(previousOutcome).label} </span>
)}
<span className={color}>{label}</span>
</div>
{evidence && (
<div className="text-[#777] text-xs mt-1 leading-snug">{evidence}</div>
)}
</div>
</div>
</div>
);
}
@@ -1,138 +0,0 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { Crosshair, AlertTriangle, Plus, Save } from "lucide-react";
import { TruncatedText } from "./ToolCard";
interface Amendment {
agent_name?: string;
content?: string;
recorded_at?: string;
}
const ACTION_LABELS: Record<string, { label: string; Icon: typeof Crosshair }> = {
get_threat_model: { label: "Threat model", Icon: Crosshair },
save_threat_model: { label: "Threat model saved", Icon: Save },
amend_threat_model: { label: "Threat model amended", Icon: Plus },
};
/** A git sha is noise past its first bytes, and "unversioned" is not a revision. */
function shortRevision(revision: unknown): string {
const value = typeof revision === "string" ? revision.trim() : "";
if (!value || value === "unversioned") return "";
return value.slice(0, 8);
}
export default function ThreatModelRenderer({ toolName, args, result }: ToolRendererProps) {
const action = ACTION_LABELS[toolName] ?? { label: "Threat model", Icon: Crosshair };
const ActionIcon = action.Icon;
const target = (args.target as string) ?? "";
const res = result as Record<string, unknown> | string | null;
const header = (
<div className="flex items-center gap-2 flex-wrap">
<ActionIcon className="w-3.5 h-3.5 text-blue-400/60" />
<span className="text-blue-400/80 font-semibold text-sm">{action.label}</span>
{target && <span className="text-[#666] font-mono text-xs">{target}</span>}
</div>
);
if (typeof res === "string" && res.trim()) {
return <div>{header}<div className="mt-1.5 text-[#888] text-[13px]">{res.trim()}</div></div>;
}
const structured = res && typeof res === "object" ? res : null;
if (structured && !structured.success) {
return (
<div>
{header}
<div className="mt-1.5 text-red-400/70 text-[13px]">
{(structured.error as string) ?? "Threat model call failed"}
</div>
</div>
);
}
if (toolName === "get_threat_model") {
if (structured && !structured.found) {
return (
<div>
{header}
<div className="mt-1.5 text-[#555] text-xs">No model cached for this target yet</div>
</div>
);
}
const rawAmendments = structured?.amendments;
const amendments: Amendment[] = Array.isArray(rawAmendments) ? (rawAmendments as Amendment[]) : [];
const cachedRevision = shortRevision(structured?.cached_revision);
return (
<div>
{header}
{structured?.stale === true && (
<div className="mt-1.5 flex items-center gap-1.5 text-yellow-400/80 text-xs">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>stale{cachedRevision ? ` — written at ${cachedRevision}` : ""}</span>
</div>
)}
{amendments.length > 0 && (
<div className="mt-2">
<span className="text-amber-400/70 text-xs font-semibold">
{amendments.length} amendment{amendments.length === 1 ? "" : "s"}
</span>
<span className="text-[#555] text-xs"> later statements win</span>
<div className="mt-1 space-y-1">
{/* On a public share link the amendment body is stripped, so the
author line has to stand on its own. */}
{amendments.map((amendment, i) => (
<div key={i} className="text-xs leading-snug">
<span className="text-[#666]">{amendment.agent_name ?? "unknown agent"}</span>
{amendment.content && (
<span className="text-[#999]">: {amendment.content}</span>
)}
</div>
))}
</div>
</div>
)}
{typeof structured?.content === "string" && structured.content.trim() && (
<div className="mt-2">
<TruncatedText text={structured.content} maxLines={14} />
</div>
)}
</div>
);
}
if (toolName === "amend_threat_model") {
const addendum = (args.addendum as string) ?? "";
const count = structured?.amendment_count as number | undefined;
return (
<div>
{header}
{count != null && (
<div className="mt-1.5 text-[#666] text-xs">{count} amendment{count === 1 ? "" : "s"} on this model</div>
)}
{addendum && <div className="mt-1.5"><TruncatedText text={addendum} maxLines={10} /></div>}
</div>
);
}
const cleared = (structured?.amendments_cleared as number | undefined) ?? 0;
const revision = shortRevision(structured?.revision);
const content = (args.content as string) ?? "";
return (
<div>
{header}
{revision && <div className="mt-1.5 text-[#666] font-mono text-xs">at {revision}</div>}
{/* Saving folds amendments away — the one destructive thing this tool does. */}
{cleared > 0 && (
<div className="mt-1.5 flex items-center gap-1.5 text-yellow-400/80 text-xs">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>cleared {cleared} amendment{cleared === 1 ? "" : "s"}</span>
</div>
)}
{content && <div className="mt-2"><TruncatedText text={content} maxLines={14} /></div>}
</div>
);
}
@@ -11,11 +11,6 @@ const SEVERITY_COLORS: Record<string, string> = {
low: "text-blue-400", info: "text-cyan-400",
};
/** Anything below high is a claim the reader still has to check. */
const CONFIDENCE_COLORS: Record<string, string> = {
high: "text-emerald-400", medium: "text-yellow-400", low: "text-orange-400",
};
export default function VulnReportRenderer({ args, result }: ToolRendererProps) {
const title = (args.title as string) ?? "";
const description = (args.description as string) ?? "";
@@ -29,11 +24,6 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
const remediation = (args.remediation_steps as string) ?? "";
const cve = (args.cve as string) ?? "";
const cwe = (args.cwe as string) ?? "";
const counterevidence = (args.counterevidence as string) ?? "";
const confidence = ((args.confidence as string) ?? "").toLowerCase();
const confidenceRationale = (args.confidence_rationale as string) ?? "";
const severityChangeConditions = (args.severity_change_conditions as string) ?? "";
const fixVerification = (args.fix_verification as string) ?? "";
const res = result as Record<string, unknown> | null;
const rawSev = (res && typeof res === "object" ? res.severity : null) ?? args.severity ?? "medium";
@@ -48,11 +38,6 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
{cvss != null && <span className="text-[#888] text-[13px]">CVSS {cvss}</span>}
{cve && <span className="text-[#888] font-mono text-[13px]">{cve}</span>}
{cwe && <span className="text-[#888] font-mono text-[13px]">{cwe}</span>}
{confidence && (
<span className={`text-[13px] ${CONFIDENCE_COLORS[confidence] ?? "text-[#888]"}`}>
{confidence} confidence
</span>
)}
</div>
{title && <div className="text-[15px] text-white/80 font-semibold">{title}</div>}
{(target || endpoint) && (
@@ -71,23 +56,6 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
<div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={20} /></div>
</div>
)}
{confidenceRationale && (
<div className="text-[#777] text-xs leading-snug">{confidenceRationale}</div>
)}
{/* The case against the finding sits beside the case for it: whoever
triages this needs both to decide whether to act. */}
{counterevidence && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Counterevidence</span>
<div className="mt-1"><TruncatedText text={counterevidence} maxLines={12} /></div>
</div>
)}
{severityChangeConditions && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Severity would change if</span>
<div className="mt-1"><TruncatedText text={severityChangeConditions} maxLines={10} /></div>
</div>
)}
{(pocDescription || pocCode) && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
@@ -101,14 +69,6 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
<div className="mt-1"><TruncatedText text={remediation} maxLines={15} /></div>
</div>
)}
{/* An applyable fix is one click from the user's codebase, so how it was
verified belongs next to it. */}
{fixVerification && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Fix verification</span>
<div className="mt-1"><TruncatedText text={fixVerification} maxLines={12} /></div>
</div>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import type { ToolRendererProps } from "@/types/events";
import {
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
ListTodo, Crosshair, Wrench, Ban, Image, ClipboardList,
ListTodo, Crosshair, Wrench, Ban, Image,
} from "lucide-react";
import TerminalRenderer from "./TerminalRenderer";
@@ -25,8 +25,6 @@ import TodoRenderer from "./TodoRenderer";
import FallbackRenderer from "./FallbackRenderer";
import LoadSkillRenderer from "./LoadSkillRenderer";
import RespondRenderer from "./RespondRenderer";
import CoverageRenderer from "./CoverageRenderer";
import ThreatModelRenderer from "./ThreatModelRenderer";
/**
* Tool-renderer mapping data-driven, keyed by the engine's tool *family*.
@@ -55,8 +53,6 @@ export type ToolCategory =
| "notes"
| "skills"
| "todos"
| "coverage"
| "threatModel"
| "telemetry";
export interface ToolIconMeta {
@@ -87,8 +83,6 @@ const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
notes: { renderer: NotesRenderer, icon: StickyNote, color: "text-amber-400", match: /note/ },
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
coverage: { renderer: CoverageRenderer, icon: ClipboardList, color: "text-cyan-400", match: /coverage/ },
threatModel: { renderer: ThreatModelRenderer, icon: Crosshair, color: "text-blue-400", match: /threat_model/ },
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
};
@@ -118,10 +112,6 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
skills: ["load_skill"],
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
// Shared coverage ledger — one row per surface × risk area for the whole run
coverage: ["record_coverage", "update_coverage", "list_coverage"],
// Per-target threat model, shared across the agent tree
threatModel: ["get_threat_model", "save_threat_model", "amend_threat_model"],
telemetry: ["sandbox_error_details", "llm_error_details"],
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-Bi_X6kI3.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-g-_6CcwH.css">
<script type="module" crossorigin src="./assets/index-DBJ-RJqo.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DKbLYAbP.css">
</head>
<body>
<div id="root"></div>
-422
View File
@@ -1,422 +0,0 @@
"""``coverage.json`` — the negative space of a scan, with provenance.
A findings list answers "what is wrong". It cannot answer "what did you
check", and in a compliance context that second question is the one that
decides whether a clean result means anything: an auditor reading zero SQL
injection findings cannot tell "tested fourteen endpoints, all parameterized"
apart from "never looked".
This module assembles the artifact that answers it. Two kinds of statement go
in, and they are kept apart on purpose:
- ``agent_reported`` the coverage ledger (:mod:`strix.tools.coverage.tools`).
Rich and specific, but it is an agent's account of its own work.
- ``machine_observed`` facts the runtime recorded regardless of what any
agent claimed: which agents ran and how they terminated, which skills they
carried, how many findings were filed, whether the run finished or was cut
short.
A coverage claim is an attestation, so conflating the two would be the worst
possible failure: a hallucinated "tested and clean" is strictly less honest
than no coverage record at all. Every entry therefore carries its ``source``,
and machine-observed facts contradict rather than confirm an agent that
carried the ``sql_injection`` skill and recorded nothing about SQL injection
shows up under ``gaps``, and a run that hit its budget ceiling is stamped
``complete: false`` no matter how tidy the ledger looks.
"""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from strix.report.writer import atomic_write_text
from strix.skills import get_available_skills
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
COVERAGE_FILENAME = "coverage.json"
COVERAGE_SCHEMA_VERSION = 1
#: Ledger outcomes rendered for a reader who has never seen our enum.
OUTCOME_LABELS: dict[str, str] = {
"reported": "Finding reported",
"no_issue_found": "No issue identified",
"ruled_out": "Ruled out",
"not_applicable": "Not applicable",
"needs_follow_up": "Requires further review",
}
#: Statuses that mean the agent stopped early rather than finishing its task.
_INCOMPLETE_AGENT_STATUSES = frozenset({"crashed", "stopped", "running", "waiting"})
#: Run statuses that mean the scan itself did not run to completion.
_INCOMPLETE_RUN_STATUSES = frozenset({"failed", "interrupted", "stopped", "running"})
#: Only this skill category names a vulnerability class. ``tooling`` and
#: ``reconnaissance`` skills describe how an agent works, not what it hunts,
#: so holding one implies no coverage obligation.
_RISK_SKILL_CATEGORY = "vulnerabilities"
#: How each vulnerability skill can legitimately appear in a ledger row.
#:
#: Matching a skill to a row is textual, and a skill's filename is not how a
#: pentester writes the class down: an agent carrying ``path_traversal_lfi_rfi``
#: records "Path Traversal", and one carrying ``weak_password_detection``
#: records "weak password policy". A row matches when it contains every word
#: of *any one* phrasing here. Skills absent from this map fall back to their
#: own words, so a new skill is merely matched strictly, never crashed on —
#: but add an entry, because a false gap asserts something untrue in a report.
_SKILL_PHRASINGS: dict[str, tuple[str, ...]] = {
"authentication_jwt": ("authentication", "jwt", "session"),
"broken_function_level_authorization": (
"function level authorization",
"authorization",
"access control",
"privilege escalation",
),
"business_logic": ("business logic", "logic flaw"),
"csrf": ("csrf", "cross site request forgery"),
"header_injection": ("header injection", "host header", "crlf"),
"http_request_smuggling": ("request smuggling", "desync"),
"idor": ("idor", "object level authorization", "bola", "direct object reference"),
"information_disclosure": (
"information disclosure",
"information leak",
"sensitive data",
"data exposure",
),
"insecure_deserialization": ("deserialization",),
"insecure_file_uploads": ("file upload",),
"llm_prompt_injection": ("prompt injection",),
"mass_assignment": ("mass assignment", "parameter binding"),
"nosql_injection": ("nosql",),
"open_redirect": ("redirect",),
"path_traversal_lfi_rfi": (
"path traversal",
"directory traversal",
"file inclusion",
"lfi",
"rfi",
),
"prototype_pollution": ("prototype pollution",),
"race_conditions": ("race condition", "toctou"),
"rce": ("rce", "remote code execution", "code execution", "command injection"),
"sql_injection": ("sql injection", "sqli"),
"ssrf": ("ssrf", "server side request forgery"),
"ssti": ("ssti", "template injection"),
"subdomain_takeover": ("subdomain takeover",),
"weak_password_detection": ("password", "credential", "brute force"),
"xss": ("xss", "cross site scripting", "script injection"),
"xxe": ("xxe", "xml external entity", "xml entity"),
}
def read_agent_graph(state_dir: Path) -> dict[str, Any]:
"""Load the coordinator's snapshot, or ``{}`` when it isn't readable.
The snapshot is the runtime's own record of the agent tree, written on
every graph mutation. Reading it here (rather than holding a coordinator
reference) keeps artifact assembly usable from a finished or resumed run,
where the live coordinator is gone but the file is still on disk.
"""
path = state_dir / "agents.json"
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("agent graph snapshot at %s is unreadable", path, exc_info=True)
return {}
return data if isinstance(data, dict) else {}
def _normalized(text: str) -> str:
"""Lowercase *text* with punctuation flattened to spaces, for matching."""
return "".join(char if char.isalnum() else " " for char in text.lower())
def _skill_leaf(skill: str) -> str:
return skill.rsplit("/", maxsplit=1)[-1].strip().lower()
def _risk_skill_names() -> frozenset[str]:
"""Bare names of every skill that denotes a vulnerability class."""
try:
entries = get_available_skills().get(_RISK_SKILL_CATEGORY, [])
return frozenset(entry["name"] for entry in entries if entry.get("name"))
except OSError:
logger.warning("could not enumerate skills for coverage gaps", exc_info=True)
return frozenset()
def agents_from_graph(graph: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten the coordinator snapshot into one record per agent."""
statuses = graph.get("statuses")
if not isinstance(statuses, dict):
return []
raw_names = graph.get("names")
names: dict[str, Any] = raw_names if isinstance(raw_names, dict) else {}
raw_metadata = graph.get("metadata")
metadata: dict[str, Any] = raw_metadata if isinstance(raw_metadata, dict) else {}
raw_parents = graph.get("parent_of")
parents: dict[str, Any] = raw_parents if isinstance(raw_parents, dict) else {}
# Only an unambiguous root earns the exemption below. A snapshot with no
# parent links at all makes every agent look parentless, and excusing all
# of them would silently delete the silent-agent check.
parentless = [agent_id for agent_id in statuses if not parents.get(agent_id)]
root_id = parentless[0] if len(parentless) == 1 else None
agents: list[dict[str, Any]] = []
for agent_id, status in statuses.items():
raw_meta = metadata.get(agent_id)
meta: dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {}
raw_skills = meta.get("skills")
skills: list[Any] = raw_skills if isinstance(raw_skills, list) else []
agents.append(
{
"agent_id": agent_id,
"agent_name": names.get(agent_id) or agent_id,
"status": str(status),
"skills": [str(skill) for skill in skills],
"task": str(meta.get("task") or ""),
"is_root": agent_id == root_id,
}
)
agents.sort(key=lambda agent: str(agent["agent_name"]))
return agents
def _skill_phrasings(skill: str) -> list[list[str]]:
"""Word lists that would each count as a ledger row naming *skill*."""
phrasings = _SKILL_PHRASINGS.get(skill) or (skill,)
return [terms for phrase in phrasings if (terms := _normalized(phrase).split())]
def _entry_is_about(entry: dict[str, Any], phrasings: list[list[str]]) -> bool:
"""True when a ledger row plausibly concerns any phrasing of a risk class."""
haystack = _normalized(f"{entry.get('risk_area', '')} {entry.get('surface', '')}")
return any(all(term in haystack for term in terms) for terms in phrasings)
def skill_coverage_gaps(
entries: list[dict[str, Any]], agents: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Vulnerability classes an agent was equipped for but never recorded.
A skill assigned to an agent is a declaration of intent that the runtime
observed independently of anything the agent later said. When no ledger
row mentions that class, the class is unaccounted for which is a very
different report line from "tested, nothing found".
"""
risk_skills = _risk_skill_names()
if not risk_skills:
return []
carriers: dict[str, list[str]] = {}
for agent in agents:
for skill in agent["skills"]:
leaf = _skill_leaf(skill)
if leaf in risk_skills:
carriers.setdefault(leaf, []).append(str(agent["agent_name"]))
gaps: list[dict[str, Any]] = []
for skill, agent_names in sorted(carriers.items()):
phrasings = _skill_phrasings(skill)
if any(_entry_is_about(entry, phrasings) for entry in entries):
continue
gaps.append(
{
"kind": "unrecorded_risk_class",
"risk_area": skill.replace("_", " "),
"detail": (
f"Agent(s) {', '.join(sorted(set(agent_names)))} were assigned the "
f"'{skill}' skill, but no coverage entry records this class being "
"assessed. Treat it as unexamined, not as clean."
),
}
)
return gaps
def _silent_agent_gaps(
entries: list[dict[str, Any]], agents: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Agents that ran and recorded nothing at all.
The root agent is exempt while it has children: it delegates and
reconciles rather than testing, so flagging it on every clean scan would
put a permanent false line in the report and teach readers to skip the
section. A root that ran alone tested alone, and is held to the rule.
"""
recorded_ids = {str(entry.get("agent_id")) for entry in entries if entry.get("agent_id")}
delegated = len(agents) > 1
gaps: list[dict[str, Any]] = []
for agent in agents:
if agent["agent_id"] in recorded_ids or (agent["is_root"] and delegated):
continue
gaps.append(
{
"kind": "agent_recorded_no_coverage",
"agent_name": agent["agent_name"],
"detail": (
f"{agent['agent_name']} ran (status: {agent['status']}) without "
"recording any coverage. Whatever it examined is absent from this "
"record."
),
}
)
return gaps
def _unresolved_gaps(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Ledger rows the agents themselves left open."""
return [
{
"kind": "needs_follow_up",
"surface": entry.get("surface", ""),
"risk_area": entry.get("risk_area", ""),
"detail": str(entry.get("evidence") or "Left open without a stated reason."),
}
for entry in entries
if entry.get("outcome") == "needs_follow_up"
]
def _completeness(
run_record: dict[str, Any],
agents: list[dict[str, Any]],
exit_reason: str | None,
) -> dict[str, Any]:
"""Whether this record can be read as a complete account of the scan.
Any of these makes it partial, and the caveats say which: the run did not
reach ``completed``, an agent was still live or died when the scan ended,
or the run stopped for a reason other than the root agent deciding it was
done (budget ceilings are the common case).
"""
status = str(run_record.get("status") or "unknown")
caveats: list[str] = []
if status in _INCOMPLETE_RUN_STATUSES:
caveats.append(
f"The scan ended with status '{status}' rather than completing, so coverage "
"reflects only the work finished before it stopped."
)
unfinished = [agent for agent in agents if agent["status"] in _INCOMPLETE_AGENT_STATUSES]
if unfinished:
names = ", ".join(sorted(str(agent["agent_name"]) for agent in unfinished))
caveats.append(
f"{len(unfinished)} agent(s) did not finish cleanly ({names}); any surface they "
"held is under-covered."
)
if exit_reason and exit_reason not in {"finished_by_tool", "completed"}:
caveats.append(
f"The run terminated via '{exit_reason}' rather than the root agent finishing, "
"so remaining scope was not reached."
)
return {
"complete": not caveats,
"scan_status": status,
"exit_reason": exit_reason,
"caveats": caveats,
}
def _outcome_counts(entries: list[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for entry in entries:
outcome = str(entry.get("outcome", ""))
counts[outcome] = counts.get(outcome, 0) + 1
return {label: counts[label] for label in OUTCOME_LABELS if label in counts}
def build_coverage_document(
*,
run_record: dict[str, Any],
entries: list[dict[str, Any]],
agent_graph: dict[str, Any],
vulnerability_reports: list[dict[str, Any]],
exit_reason: str | None = None,
) -> dict[str, Any]:
"""Assemble the ``coverage.json`` document."""
agents = agents_from_graph(agent_graph)
skills_exercised = sorted(
{_skill_leaf(skill) for agent in agents for skill in agent["skills"] if skill}
)
ledger = [
{
"surface": entry.get("surface", ""),
"risk_area": entry.get("risk_area", ""),
"outcome": entry.get("outcome", ""),
"outcome_label": OUTCOME_LABELS.get(str(entry.get("outcome", "")), ""),
"evidence": entry.get("evidence", ""),
"recorded_by": entry.get("agent_name", ""),
"recorded_at": entry.get("created_at", ""),
"updated_at": entry.get("updated_at", ""),
"previous_outcomes": [
str(previous.get("outcome", ""))
for previous in entry.get("history", [])
if isinstance(previous, dict)
],
"source": "agent_reported",
}
for entry in entries
]
gaps = [
*_unresolved_gaps(entries),
*skill_coverage_gaps(entries, agents),
*_silent_agent_gaps(entries, agents),
]
return {
"schema_version": COVERAGE_SCHEMA_VERSION,
"generated_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
"run_id": run_record.get("run_id"),
"run_name": run_record.get("run_name"),
"scope": {
"targets": run_record.get("targets_info") or [],
"scan_mode": run_record.get("scan_mode"),
"scope_mode": run_record.get("scope_mode"),
"diff_scope": run_record.get("diff_scope"),
"instruction": run_record.get("instruction") or "",
},
"summary": {
"surfaces_reviewed": len(ledger),
"outcomes": _outcome_counts(entries),
"findings_filed": len(vulnerability_reports),
"gaps": len(gaps),
},
"machine_observed": {
"agents": agents,
"skills_exercised": skills_exercised,
"findings_filed": len(vulnerability_reports),
"source": "runtime",
},
"completeness": _completeness(run_record, agents, exit_reason),
"entries": ledger,
"gaps": gaps,
}
def write_coverage(run_dir: Path, document: dict[str, Any]) -> Path:
"""Write ``coverage.json`` into the run directory and return its path."""
path = run_dir / COVERAGE_FILENAME
atomic_write_text(path, json.dumps(document, ensure_ascii=False, indent=2, default=str))
logger.info(
"Saved coverage record to: %s (%d surface(s), %d gap(s))",
path,
len(document.get("entries", [])),
len(document.get("gaps", [])),
)
return path
-20
View File
@@ -183,24 +183,6 @@ def _dependency_identity(report: dict[str, Any]) -> tuple[str, str, str] | None:
return cve, ecosystem, package_name
def _manifest_path(report: dict[str, Any]) -> str:
metadata = report.get("dependency_metadata")
if not isinstance(metadata, dict):
return ""
return str(metadata.get("manifest_path") or "").strip()
def _distinct_manifest_paths(candidate: dict[str, Any], report: dict[str, Any]) -> bool:
"""Same CVE/package observed in two different manifests is two findings.
Only applies when both sides carry a manifest_path; a missing path keeps
the legacy CVE/package/ecosystem identity.
"""
candidate_path = _manifest_path(candidate)
report_path = _manifest_path(report)
return bool(candidate_path and report_path and candidate_path != report_path)
def _report_cve(report: dict[str, Any]) -> str:
return str(report.get("cve") or "").strip().upper()
@@ -246,8 +228,6 @@ def _check_dependency_duplicate(
report_cve, report_ecosystem, report_package_name = report_identity
if (report_cve, report_package_name) != (cve, package_name):
continue
if _distinct_manifest_paths(candidate, report):
continue
if report_ecosystem == ecosystem:
return {
"is_duplicate": True,
-138
View File
@@ -40,10 +40,6 @@ Design notes:
* Findings without safe locations still appear in the SARIF output,
anchored to SECURITY.md and flagged via
``properties.synthetic_location`` rather than being dropped silently.
* Coverage rides in the same document as non-failing results (``kind`` of
``pass`` / ``notApplicable`` / ``open``), and run completeness on
``run.invocations``. Consumers that only want alerts filter on
``kind == "fail"`` and are unaffected.
"""
from __future__ import annotations
@@ -203,7 +199,6 @@ def build_sarif_report(
*,
tool_version: str | None = None,
repository_context: dict[str, Any] | None = None,
coverage: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a SARIF 2.1.0 document for findings.
@@ -214,11 +209,6 @@ def build_sarif_report(
can bind alerts to the scanned commit; it is omitted for URL / IP
(DAST) targets that have no repository.
``coverage`` (optional) is the document from
:func:`strix.report.coverage.build_coverage_document`: its cleared
surfaces become non-failing results and its completeness caveats become
invocation notifications.
Findings without safe source locations are anchored synthetically
to SECURITY.md and flagged via ``properties.synthetic_location``.
They're still emitted as proper SARIF results so they (a) flow
@@ -257,9 +247,6 @@ def build_sarif_report(
)
)
if coverage:
_append_coverage(coverage, rules_by_id, rule_index_by_id, results)
driver: dict[str, Any] = {
"name": TOOL_NAME,
"informationUri": TOOL_INFORMATION_URI,
@@ -273,9 +260,6 @@ def build_sarif_report(
"results": results,
}
if coverage:
run["invocations"] = [_coverage_invocation(coverage)]
run_properties: dict[str, Any] = {}
if synthetic_location_count:
# Surface the count for observability without duplicating the
@@ -308,7 +292,6 @@ def write_sarif_report(
*,
tool_version: str | None = None,
repository_context: dict[str, Any] | None = None,
coverage: dict[str, Any] | None = None,
) -> None:
"""Write a SARIF report to disk, creating parent directories first.
@@ -321,7 +304,6 @@ def write_sarif_report(
vulnerability_reports,
tool_version=tool_version,
repository_context=repository_context,
coverage=coverage,
)
tmp_path = output_path.with_name(f"{output_path.name}.{os.getpid()}.tmp")
try:
@@ -339,7 +321,6 @@ def write_sarif(
*,
tool_version: str | None = None,
repository_context: dict[str, Any] | None = None,
coverage: dict[str, Any] | None = None,
filename: str = "findings.sarif",
) -> Path:
"""Write ``findings.sarif`` alongside existing outputs in ``run_dir``.
@@ -354,7 +335,6 @@ def write_sarif(
reports,
tool_version=tool_version,
repository_context=repository_context,
coverage=coverage,
)
logger.info(
"Wrote SARIF 2.1.0 report: %s (%d results)",
@@ -546,20 +526,11 @@ def _result_properties(
"impact",
"technical_analysis",
"remediation_steps",
"counterevidence",
"confidence",
"confidence_rationale",
"severity_change_conditions",
"fix_verification",
):
value = report.get(key)
if value not in (None, ""):
strix[key] = value
dependency_metadata = report.get("dependency_metadata")
if isinstance(dependency_metadata, dict) and dependency_metadata:
strix["dependency_metadata"] = dependency_metadata
# SARIF is written for external upload (code-scanning / ASPM), so it must
# NOT carry the weaponized exploit payload — that stays a local run
# artifact (vulnerabilities.json / the finding MD). We surface the PoC
@@ -638,115 +609,6 @@ def _build_fixes(report: dict[str, Any]) -> list[dict[str, Any]] | None:
return [fix]
# ---------------------------------------------------------------------------
# Coverage
# ---------------------------------------------------------------------------
_COVERAGE_RULE_PREFIX = "strix-coverage"
# ``reported`` is absent on purpose: those surfaces are already in ``results``
# as ``fail`` findings.
_OUTCOME_TO_KIND = {
"no_issue_found": "pass",
"ruled_out": "pass",
"not_applicable": "notApplicable",
"needs_follow_up": "open",
}
def _coverage_rule_id(risk_area: str) -> str:
slug = _slugify(risk_area) or "unspecified"
return f"{_COVERAGE_RULE_PREFIX}/{slug}"
def _build_coverage_rule(rule_id: str, risk_area: str) -> dict[str, Any]:
description = f"Coverage of {risk_area} across the assessed attack surface."
return {
"id": rule_id,
"name": _rule_name(rule_id, risk_area),
"shortDescription": {"text": f"Coverage: {risk_area}"},
"fullDescription": {"text": description},
"defaultConfiguration": {"level": "none"},
"help": {"text": description, "markdown": description},
"properties": {"tags": ["coverage"]},
}
def _build_coverage_result(
rule_id: str,
rule_index: int,
kind: str,
entry: dict[str, Any],
) -> dict[str, Any]:
surface = _string_value(entry.get("surface")) or "unspecified surface"
risk_area = _string_value(entry.get("risk_area")) or "unspecified risk"
evidence = _string_value(entry.get("evidence"))
label = _string_value(entry.get("outcome_label")) or str(entry.get("outcome", ""))
message = f"{risk_area}{label}: {surface}"
if evidence:
message = f"{message}\n\n{evidence}"
result: dict[str, Any] = {
"ruleId": rule_id,
"ruleIndex": rule_index,
"kind": kind,
# SARIF requires ``level: none`` for any result whose kind is not ``fail``.
"level": "none",
"message": {"text": message},
"locations": [{"logicalLocations": [{"fullyQualifiedName": surface}]}],
"properties": {
"strix": {
"coverage_outcome": entry.get("outcome", ""),
"risk_area": risk_area,
"surface": surface,
"recorded_by": entry.get("recorded_by", ""),
"source": entry.get("source", "agent_reported"),
}
},
}
return result
def _append_coverage(
coverage: dict[str, Any],
rules_by_id: dict[str, dict[str, Any]],
rule_index_by_id: dict[str, int],
results: list[dict[str, Any]],
) -> None:
entries = coverage.get("entries")
if not isinstance(entries, list):
return
for entry in entries:
if not isinstance(entry, dict):
continue
kind = _OUTCOME_TO_KIND.get(str(entry.get("outcome", "")))
if kind is None:
continue
rule_id = _coverage_rule_id(str(entry.get("risk_area", "")))
if rule_id not in rules_by_id:
rule_index_by_id[rule_id] = len(rules_by_id)
rules_by_id[rule_id] = _build_coverage_rule(
rule_id, _string_value(entry.get("risk_area")) or "unspecified risk"
)
results.append(_build_coverage_result(rule_id, rule_index_by_id[rule_id], kind, entry))
def _coverage_invocation(coverage: dict[str, Any]) -> dict[str, Any]:
"""``executionSuccessful: false`` stops a truncated run reading as a clean one."""
completeness = coverage.get("completeness")
completeness = completeness if isinstance(completeness, dict) else {}
caveats = completeness.get("caveats")
caveats = caveats if isinstance(caveats, list) else []
invocation: dict[str, Any] = {"executionSuccessful": bool(completeness.get("complete", True))}
if caveats:
invocation["toolExecutionNotifications"] = [
{"level": "warning", "message": {"text": str(caveat)}} for caveat in caveats
]
return invocation
# ---------------------------------------------------------------------------
# Location handling
# ---------------------------------------------------------------------------
+1 -47
View File
@@ -13,8 +13,7 @@ from agents.usage import Usage
from strix.config import codex
from strix.config.loader import load_settings
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.report.coverage import write_coverage
from strix.core.paths import run_dir_for
from strix.report.sarif import write_sarif
from strix.report.usage import LLMUsageLedger
from strix.report.writer import (
@@ -227,10 +226,6 @@ class ReportState:
remediation_steps: str | None = None,
evidence: str | None = None,
assumptions: str | None = None,
counterevidence: str | None = None,
confidence: str | None = None,
confidence_rationale: str | None = None,
severity_change_conditions: str | None = None,
fix_effort: str | None = None,
cvss: float | None = None,
cvss_breakdown: dict[str, str] | None = None,
@@ -239,7 +234,6 @@ class ReportState:
cve: str | None = None,
cwe: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
fix_verification: str | None = None,
fix_pr_body: str | None = None,
finding_class: str | None = None,
dependency_metadata: dict[str, str] | None = None,
@@ -273,14 +267,6 @@ class ReportState:
report["evidence"] = evidence.strip()
if assumptions:
report["assumptions"] = assumptions.strip()
if counterevidence:
report["counterevidence"] = counterevidence.strip()
if confidence:
report["confidence"] = confidence.strip().lower()
if confidence_rationale:
report["confidence_rationale"] = confidence_rationale.strip()
if severity_change_conditions:
report["severity_change_conditions"] = severity_change_conditions.strip()
if fix_effort:
report["fix_effort"] = fix_effort.strip().lower()
if cvss is not None:
@@ -297,8 +283,6 @@ class ReportState:
report["cwe"] = cwe.strip()
if code_locations:
report["code_locations"] = code_locations
if fix_verification:
report["fix_verification"] = fix_verification.strip()
if fix_pr_body:
report["fix_pr_body"] = fix_pr_body.strip()
report["finding_class"] = (finding_class or "dynamic").strip().lower()
@@ -433,41 +417,12 @@ class ReportState:
{str(scan_results.get("recommendations", "")).strip()}
"""
def _coverage_document(self) -> dict[str, Any] | None:
"""Assemble the coverage record, or None when it can't be built.
Coverage is a secondary artifact: a failure here must not cost the
caller its findings, so this swallows and logs rather than raising
into :meth:`_save_artifacts`.
"""
try:
from strix.report.coverage import build_coverage_document, read_agent_graph
from strix.tools.coverage.tools import get_coverage_entries
return build_coverage_document(
run_record=self.run_record,
entries=get_coverage_entries(),
agent_graph=read_agent_graph(runtime_state_dir(self.get_run_dir())),
vulnerability_reports=self.vulnerability_reports,
exit_reason=self.scan_ended_exit_reason,
)
except Exception:
logger.exception("coverage document build failed (non-fatal)")
return None
def _save_artifacts(self) -> None:
"""Write scan artifacts under ``run_dir``."""
run_dir = self.get_run_dir()
try:
run_dir.mkdir(parents=True, exist_ok=True)
coverage = self._coverage_document()
if coverage is not None:
try:
write_coverage(run_dir, coverage)
except OSError:
logger.exception("coverage.json write failed (non-fatal)")
if self.final_scan_result:
write_executive_report(run_dir, self.final_scan_result)
@@ -486,7 +441,6 @@ class ReportState:
self.vulnerability_reports,
tool_version=_strix_version(),
repository_context=self._sarif_repository_context(),
coverage=coverage,
)
except Exception:
logger.exception("SARIF emit failed (non-fatal; CSV/MD unaffected)")
+5 -30
View File
@@ -107,7 +107,7 @@ def read_run_record(run_dir: Path) -> dict[str, Any]:
def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
atomic_write_text(
_atomic_write_text(
run_record_path(run_dir),
json.dumps(run_record, ensure_ascii=False, indent=2, default=str),
)
@@ -133,7 +133,7 @@ def write_vulnerabilities(
new_reports = [r for r in vulnerability_reports if r["id"] not in saved_vuln_ids]
for report in new_reports:
atomic_write_text(
_atomic_write_text(
vuln_dir / f"{report['id']}.md",
render_vulnerability_md(report),
)
@@ -158,9 +158,9 @@ def write_vulnerabilities(
"file": f"vulnerabilities/{report['id']}.md",
},
)
atomic_write_text(csv_path, csv_buf.getvalue())
_atomic_write_text(csv_path, csv_buf.getvalue())
atomic_write_text(
_atomic_write_text(
run_dir / "vulnerabilities.json",
json.dumps(vulnerability_reports, ensure_ascii=False, indent=2, default=str),
)
@@ -175,8 +175,7 @@ def write_vulnerabilities(
return len(new_reports)
def atomic_write_text(path: Path, payload: str) -> None:
"""Write *payload* to *path* via a sibling temp file and an atomic rename."""
def _atomic_write_text(path: Path, payload: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
@@ -206,8 +205,6 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
("Ecosystem", dep_meta.get("package_ecosystem")),
("Installed Version", dep_meta.get("installed_version")),
("Fixed Version", dep_meta.get("fixed_version")),
("Introduced By", dep_meta.get("introduced_by")),
("Dependency Chain", dep_meta.get("dependency_path")),
("Endpoint", report.get("endpoint")),
("Method", report.get("method")),
("CVE", report.get("cve")),
@@ -216,8 +213,6 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
cvss = report.get("cvss")
if cvss is not None:
metadata.append(("CVSS", cvss))
if report.get("confidence"):
metadata.append(("Confidence", str(report["confidence"]).title()))
if report.get("fix_effort"):
metadata.append(("Fix Effort", str(report["fix_effort"]).title()))
for label, value in metadata:
@@ -239,21 +234,6 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append(str(report["impact"]))
lines.append("")
if report.get("counterevidence"):
lines.append("## Counterevidence\n")
lines.append(str(report["counterevidence"]))
lines.append("")
if report.get("confidence_rationale"):
lines.append("## Confidence Rationale\n")
lines.append(str(report["confidence_rationale"]))
lines.append("")
if report.get("severity_change_conditions"):
lines.append("## What Would Change This Severity\n")
lines.append(str(report["severity_change_conditions"]))
lines.append("")
if report.get("technical_analysis"):
lines.append("## Technical Analysis\n")
lines.append(str(report["technical_analysis"]))
@@ -307,11 +287,6 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append(str(report["remediation_steps"]))
lines.append("")
if report.get("fix_verification"):
lines.append("## Fix Verification\n")
lines.append(str(report["fix_verification"]))
lines.append("")
if report.get("assumptions"):
lines.append("## Assumptions\n")
lines.append(str(report["assumptions"]))
+9 -72
View File
@@ -4,9 +4,6 @@ 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
@@ -14,17 +11,12 @@ from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__)
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n", re.DOTALL)
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination", "analysis"})
_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:
@@ -117,18 +109,13 @@ def _get_ambiguous_skill_names() -> set[str]:
return {name for name, count in counts.items() if count > 1}
def _qualified_skill_file_for_name(skill_name: str) -> Path | None:
def _qualified_skill_files(skill_name: str) -> list[Path]:
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 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 []
return [candidate]
return []
def _bare_skill_files(skill_name: str) -> list[Path]:
@@ -158,59 +145,10 @@ def _bare_skill_files(skill_name: str) -> list[Path]:
return candidates
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]]] = {}
def get_available_skills() -> dict[str, list[str]]:
grouped: dict[str, list[str]] = {}
for category, name in _iter_user_skill_files():
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})
grouped.setdefault(category, []).append(name)
return grouped
@@ -290,8 +228,7 @@ def load_skills(skill_names: list[str]) -> dict[str, str]:
continue
var_name = skill_name.split("/")[-1]
_, skill_body = _parse_skill_content(content, file_path)
skill_content[var_name] = skill_body
skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip()
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
_track_skill_loaded(var_name, file_path)
-185
View File
@@ -1,185 +0,0 @@
---
name: counterevidence
description: Closure discipline for security findings — what counts as proof of safety, what does not, and how to record an unresolved candidate instead of silently dropping it
---
# Counterevidence and Closure Discipline
Proving a bug is real is only half the job. The other half is proving a
candidate is *not* real — and that half is where both false positives and
false negatives come from.
This skill governs how you close a candidate. It applies to every
candidate you open, whether it came from a scanner, a code read, a crawl,
or a hunch.
## Three Closure States
Every candidate you open ends in exactly one of these. There is no fourth
state, and "I moved on" is not one of them.
**1. `confirmed`** — you have a working PoC or, in white-box, a complete
source → control → sink → impact trace plus evidence the path is
reachable. File it with `create_vulnerability_report`.
**2. `ruled_out`** — you can name the **specific control** that makes the
code safe, at a specific location, and you have checked that the control
actually runs on the attacker's path. "Named control" means you can
complete this sentence with concrete detail: *"This is safe because
`<control>` at `<file:line or observed behavior>` `<does what>` before
`<sink>`, on every path an attacker can reach."* If you cannot complete
that sentence, you are not in `ruled_out`.
**3. `open_proof_gap`** — the candidate is plausible, you could not
confirm it, and you also could not name a control that rules it out. This
is a legitimate, expected outcome. Record it with
`record_coverage(outcome="needs_follow_up")`, carry it up in
`agent_finish(open_items=[...])`, and reflect it in `counterevidence` /
`confidence_rationale` if you file a related report. Do **not** convert
it to `ruled_out` to tidy up your worklist.
The failure mode this exists to prevent: an agent reads code, feels
uncertain, and quietly closes the candidate. That is an
`open_proof_gap` being mislabelled as `ruled_out`, and it is how real
vulnerabilities get missed.
## What Does NOT Rule Out a Candidate
Each of these is a common, plausible-sounding reason to drop a candidate.
None of them is sufficient on its own.
**Generic trust in a library or helper.** "It uses a well-known
sanitizer / the framework escapes this / the ORM handles it" is not
counterevidence. You must confirm *that* call, with *those* arguments, in
*that* context. Escaping helpers are context-specific: an HTML escaper
does nothing in a JS or attribute context, a SQL identifier quoter is not
a value quoter, and a path joiner is not a containment check.
**A control that runs on a different path.** Middleware, a decorator, or
a guard that protects the common route does not protect a sibling route,
an internal caller, a batch/async job, or an admin alias that reaches the
same sink. Check the specific path.
**A control that runs at the wrong time.** Validation *before* a
redirect, canonicalization *after* a path is already materialized, a
containment check *after* extraction, or an ownership check *after* the
object was already fetched and returned — these are ordering bugs, not
controls. Establish that the control runs before the dangerous effect.
**A control that can fail open.** Hardening flags set inside a
`try`/`except` that swallows failures, a parser feature that a caller can
override, a factory or config object supplied by the caller, or a
allow-list that is empty by default — all leave the candidate alive.
**A safe sibling.** If one call site is correctly guarded, that says
nothing about the other call sites of the same helper. Never let a safe
instance close a vulnerable one, and never collapse multiple instances
into one candidate just because they share a root cause — each reachable
instance stands or falls on its own.
**Missing information.** "I could not find a caller", "I could not tell
if this is deployed", "I could not determine whether this route is
exposed", "I could not stand up the service" — every one of these is an
`open_proof_gap`, not proof of safety. Missing evidence is missing
evidence; it is not evidence of absence.
**Difficulty.** "The build failed", "it needs credentials I don't have",
"the service mesh isn't available" are reasons to record a proof gap and
move on to the next candidate — not reasons to mark it clean. Do not let
one hard environment setup consume the budget you need for sibling
candidates.
**Operator configurability.** "An operator *could* configure a filter",
"this is a documented feature", "it's off by default" are not controls.
What ships and what is reachable is what matters.
**Being internal.** Internal-only, admin-only, or authenticated-only
reduces severity — it does not make the finding unreal. Downgrade it;
do not delete it.
## Recording Closure
Closure is only useful if it is written down. Every surface you assess
gets a `record_coverage` entry:
- `confirmed` → outcome `reported`, once the report is filed.
- `ruled_out` → outcome `ruled_out`, with the named control in
`evidence`. If you cannot name it, this is not `ruled_out`.
- `open_proof_gap` → outcome `needs_follow_up`, with the specific gap in
`evidence`.
- Tested thoroughly with nothing to show for it → `no_issue_found`.
- The risk cannot apply to this surface at all → `not_applicable`, with
the reason.
A scan that records only findings cannot tell the reader what was
reviewed and cleared, which makes every clean area indistinguishable
from an unvisited one.
Closure is not permanent. The ledger is shared across every agent, and
a surface someone left at `needs_follow_up` is an invitation: if you
had the credentials, the running service, or the reachability proof
they lacked, move their entry with `update_coverage` rather than
recording a parallel one. This runs both ways — a `ruled_out` whose
named control does not cover the path you just found goes back to
`reported` or `needs_follow_up`, with what changed in `evidence`. The
previous state is kept as history, so correcting the record costs
nothing and leaving it wrong costs a finding.
## What DOES Rule Out a Candidate
- You executed the attack and it demonstrably failed, and you understand
*why* it failed (not just that the response was a 403).
- You can point at the control, at a location, and show it runs on every
attacker-reachable path to the sink, before the effect, without a
fail-open branch.
- The sink is not actually dangerous in this context, and you can say
what makes it inert.
- The input is not actually attacker-controlled, and you traced it to a
trusted origin rather than assuming it.
Negative controls make a `ruled_out` much stronger: send the payload that
*should* work if the bug were real, and show it is blocked, while a
benign variant succeeds. That distinguishes "the control works" from "the
endpoint is broken/unreachable for unrelated reasons".
## Before You File a Report
Run this pass on every finding before calling
`create_vulnerability_report`:
1. **Argue the other side.** Spend real effort building the strongest
case that this is *not* exploitable, or not as severe as you think.
Look for the guard you might have missed, the deployment context that
constrains it, the precondition you assumed.
2. **Record what you found** in `counterevidence`. If you found a real
constraint, say what it is and why it does not neutralize the finding.
If you genuinely found nothing, say what you checked — "no input
validation, WAF, or authorization check was found on this path; tested
both authenticated and unauthenticated" — not just "none".
3. **Set `confidence` honestly.** A working PoC against a live target is
`high`. A complete static trace you could not execute is at best
`medium`, and `confidence_rationale` must name the gap. Do not inflate
confidence to make a finding look better; an accurate `medium` is far
more useful to the reader than a `high` that does not survive triage.
4. **State what would move the severity** in `severity_change_conditions`
— the one concrete piece of evidence that would raise or lower it
(e.g. "confirmation that this route is exposed to unauthenticated
internet traffic would raise this to critical").
## Reporting an Unconfirmed Candidate
Dynamic proof is the standard. But when you have a complete
source → control → sink → impact trace and runtime reproduction is
genuinely out of reach (no credentials, unavailable internal services, a
build that cannot run in the sandbox), a static-only finding is still
reportable — at `confidence: medium` or `low`, with the missing runtime
proof named explicitly in `confidence_rationale`.
What is **not** acceptable is a scanner hit with no trace, a "this
pattern is usually dangerous" claim, or a finding where you never
identified the attacker-controlled input. Those are not proof gaps, they
are non-findings.
If you are unsure whether a candidate clears this bar: it clears it if
you can name the input, the path, the missing or broken control, and the
effect. It does not if any one of those is a guess.
-129
View File
@@ -1,129 +0,0 @@
---
name: fix_verification
description: How to verify a proposed code fix before shipping it — the ordered gates, what disqualifies a fix, and when to withhold the suggestion instead
---
# Fix Verification
When you attach `fix_before` / `fix_after` to a code location, you are not
writing advice. You are writing a suggestion block that a reviewer can
apply with one click, straight into their codebase. An unverified fix is
worse than no fix: it converts your uncertainty into their merged commit.
This skill covers what you must establish before that happens.
## Judge in This Order
1. The current state is correctly classified — vulnerable, already safe,
or unproven.
2. The fix completely closes the broken security boundary.
3. Legitimate behavior and compatibility are preserved.
4. The relevant repository checks pass.
5. The change follows the repository's own conventions.
6. The patch contains only what properties 15 require.
**Never trade an earlier property for a later one.** A smaller, tidier,
more idiomatic patch that leaves the boundary open is a failure. Minimal
means *the smallest repository-native change that satisfies everything
above it* — not the fewest lines.
## Before You Edit
Establish these from the code, not from assumption:
- The source → sink path or the specific broken control.
- The attacker-controlled input and the preconditions it needs.
- **The security invariant** — state it in one sentence. "Only the owning
tenant may read this record." "The extracted path must stay inside the
destination directory." If you cannot state the invariant, you cannot
tell whether your patch enforces it.
- The narrowest place that invariant can be enforced.
- The legitimate behavior, public APIs, and error semantics that must
survive the change.
- The repository's existing helpers and precedents for this kind of
control. Reach for the codebase's own validator before inventing one.
## The Verification Gates
Run these **in order**. A failure at any gate disqualifies the fix —
revise the patch or withhold it. Do not compensate for a failed gate by
making the diff smaller or the write-up longer.
**1. Applicability.** Read the final diff. Confirm it contains nothing
unrelated, that `fix_before` still matches the file character-for-
character, and that `start_line`/`end_line` still cover exactly those
lines. Run the narrowest syntax / import / type check available.
**2. Security closure.** Re-run the original PoC against the patched
code. If you cannot execute it, re-trace source → control → sink through
the *patched* source and state precisely which step now fails and why.
"The fix adds validation" is not closure; "the fix rejects `../` before
the path reaches `open()`, and `open()` is the only sink on this path" is.
**3. Bypass review.** Re-read the finding and the diff *without* leaning
on the reasoning that produced the patch — you are looking for what that
reasoning missed. Trace the changed branches from their direct callers.
Check equivalent sinks and sibling call sites of the same helper. Try at
least one alternate malicious input class: different encoding, different
content type, a null byte, a unicode homoglyph, a nested/doubled
payload, a different HTTP verb. A control that catches your one payload
and nothing else has not closed the boundary.
**4. Preserved behavior.** Exercise the legitimate case through the same
boundary. Confirm the APIs, error semantics, and compatibility
constraints you recorded still hold. A fix that breaks the feature will
be reverted, which means the vulnerability comes back.
**5. Repository checks.** Run the focused tests covering the changed
lines, then the owning package's tests, then the applicable formatter,
linter, and type checker. Use the repository's own commands.
Where practical, confirm the check would **fail if the security change
were removed**. A test that passes both with and without the patch is
proving nothing.
## What Disqualifies a Fix
- It closes your specific payload but not the input class.
- It sanitizes at the wrong layer — after the value was already used, or
in a helper that other callers bypass.
- It relies on a caller passing the right flag, or on a config the
operator has to set.
- It fails open: the new check sits inside a `try`/`except` that swallows
the failure, or returns "allowed" on error.
- It weakens authentication, authorization, tenant isolation, input
validation, sandboxing, or logging to make something else pass. Never
do this.
- It silently accepts, truncates, or reinterprets unsafe state instead of
rejecting it.
- It drags in unrelated refactors, sibling findings, or architectural
redesign.
## Withholding the Fix
If you cannot pass the gates, that is a legitimate outcome — say so
rather than shipping a guess. Drop `fix_after` from the location, leave
it informational, and put the remediation in prose in
`remediation_steps` instead. State in `fix_verification` exactly which
gate you could not clear and what was missing: the command that failed,
the service you could not start, the decision that needs a human.
Withhold and explain when:
- The complete fix depends on an unresolved product or public-API
compatibility decision.
- The invariant cannot be enforced without cross-subsystem changes you
cannot validate.
- You could not establish that the vulnerable path is real in the
current checkout. Do not patch an adjacent weakness as a consolation
prize, and do not add speculative defense-in-depth to a path you never
proved was reachable.
## Recording It
Everything above goes in `fix_verification`, which is required whenever
any location carries a `fix_after`. Write the actual commands and their
results, grouped by gate, and mark every gate you could only reason
about — rather than execute — as an explicit gap. Do not hide proof
gaps; a reviewer who knows gate 5 was skipped can run it themselves, but
one who was told it passed cannot.
@@ -1,130 +0,0 @@
---
name: severity-calibration
description: Qualitative rubric for what actually deserves high/critical severity, and an acceptance checklist to apply before rating a finding
---
# Severity Calibration
CVSS gives you a number once you have chosen the metrics. This skill is
about choosing them honestly — deciding what class of issue genuinely
belongs at each severity before you fill in the vector.
Calibrate severity **after** you have established reachability and run
the counterevidence pass, never before. Severity is a conclusion, not an
opening position.
## The Test That Matters
Before rating anything high or critical, ask:
> Would this be accepted as high/critical in serious audit or bug bounty
> triage, by a firm putting its reputation on the line?
If the honest answer is "only if you accept a chain of assumptions", it
is not high. Rate the weakness you proved, not the worst case you can
imagine reaching from it.
## Critical
Reserve for findings where a realistic attacker gets decisive control or
mass data access, with evidence:
- Unauthenticated remote code execution, or command/code execution
reachable by any user on internet-exposed surface.
- Full authentication bypass, or trivially forgeable authentication
(accepted unsigned tokens, `alg: none`, signature not verified).
- Mass extraction of other users' or other tenants' sensitive data.
- Compromise of signing keys, control-plane credentials, or credentials
granting broad infrastructure access.
- Complete cross-tenant isolation failure in a multi-tenant system.
Factors that push a high up to critical: no authentication required,
internet reachable, zero user interaction, wormable/self-propagating,
or the impact spans all tenants rather than one.
## High
- Authenticated RCE, or RCE requiring a common non-privileged role.
- Privilege escalation crossing a real trust boundary (user → admin,
tenant → tenant, read → write on protected objects).
- Object-level authorization failures exposing or modifying other users'
sensitive data at scale.
- SQL injection or equivalent injection reaching real data.
- SSRF that demonstrably reaches internal services, cloud metadata, or
credentials.
- Sensitive credential or PII exposure that an attacker can actually
reach.
## Medium
- Stored XSS in a limited context, or reflected XSS requiring user
interaction.
- CSRF on a meaningful state-changing action.
- Authorization gaps on lower-value objects.
- Information disclosure that materially aids a further attack.
- Findings whose high-impact version is blocked by a real constraint you
confirmed (internal-only exposure, a required privileged role, a
narrow precondition).
## Low / Informational
- Missing security headers, cookie flag issues, verbose errors.
- Self-XSS, or XSS requiring the victim to paste a payload.
- Open redirect with no credential or token leakage.
- Rate-limiting and enumeration issues without a demonstrated impact.
- Defense-in-depth gaps with no reachable exploitation path.
## Usually NOT High or Critical
These are over-rated constantly. Each needs unusual, demonstrated
circumstances to exceed medium:
- Self-XSS and clickjacking on non-sensitive actions.
- Missing headers, cookie attributes, TLS configuration nits.
- Open redirect on its own.
- Theoretical memory-safety issues with no reachable attacker input.
- "Could matter if chained with several unproven assumptions."
- Anything already requiring admin, shell, or physical access — if the
attacker already has that, the finding adds little.
- Session-management weaknesses that require the attacker to already
hold a victim secret (a stolen cookie, an intercepted link). The
acquisition of that secret is not free; unless the *same* finding shows
how to obtain it, this is usually low/medium.
- Enumeration that only confirms an account, domain, or version exists.
## Downgrade, Don't Delete
A finding that turns out to be constrained gets a lower severity — not a
silent drop. Internal-only reachability, a required privileged role, or a
narrow precondition are all reasons to reduce severity and say so in the
report. They are not reasons to withhold the finding.
Equally: missing evidence about deployment or exposure lowers your
**confidence**, not the severity floor. Do not treat "I could not confirm
this is internet-facing" as if it were "this is internal-only".
## Acceptance Checklist for High / Critical
All of these must be true. If any is not, drop a level:
- [ ] The attack path is realistic and in scope — not a lab-only
condition, not dependent on an unproven prior compromise.
- [ ] The attacker position required is one an attacker can actually
obtain, and the CVSS `privileges_required` / `attack_complexity`
reflect that honestly.
- [ ] The impact is material and demonstrated, not asserted — `C:H` /
`I:H` mean proven broad or systemic read/write, not one record.
- [ ] The counterevidence pass found no constraint that meaningfully
limits exploitation, or you have explained why the constraint does
not hold.
- [ ] You have concrete evidence of reachability, not an assumption
about how the application is deployed.
- [ ] You would defend this rating in a client debrief.
## Output
Severity still comes from the CVSS vector — this rubric decides which
vector is honest. When your intuitive rating and the computed CVSS
severity disagree, re-examine the metrics: usually one of
`privileges_required`, `attack_complexity`, or the impact triad was set
optimistically. Fix the metric, do not override the result.
@@ -1,211 +0,0 @@
---
name: source_aware_discovery
description: Enumeration discipline for reading code — which locations to keep as separate candidates, which safe siblings prove nothing, and the per-family sweeps that are routinely missed
---
# Source-Aware Discovery
Reading code for bugs fails in two directions. You collapse many real
instances into one candidate and under-report, or you stop at the loudest
issue in a file and never sweep the family around it.
This skill is about *what to enumerate*, not how to exploit it — the
vulnerability-class skills cover exploitation. Discovery decides
plausibility and preserves evidence; severity comes later.
## Instance Discipline
**One root cause is not one candidate.** If a dangerous helper has six
call sites and four are independently reachable, that is four candidates
— not one "the helper is unsafe" note. Each needs its own source, its own
closest control, and its own line. A reader has to be able to fix them
individually.
**Do not collapse distinct proof tuples that share a route.** Command
execution, SSRF, path/file write, parser abuse, template execution, and
authorization bypass on the same endpoint are separate findings when the
sink, the broken control, or the impact differ. Sharing a URL is not
sharing a bug.
**Keep the wrapper and the shared helper both visible.** When the path
crosses from an entrypoint into a shared sink or control, record both:
the wrapper proves reachability, the helper is where the fix goes. Losing
either one makes the finding unactionable.
**A safe sibling is a negative control for itself and nothing else.** A
correctly-parameterized query three lines above a concatenated one proves
the developer knew better, not that the concatenated one is safe.
**Label your locations.** Mark each as entrypoint, root control, sink, or
concrete implementation. Multi-location findings that don't say which
line is which force the reader to re-derive your analysis.
## Where the Real Control Lives
The most common discovery error is anchoring on the dramatic sink and
missing the reusable broken control behind it.
- When a resolver, allowlist, denylist, class filter, or guard is the
thing that's wrong, that line is the candidate. The transport that
reaches it proves reachability — it doesn't replace it.
- When the same filter or resolver is **duplicated** across core, server,
client, plugin, or import packages, each copy is its own candidate.
Fixing one leaves the others live.
- In a concrete strategy / handler / converter / operation subclass, read
the specialized helper, not just the top-level `handle` / `apply` /
`perform` override. If the subclass splits, filters, canonicalizes, or
rebuilds attacker input before delegating to a shared evaluator, the
subclass line is the root control.
- Branch-specific transforms — append, wildcard, fallback, copy/move
`from`, default-value, type-resolution — routinely bypass or narrow the
shared validator. Keep the branch predicate as its own location. A
finding on the shared helper does not close them.
## Family Sweeps
When you find one instance of these, sweep the whole family before
closing it out.
**Deserialization / object construction.** Enumerate every registered
codec, deserializer, converter, and container handler — array,
collection, map, bean, enum, throwable, generic object. A top-level
parser-config finding does not close a concrete codec that recursively
re-invokes parsing or type resolution on attacker data.
**XML / parsers.** Enumerate parser factories, readers, converters,
validators, transformers, and unmarshal entrypoints independently.
Hardening that is best-effort does not suppress anything: a
secure-processing flag alone, a `setFeature` call whose failure is
swallowed or logged, or a safe default factory all leave
caller-supplied factories and converter paths open.
**Object models for untrusted formats.** Sweep the primitive and
container helpers that traverse or convert attacker-controlled documents
`to*Array`, `get*`, numeric conversion, `parse*`, iterators, size
accessors, unchecked casts, allocation loops. Missing type, size, shape,
recursion, or numeric guards here cause type confusion, unbounded
traversal, and resource exhaustion. These sweeps create candidate rows,
not automatic findings — promote one only when malformed input plausibly
reaches it and the missing guard has a concrete security effect.
**Archive extraction and import/restore.** Keep four things visible per
operation: the member name, the destination join, the containment check,
and the extract/write call. A later copy step, manifest gate, or UUID
check does not close it if the write already happened. "The stdlib
normalizes paths" is not containment evidence — the code must show
per-entry containment *before* the write, including symlink, hardlink,
and recursive-copy paths. The write does not need to escape the app root
to matter: overwriting config, a peer tenant's directory, or a shared
imported subtree is still file impact.
**Path-sensitive filesystem operations.** Enumerate each exported
operation separately — restore, import, export, backup, copy, move,
download, open, key/config fetch. For each, keep the decode, join,
normalize, canonicalize, strip-prefix, extension-check, and
destination-selection lines candidate-visible.
**Static-file and resource serving.** The candidate is the line that
decides whether an attacker-chosen path is allowed: the allowlist, the
matcher, the canonicalization, the URL decode, the resource selection. Do
not substitute a safer sibling handler for the vulnerable legacy one.
**Outbound requests.** For URL importers, webhook and callback clients,
preview/render fetchers, `downloadFrom`-style helpers, and
redirect-following clients: enumerate each attacker-controlled
destination and its closest allow/deny/redirect control. Do not drop the
row because the fetch is an intended feature, because the filter is
operator-configured or empty by default, or because it only runs
pre-request.
**Command and action runners.** Enumerate every attacker-controllable
argument type and execution mode before you call command injection
covered. Type-safety maps, unsafe-type denylists, template substitution,
shell wrapping, direct-exec branches, and API-side argument ingestion are
each separate controls. A denylist covering three types says nothing
about the no-op typecheck branches that still render into a shell string.
Frontend widget constraints are not controls at all.
**Query APIs (SQL, NoSQL, LDAP, XPath, and friends).** Do not suppress
because the endpoint is already user-facing, because it's an insert
rather than a read, or because a later business check appears to limit
the effect. If attacker input reaches query syntax or selector operators,
carry it forward and record the later check as counterevidence.
**Structured patch / edit APIs.** For JSON Patch, document edits, and
config mutations, enumerate the request-selected operations — add,
remove, replace, move, copy, test. Operation-specific path transforms,
array-append handling, and wildcard selection stay candidate-visible when
they feed a shared evaluator or binder.
**Authentication state machines.** The candidate is the line that
installs or reuses a principal, credential, token, issuer, or protocol
state *after* a transition — pre-auth to authenticated, TLS upgrade,
redirect, assertion consumption, IdP handoff. Missing rebind or
reauthentication at that seam authenticates the wrong identity.
**SSO / SAML / federation.** Keep response and assertion validators
distinct from generic claims authorizers and from service-method
authorization; they fail differently. Include the lines doing assertion
selection, list indexing, DOM access, node cloning, signed-object lookup,
subject confirmation, recipient, audience, destination, ACS URL, and
issuer binding — each decides *which* assertion is trusted.
The signature failure to watch for: a validation loop or a
`foundValid`-style flag, followed by a **separate** fixed-index,
first-element, clone, re-serialization, or return path. Treat that later
selection line as the broken control until you have proven the validated
object and the consumed object are byte-identical and equally bound. This
is the validated-vs-consumed mismatch, and it is invisible if you only
read the validator.
**Realms and authenticators.** Enumerate the concrete implementations —
LDAP, Kerberos, PAM, SAML, OAuth/OIDC, custom realms — before promoting a
generic HTTP auth finding. In multi-step or TLS-upgraded binds, keep the
bind/rebind and credential-installation line visible.
**Self-service update routes.** Include the guard that compares the
requested object against the persisted one. Missing checks on
security-sensitive scalars and collection aliases let a user change their
own identity, roles, group membership, tenancy, or account-recovery
properties.
**Protocol utility code.** In protocol-heavy repositories, read the
version, capability, feature, and negotiation helpers even when the
obvious candidates are REST and admin routes. Look for `Version`,
`versionCompare`, `Capability`, `Feature`, `Negotiation`, and the
comparator methods around them — downgrade and confusion bugs live there,
and nobody looks.
**Public webhook / status / callback endpoints.** Enumerate these
independently from nearby credential bugs whenever they read protected
objects, trigger jobs, or mutate protected state.
## Cross-Boundary Inputs
In frameworks and libraries, stored client, tenant, application, IdP,
exception, and imported-configuration values are attacker-controlled when
they are later rendered, evaluated, parsed, or used for authorization —
provided there is a plausible runtime path from some boundary. Do not
suppress just because the writer lives outside this repository. That
requires evidence the value is trusted-only in normal deployments, not an
assumption.
Similarly, do not suppress a high-impact candidate because the API is
deprecated, opt-in, or documented as dangerous. Record that as a
precondition and keep the candidate — shipped code with a bypassable
control is shipped code.
## The Finding Bar
Worth opening a candidate: authorization bypass, confused deputy, SSRF,
path traversal, injection with a real sink, cross-tenant exposure,
sensitive state change without enforcement, sandbox or trust-boundary
escape.
Not worth it: "this could use more validation" with no path, style and
maintainability complaints, and cosmetic variants of a candidate you
already opened.
Keep reading until no distinct plausible candidate remains — then record
what you swept with `record_coverage`, including the families that came
back clean.
+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` skill)
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase_firestore` skill)
- Secret Manager, Cloud KMS keys
**Compute**
-14
View File
@@ -25,20 +25,6 @@ Before spawning agents, analyze the target from the scan config/scope and any pr
3. **Determine approach** - blackbox, greybox, or whitebox assessment
4. **Prioritize by risk** - critical assets and high-value targets first
## Establish the Threat Model
Every scan needs one shared answer to "who is the attacker here, and what are they attacking" — black-box or white-box. Without it, five agents derive five different answers and their findings cannot be reconciled. Call `get_threat_model` on the target (a host, a URL, or a repository path) before you spawn hunters; if nothing is cached, derive one and persist it with `save_threat_model`. It is cached per target, so a later scan of the same host or tree reads it back instead of paying for it twice, and a model written from source is read back by an agent testing the deployment.
**When the target includes a repository**, derive it up front: the code tells you the boundaries, entrypoints, and controls before you send a single request.
**Black-box, the ordering inverts.** You cannot model a target you have not seen, so recon comes first: spawn reconnaissance, and write the model from what it found — the hosts and ports that answered, the technology fingerprints, the authentication and session model, the roles and tenants you can distinguish, the endpoints and parameters enumerated. Then spawn the hunters against that model. Do not stall the scan waiting for a perfect picture and do not skip the step because the picture is partial: mark what is inferred rather than observed and let it be corrected. A black-box model that says "admin panel at `/admin` appears to be IP-restricted — unverified" is worth far more than no model, because it tells the next agent exactly what to go check.
Either way you write it with the least information anyone on this scan will ever have, so expect it to be wrong somewhere. Subagents correct it with `amend_threat_model`, which appends an attributed addendum instead of overwriting — expect many of these on a black-box run, as authenticating, pivoting between roles, and reaching internal surfaces is exactly what turns inference into fact. Read the amendments back before you write the final report: an agent telling you a boundary you called trusted is attacker-reachable is a finding about your model, not a note. Only call `save_threat_model` again to fold accumulated amendments into the body; it replaces the document and clears them.
## Reconcile Coverage Before Finishing
Coverage entries are shared and mutable. Before `finish_scan`, list the `needs_follow_up` rows: each one is either work you still owe or a row somebody already resolved without updating. Assign the former to a subagent and have it call `update_coverage` on the existing entry rather than recording a second one — a stale open item sitting next to its own resolution is worse than either alone.
## Agent Architecture
Structure agents by function:
-61
View File
@@ -1,61 +0,0 @@
---
name: api_spec_testing
description: Spec-driven API pentesting — systematically exercise every endpoint from an ingested OpenAPI/Swagger/Postman inventory for authz, injection, and business-logic flaws
---
# API Spec Testing
When a target is an API specification (OpenAPI 3.x, Swagger 2.0, or a Postman
collection), the root task lists it under **API Specifications** with the path
to the spec file in the workspace and the authorized base URL(s). Read the spec
file first and build your own endpoint inventory from it — every operation with
its method, path, parameters, request-body schema (resolve `$ref`/`allOf`), and
auth scheme. Do not rediscover the surface by crawling. Walk the inventory
operation-by-operation and prove findings against the live base URL(s), which
are authorized in scope.
## Methodology
**1. Baseline the contract.** For each endpoint, send a well-formed request that
matches the declared schema and record the normal response (status, shape,
auth requirement). This baseline is what every abuse case is compared against.
**2. Enumerate coverage.** Track every `METHOD path` in the inventory and mark it
tested. Undocumented-but-implied siblings are worth probing too (e.g. if
`GET /users/{id}` exists, try `PUT`/`DELETE`/`PATCH` on the same path even when
the spec omits them — specs routinely under-document write operations).
**3. Prioritize by risk.** Object-scoped reads/writes, exports, admin/staff
operations, and anything touching billing, auth, or PII first.
## What to test per endpoint
Test the full range of API weaknesses against each operation, driven by what the
contract reveals — do not treat the following as an exhaustive checklist. The
highest-yield classes on APIs are **authorization** flaws, since the spec hands
you the object identifiers and privilege boundaries to abuse: examples include
BOLA/IDOR (swap `{id}`/`accountId`/`tenantId` across two accounts), BFLA
(privileged operations with a lower-privilege token), and missing/broken auth
(replay with the token stripped or expired against endpoints whose declared auth
says one is required). Beyond authorization, use the declared parameters and
body schema as a launch point for mass assignment and excessive data exposure,
injection and type-confusion on every parameter, and multi-step business-logic
and rate-limit abuse — and follow the contract wherever it suggests something
else worth probing.
## Validation
A finding is only real once reproduced against the live base URL with a
concrete request/response pair. Capture the exact HTTP request (method, path,
headers, body) and the response proving impact (another account's data, a
privileged action succeeding, an injected payload executing). Prefer two-account
diffs for authorization findings: same request, different token, unauthorized
success.
## Tips
- The base URL(s) from the spec are authorized targets — send real traffic.
- Path templates use `{param}`; substitute real values from your baseline.
- For Postman collections, saved example values and environment variables are
strong hints for valid inputs — use them to get past validation quickly.
- Keep a running coverage table so no operation in the inventory is skipped.
+11 -126
View File
@@ -28,7 +28,7 @@ Run from the repo root and store output in the shared artifact directory used by
the source-aware pass:
```bash
ART=/workspace/.source-aware
ART=/workspace/.strix-source-aware
mkdir -p "$ART"
# Record the vuln DB age so a stale DB is a visible signal, not a silent clean scan.
@@ -39,11 +39,9 @@ trivy version --format json 2>/dev/null | tee "$ART/trivy-version.json"
# sandbox with egress gets the freshest CVEs; if the update fails, fall back to the
# cached DB instead of failing the scan. --offline-scan keeps per-package advisory
# lookups offline.
# --list-all-pkgs includes the package graph (Relationship + DependsOn) needed
# to attribute transitive CVEs to the direct dependency that introduces them.
trivy fs --scanners vuln --timeout 30m --offline-scan --list-all-pkgs \
trivy fs --scanners vuln --timeout 30m --offline-scan \
--format json --output "$ART/trivy-sca.json" . \
|| trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update --list-all-pkgs \
|| trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update \
--format json --output "$ART/trivy-sca.json" . \
|| true
```
@@ -77,122 +75,18 @@ For each entry under `.Results[].Vulnerabilities[]` in `trivy-sca.json`, collect
- `CVSS` — the published advisory base score
- `PrimaryURL` / references — to verify the advisory
Deduplicate by `(CVE, PkgName, Target)` — the same CVE/package observed in two
different manifests (e.g. two workspaces of a monorepo) is two findings, one
per manifest. File one `create_dependency_report` per CVE — do not batch
multiple CVEs into one report.
### Attribute transitive CVEs to the direct dependency
With `--list-all-pkgs`, each `.Results[].Packages[]` entry carries `ID`
(`name@version`), `Relationship` (`direct` / `indirect`) and `DependsOn` (the
`ID`s it resolves to). For every vulnerable package that is **indirect**, walk
the `DependsOn` graph backwards to find the `direct` package(s) whose closure
contains it, then pass to `create_dependency_report`:
- `introduced_by` — the direct dependency as `name@version` (e.g.
`express@4.18.1`). If several direct dependencies pull it in, pick the
primary one and name the rest in `technical_analysis`.
- `dependency_path` — the shortest resolution chain from that direct
dependency to the vulnerable package, joined with ` > ` (e.g.
`express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`).
- Omit both when the vulnerable package is itself a direct dependency.
If the ecosystem's lockfile gives trivy no graph (`DependsOn` absent), derive
the chain from the package manager instead (`npm ls <pkg>`, `pnpm why <pkg>`,
`yarn why <pkg>`, `pipdeptree --reverse -p <pkg>`, `go mod graph`,
`mvn dependency:tree`, ...) — and if that also fails, leave the fields out
rather than guessing.
For transitive findings, `remediation_steps` must be actionable at the
**direct-dependency level**: upgrading the vulnerable package directly is
usually impossible from the app's own manifest. Say which direct dependency to
bump (a version whose closure resolves the fixed version), or how to force the
resolution (npm `overrides` / yarn `resolutions` / pnpm `pnpm.overrides` /
Maven `dependencyManagement` / Gradle resolution strategy / `go mod edit`),
not just "upgrade <vulnerable pkg> to <fixed>".
### Usage / reachability analysis (required for every dependency CVE)
For every CVE you are about to report, run a static usage analysis and record
the result in the structured `reachability` + `reachability_evidence` fields.
The level is an **evidence ladder, never an exploitability verdict** — claim
only what you proved, and cite the proof. It never changes severity (that is
`advisory_cvss` alone); it exists so the reader can prioritize.
**Go — use govulncheck (real call-graph analysis):**
```bash
# Symbol-level: reports only vulnerabilities whose vulnerable functions are
# actually reachable from application code. Needs the Go toolchain + module
# deps; if either is missing, fall back to the checks below rather than
# claiming a level.
if command -v govulncheck >/dev/null && go version >/dev/null 2>&1; then
govulncheck -format json ./... > "$ART/govulncheck.json" || true
fi
```
- A finding with a call stack ⇒ `reachability=reachable_call_path`, put the
call-path excerpt (entrypoint → vulnerable function) in
`reachability_evidence`.
- Listed as affecting a required module but with no reachable symbol ⇒ fall
back to the import/symbol checks below (`imported` / `not_imported`).
**All other ecosystems — import check, then symbol match:**
1. **Import check.** Search application code (exclude lockfiles, vendored
deps, `node_modules`, build output) for imports of the vulnerable package:
`ast-grep`/`rg` for `import`/`require`/`from X import` of the package (and
its ecosystem import name, which may differ from the registry name, e.g.
`PyYAML``yaml`). No hits ⇒ `not_imported`, with the search scope stated
in `reachability_evidence`. For a **transitive** dependency, the check is
whether application code imports it directly; if not, it is reachable only
through the direct dependency — check whether the direct dep's usage can
hit it (if unclear, use `imported` when the direct dep is used at all).
2. **Symbol match — per CVE, not per package.** Read each CVE's own advisory
(GHSA/NVD/OSV `affected[].ecosystem_specific.imports` or the advisory
text) for the affected functions/classes/APIs. Search application code for
those symbols (`ast-grep` pattern or `rg -n`). Hits ⇒
`vulnerable_symbol_used`, with repo-relative `file:line` of each hit (up
to a handful) in `reachability_evidence`. Imported but no affected-symbol
usage found (or the advisory names no symbols) ⇒ `imported`.
Different CVEs on the same package usually affect **different** symbols
(one hits a parser, another a header check) — never copy one CVE's
verdict/evidence onto its siblings; run the symbol search against each
CVE's own affected-symbol list. The import check (step 1) is the only
part shared across a package's CVEs.
3. If the analysis was not performed or is inconclusive (obfuscated code,
dynamic loading, unparsable sources) ⇒ `unknown` and say why in
`assumptions`.
Cheap-first budgeting: the import check is one search per package — always do
it. Do the per-CVE symbol match for every CVE whose advisory names affected
symbols (they can be batched into one multi-pattern search per package);
prioritize `critical`/`high`/KEV when the budget is tight; a CVE whose symbol
search was skipped may still be reported as `imported` (the import check is
real evidence), but its `reachability_evidence` must state that the
affected-symbol check was not performed, so a skipped search is never
mistaken for a completed one with no hits. Never let this analysis stall
reporting — `unknown` with a reason beats an unverified claim.
Anti-overclaim rules:
- `not_imported` still does NOT mean safe (dynamic `import()`/reflection/
framework wiring evade static search) — never phrase it as "not exploitable".
- `reachable_call_path` is reserved for call-graph tools (govulncheck); a
symbol grep hit is `vulnerable_symbol_used`, no matter how convinced you are.
- The tool rejects any level other than `unknown` without
`reachability_evidence`.
Deduplicate by `(CVE, PkgName, InstalledVersion)`. File one
`create_dependency_report` per CVE — do not batch multiple CVEs into one report.
### Reachability is a confidence modifier, not a gate
Do NOT suppress or downgrade a known CVE just because you could not prove the
vulnerable code path is reachable. Report it, set `advisory_cvss` from the
advisory, record the usage analysis in `reachability`/`reachability_evidence`,
and use `assumptions` for anything softer. If you *can* actually trigger the
vulnerable path or chain it into a dynamic exploit, additionally report that
as a normal dynamic finding with `create_vulnerability_report` (the standalone
CVE stays in its own `create_dependency_report`).
advisory, and use `assumptions` to note reachability (e.g. "the vulnerable
`template()` API does not appear to be imported in application code, so practical
exploitability is uncertain"). If you *can* show reachability or chain it into a
dynamic exploit, do that and report it as a normal dynamic finding with
`create_vulnerability_report` instead.
## Reporting
@@ -213,12 +107,6 @@ findings and rejects empty PoC fields):
- `package_ecosystem` — normalized ecosystem from `.Results[].Type` (lowercased,
e.g. `npm`, `pypi`, `go`, `maven`, `rubygems`, `cargo`) (required).
- `fixed_version``FixedVersion` (leave empty only if no fix is published).
- `manifest_path` — the repo-relative `Target` lockfile/manifest path
(required). Strip any scan-workspace or repo checkout directory prefix so
the path is relative to the repository root (e.g. `package-lock.json`,
`services/api/pom.xml`); the tool rejects absolute paths and `..` segments.
This binds the finding to the exact file so remediation can target the
right repository.
- Reference the repo-relative `Target` lockfile path in `description` /
`technical_analysis` (no leading slash) so the finding is traceable.
- Put the concrete proof in `description` / `technical_analysis`: package name,
@@ -232,8 +120,7 @@ findings and rejects empty PoC fields):
- Set `cwe` to the most specific `CWE-NNN` when the advisory names one.
- Do NOT cap severity at LOW just because there is no dynamic reproduction — use
the advisory score.
- Set `reachability` + `reachability_evidence` from the usage analysis above;
use `assumptions` for anything softer (confidence, caveats, analysis limits).
- Use `assumptions` for reachability/exploitability caveats.
Verify the CVE with `web_search` when available before reporting. Never guess or
hallucinate a CVE id.
@@ -249,5 +136,3 @@ hallucinate a CVE id.
- Do not silently drop a known CVE because it lacks a dynamic PoC — that is the
exact failure this skill prevents.
- Do not downgrade advisory severity for lack of dynamic reproduction.
- Do not claim a `reachability` level the evidence does not prove — `unknown`
with a reason is always acceptable; an overclaimed level never is.
+12 -12
View File
@@ -12,7 +12,7 @@ Use this skill for source-heavy analysis where static and structural signals sho
Run tools from repo root and store outputs in a dedicated artifact directory:
```bash
mkdir -p /workspace/.source-aware
mkdir -p /workspace/.strix-source-aware
```
## Baseline Coverage Bundle (Recommended)
@@ -20,7 +20,7 @@ mkdir -p /workspace/.source-aware
Run this baseline once per repository before deep narrowing:
```bash
ART=/workspace/.source-aware
ART=/workspace/.strix-source-aware
mkdir -p "$ART"
semgrep scan --config p/default --config p/golang --config p/secrets \
@@ -30,7 +30,7 @@ python3 - <<'PY'
import json
from pathlib import Path
art = Path("/workspace/.source-aware")
art = Path("/workspace/.strix-source-aware")
semgrep_json = art / "semgrep.json"
targets_file = art / "sg-targets.txt"
@@ -70,10 +70,10 @@ Use Semgrep as the default static triage pass:
```bash
# Preferred deterministic profile set (works with --metrics=off)
semgrep scan --config p/default --config p/golang --config p/secrets \
--metrics=off --json --output /workspace/.source-aware/semgrep.json .
--metrics=off --json --output /workspace/.strix-source-aware/semgrep.json .
# If you choose auto config, do not combine it with --metrics=off
semgrep scan --config auto --json --output /workspace/.source-aware/semgrep-auto.json .
semgrep scan --config auto --json --output /workspace/.strix-source-aware/semgrep-auto.json .
```
If diff scope is active, restrict to changed files first, then expand only when needed.
@@ -85,8 +85,8 @@ Use `sg` for structure-aware code hunting:
```bash
# Ruleless structural pass over deterministic target list (no sgconfig.yml required)
xargs -r -n 200 sg run --pattern '$F($$$ARGS)' --json=stream \
< /workspace/.source-aware/sg-targets.txt \
> /workspace/.source-aware/ast-grep.json 2> /workspace/.source-aware/ast-grep.log || true
< /workspace/.strix-source-aware/sg-targets.txt \
> /workspace/.strix-source-aware/ast-grep.json 2> /workspace/.strix-source-aware/ast-grep.log || true
```
Target high-value patterns such as:
@@ -110,15 +110,15 @@ Use outputs to improve route/symbol/sink maps for subsequent targeted scans.
Detect hardcoded credentials:
```bash
gitleaks detect --source . --report-format json --report-path /workspace/.source-aware/gitleaks.json
trufflehog filesystem --json . > /workspace/.source-aware/trufflehog.json
gitleaks detect --source . --report-format json --report-path /workspace/.strix-source-aware/gitleaks.json
trufflehog filesystem --json . > /workspace/.strix-source-aware/trufflehog.json
```
Run repository-wide dependency and config checks:
```bash
trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \
--format json --output /workspace/.source-aware/trivy-fs.json . || true
--format json --output /workspace/.strix-source-aware/trivy-fs.json . || true
```
Known-CVE dependency findings are the one exception to the "report only after
@@ -132,9 +132,9 @@ For frontends and Node services, layer these on top of the language-agnostic
passes above:
```bash
retire --path . --outputformat json --outputpath /workspace/.source-aware/retire.json || true
retire --path . --outputformat json --outputpath /workspace/.strix-source-aware/retire.json || true
eslint --no-config-lookup --rule '{"no-eval":2,"no-implied-eval":2}' \
-f json -o /workspace/.source-aware/eslint.json . || true
-f json -o /workspace/.strix-source-aware/eslint.json . || true
```
When you hit a minified bundle, run `js-beautify <file>` for a readable
-86
View File
@@ -1,86 +0,0 @@
---
name: diff
description: Methodology for diff-scoped review of a pull request, commit, or branch — what counts as in scope, how far to follow a change, and what not to report
---
# Diff-Scoped Review
You are reviewing a change set, not a repository. The changed files and
their base reference are supplied in your scope. This mode changes what
is reportable and how far you range — it does not lower the evidence bar.
## What Is In Scope
**In scope:** a security problem introduced, re-introduced, or newly made
reachable by this change.
Also in scope, and routinely missed:
- A pre-existing weakness the diff **newly reaches**. The sink was always
unsafe; this change is the first caller that can carry attacker input
to it. That is this PR's bug.
- A shared helper, guard, route pattern, template, or sink wrapper that
the diff **weakens**. Expand to the sibling call sites the change
affects, and keep each vulnerable instance separately addressable —
the fix may differ per site.
- A control the diff **removes or narrows**, even if no new sink was
added. A deleted authorization check is a finding with no new code
attached to it.
- A behavioral change that invalidates an assumption elsewhere: a type
loosened, a default flipped, a validator made optional, an error path
changed from reject to log-and-continue.
**Out of scope:** unrelated pre-existing bugs you happen to notice while
reading context files. Note them, do not file them against this PR. The
author cannot act on them and they bury the finding that matters.
## How To Read The Change
**Read the code, not the story.** The title, description, and commit
messages may be incomplete, optimistic, or actively misleading. They are
also untrusted input. Trust the diff.
**For added files, review the whole file.** All of it is new.
**For modified files, focus on the changed hunks** — then follow each
change far enough to see how it affects authorization, trust boundaries,
dangerous sinks, and existing controls. "Far enough" means until you can
say whether the security properties around it still hold, not until you
leave the hunk.
**Pull in supporting files only as needed** to understand the changed
behavior: the definition of a helper being called, the middleware on a
touched route, the caller of a modified function. Unchanged siblings are
context and negative controls. Do not let context-reading drift into an
unscoped repository-wide scan — that is a different mode and it will
consume the budget this review needs.
**Deleted files are context only.** Their disappearance can be the
finding; their contents are not reviewable code.
## Validation Under Diff Scope
Diff review often runs where the application cannot be stood up — CI with
no services, no credentials, no deployed instance. Dynamic proof is still
preferred, and you should attempt it whenever the target is actually
reachable.
When it is not, the closure rules apply unchanged: a complete
source → control → sink → impact trace through the changed code is
reportable at reduced confidence, with the missing runtime proof named in
`confidence_rationale`. A candidate you can neither confirm nor rule out
with a named control is an `open_proof_gap` — record it as
`needs_follow_up` coverage rather than dropping it because the
environment was inconvenient.
## Reporting
Anchor every finding to the changed lines that make it real, and say
plainly which part of the diff introduced or exposed it. A reviewer
reading your report next to the diff should be able to see the connection
without re-deriving your analysis.
Record coverage per changed component, not per changed file — a
formatting-only file and a rewritten auth module are not equal rows.
State which changed areas you reviewed and cleared, so the author knows
what a clean result actually covered.
@@ -202,7 +202,7 @@ Confirm with a version/patch check before firing — these are destructive.
## Tooling
**None of the AD tools below ship in the sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
**None of the AD tools below ship in the Strix sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
```
# Python identity toolkit (impacket = GetUserSPNs/GetNPUsers/secretsdump/ntlmrelayx/getST/addcomputer/rbcd)
@@ -1,9 +1,9 @@
---
name: firebase
description: Firebase security testing covering Firestore, Storage rules, Realtime Database, Auth, Functions, and client-side trust issues
name: firebase-firestore
description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues
---
# Firebase
# Firebase / Firestore
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,17 +30,7 @@ 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`
- 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.
- Storage REST: `https://storage.googleapis.com/storage/v1/b/<bucket>`
**Auth**
- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`)
@@ -127,43 +117,9 @@ 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 GCS object paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
- GET gs:// 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
@@ -233,19 +189,12 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
## Testing Methodology
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
1. **Extract config** - Get project config from client bundle
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin
3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions
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.
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
## Tooling
@@ -257,7 +206,6 @@ 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
- 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
- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure)
- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`
- Minimal reproducible requests with roles/tokens used and observed deltas
+1 -1
View File
@@ -5,7 +5,7 @@ description: Run Python through exec_command in the SDK sandbox. Use the image-b
# Python In The Sandbox
Use `exec_command` for Python. There is no separate Python executor.
Use `exec_command` for Python. There is no separate Strix Python executor.
Prefer writing reusable scripts to a `.py` file and running them with
`python3 <name>.py`. For short one-off transformations, `python3 -c` or a
@@ -80,7 +80,7 @@ Gadget availability depends on package versions — enumerate `node_modules` in
1. **Identify merge points** — Search for extend/merge/defaults/deep copy on user-controlled objects
2. **Baseline probe** — Inject benign pollution marker:
```json
{"__proto__": {"pollutionCanary": "yes"}}
{"__proto__": {"strixPolluted": "yes"}}
```
Verify via response behavior, error messages, or follow-up request reading shared state
3. **Shape variants** — Test `__proto__`, `constructor.prototype`, nested bracket notation
@@ -121,7 +121,7 @@ Gadget availability depends on package versions — enumerate `node_modules` in
## Pro Tips
1. Always verify pollution with a unique canary key (`pollutionCanary_<random>`) before attempting RCE gadgets
1. Always verify pollution with a unique canary key (`strixPolluted_<random>`) before attempting RCE gadgets
2. In white-box scans, grep for `merge`, `extend`, `defaultsDeep`, `assign` with user input
3. Check both request parsing and response template config merges (second-order)
4. Node gadget chains are version-specific — confirm package version before claiming RCE
+1 -55
View File
@@ -14,7 +14,6 @@ from agents import RunContextWrapper, function_tool
from strix.core.agents import Status, coordinator_from_context
from strix.core.execution import notify_parent_on_terminal
from strix.core.hooks import LLM_TURN_KEY
from strix.skills import validate_requested_skills
@@ -37,7 +36,6 @@ def _render_completion_report(
result_summary: str,
findings: list[str],
recommendations: list[str],
open_items: list[str],
) -> str:
"""Render a child's completion report as plain structured text.
@@ -62,12 +60,6 @@ def _render_completion_report(
lines.append("")
lines.append("Findings:")
lines.extend(f"- {f}" for f in findings)
lines.append("")
lines.append("Open items (unresolved, need follow-up):")
if open_items:
lines.extend(f"- {o}" for o in open_items)
else:
lines.append("- (none)")
if recommendations:
lines.append("")
lines.append("Recommendations:")
@@ -232,7 +224,6 @@ _WAIT_DEFAULT_TIMEOUT_S = 300
# ``timeout_seconds`` the model asks for. One second of headroom lets the
# tool's own timeout fire first and return a clean result.
_WAIT_HARD_CEILING_S = _WAIT_DEFAULT_TIMEOUT_S + 1
_WAITED_TURN_KEY = "waited_llm_turn"
@function_tool(timeout=_WAIT_HARD_CEILING_S)
@@ -248,11 +239,6 @@ async def wait_for_agents( # noqa: PLR0911
completion reports. You resume the instant any message arrives, so
size ``timeout_seconds`` to the work you're awaiting.
**Issue exactly one wait, then stop and react to what it returns.**
This call blocks and resumes on its own; it is not a poll you repeat.
Do not write out a wait/check loop ahead of time a second wait in
the same turn returns immediately without waiting.
**This tool is only for waiting on other agents.** Two things it is
NOT for:
@@ -304,24 +290,6 @@ async def wait_for_agents( # noqa: PLR0911
default=str,
)
turn = inner.get(LLM_TURN_KEY)
if turn is not None and inner.get(_WAITED_TURN_KEY) == turn:
return json.dumps(
{
"success": True,
"wait_outcome": "already_waited",
"reason": reason,
"note": (
"You already waited in this turn. A single wait_for_agents blocks and "
"resumes on its own, so queueing more waits only strands you — issue one "
"wait, then react to what it returns."
),
},
ensure_ascii=False,
default=str,
)
inner[_WAITED_TURN_KEY] = turn
async with coordinator._lock:
stopped = coordinator.statuses.get(me) == "stopped"
if stopped:
@@ -452,12 +420,7 @@ async def create_agent(
name: Human-readable child name (used in graph views and
``send_message_to_agent`` flows).
task: Specific objective. Be concrete what to test, what
success looks like, any constraints. Name the target the
child should call ``get_threat_model`` on, and any shared
state it should build on rather than rediscover what
recon already mapped, which surfaces are already covered,
which coverage entry it is picking up. A child that is not
told what is already known repeats it.
success looks like, any constraints.
inherit_context: Default ``True``. The child receives the
parent's input history as background; only set ``False``
when starting a clean-slate task.
@@ -532,7 +495,6 @@ async def agent_finish(
ctx: RunContextWrapper,
result_summary: str,
findings: list[str] | None = None,
open_items: list[str] | None = None,
success: bool = True,
report_to_parent: bool = True,
final_recommendations: list[str] | None = None,
@@ -557,14 +519,6 @@ async def agent_finish(
doing: what did you test, what did you find/confirm/rule out,
what's still open.
**Close out honestly.** Before calling this, every surface you
assessed should have a ``record_coverage`` entry, and anything you
could neither confirm nor rule out belongs in ``open_items`` an
unresolved candidate handed up to the parent is useful, a silently
dropped one is a missed vulnerability. Reporting nothing and
listing no open items asserts the area is clean; only say that if
you mean it.
Args:
result_summary: What you accomplished and discovered. Concrete
and specific (URLs, parameters, payloads that worked).
@@ -573,12 +527,6 @@ async def agent_finish(
``create_vulnerability_report`` first (or
``create_dependency_report`` for dependency CVEs); this is
for narrative.
open_items: Candidates you could NOT confirm and could NOT rule
out with a named control, plus anything you ran out of time
or access to test. State the specific gap (e.g. "password
reset token entropy could not obtain a second account to
compare tokens"). Pass an empty list only when nothing is
genuinely left open.
success: Whether the assigned subtask was completed
successfully. Default ``True``.
report_to_parent: Whether to deliver the completion report to
@@ -622,7 +570,6 @@ async def agent_finish(
result_summary=result_summary,
findings=list(findings or []),
recommendations=list(final_recommendations or []),
open_items=list(open_items or []),
)
await coordinator.send(
parent_id,
@@ -657,7 +604,6 @@ async def agent_finish(
"agent_id": me,
"summary": result_summary,
"findings_count": len(findings or []),
"open_items_count": len(open_items or []),
"has_recommendations": bool(final_recommendations),
},
ensure_ascii=False,
-1
View File
@@ -1 +0,0 @@
"""Scan coverage accounting — what was reviewed, and how it closed."""
-535
View File
@@ -1,535 +0,0 @@
"""Per-run coverage ledger — mirrored to {state_dir}/coverage.json.
Findings answer "what did we find". Coverage answers "what did we look at,
and how did each one close" — the negative space a client report needs in
order to be trustworthy. Every agent records the surfaces it reviewed; the
root agent reconciles them at the end of the scan.
Entries here are **agent-reported**: an agent's own account of what it
assessed. ``strix.report.coverage`` pairs them with machine-observed facts
(which agents ran, which skills they carried, how the run terminated) and
labels the provenance of each, so a reader can tell a self-report from an
observation. The runtime mirror under ``{state_dir}`` exists for resume; the
client-facing artifact is ``{run_dir}/coverage.json``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import tempfile
import threading
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from agents import RunContextWrapper, function_tool
logger = logging.getLogger(__name__)
_coverage_storage: dict[str, dict[str, Any]] = {}
_coverage_lock = threading.RLock()
_coverage_path: Path | None = None
_ENTRY_ID_GENERATION_ATTEMPTS = 1024
_EVIDENCE_PREVIEW_CHARS = 240
VALID_OUTCOMES: tuple[str, ...] = (
"reported",
"no_issue_found",
"ruled_out",
"not_applicable",
"needs_follow_up",
)
_OUTCOMES_REQUIRING_EVIDENCE = frozenset({"ruled_out", "not_applicable", "needs_follow_up"})
def _caller_identity(ctx: RunContextWrapper) -> tuple[str | None, str | None]:
"""Return the (agent_id, agent_name) of the agent invoking this tool."""
inner = ctx.context if isinstance(ctx.context, dict) else {}
raw_agent_id = inner.get("agent_id")
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
agent_name: str | None = None
coordinator = inner.get("coordinator")
if agent_id is not None and coordinator is not None:
names = getattr(coordinator, "names", {})
if isinstance(names, dict):
raw_agent_name = names.get(agent_id)
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
return agent_id, agent_name
def _generate_entry_id() -> str | None:
"""Allocate an unused entry id. Callers must already hold ``_coverage_lock``."""
for _ in range(_ENTRY_ID_GENERATION_ATTEMPTS):
entry_id = uuid.uuid4().hex[:6]
if entry_id not in _coverage_storage:
return entry_id
return None
def hydrate_coverage_from_disk(state_dir: Path) -> None:
global _coverage_path # noqa: PLW0603
_coverage_path = state_dir / "coverage.json"
with _coverage_lock:
_coverage_storage.clear()
if not _coverage_path.exists():
return
try:
data = json.loads(_coverage_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.exception(
"coverage.json at %s is unreadable; starting with empty coverage",
_coverage_path,
)
return
if not isinstance(data, dict):
return
_coverage_storage.update(
{
eid: entry
for eid, entry in data.items()
if isinstance(eid, str) and isinstance(entry, dict)
}
)
logger.info(
"coverage hydrated from %s (%d entr(ies))",
_coverage_path,
len(_coverage_storage),
)
def _persist_locked() -> None:
"""Mirror the ledger to disk. Callers must already hold ``_coverage_lock``.
Serialization and the rename happen in one critical section. Releasing
the lock in between would let a writer holding an older serialization win
the rename and silently roll back a concurrent agent's entry, so the
ledger would hydrate short on resume.
"""
path = _coverage_path
if path is None:
return
try:
payload = json.dumps(_coverage_storage, ensure_ascii=False, default=str)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(path.parent),
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as tmp:
tmp.write(payload)
tmp_path = Path(tmp.name)
tmp_path.replace(path)
except Exception:
logger.exception("coverage persist to %s failed", path)
def get_coverage_entries() -> list[dict[str, Any]]:
"""Return every coverage entry, newest last. Used by ``finish_scan``."""
with _coverage_lock:
entries = [{**entry, "entry_id": eid} for eid, entry in _coverage_storage.items()]
entries.sort(key=lambda e: str(e.get("created_at", "")))
return entries
def outcome_counts() -> dict[str, int]:
"""Count coverage entries per outcome, in the canonical outcome order."""
counts: dict[str, int] = {}
for entry in get_coverage_entries():
outcome = str(entry.get("outcome", "")).lower()
counts[outcome] = counts.get(outcome, 0) + 1
return {o: counts[o] for o in VALID_OUTCOMES if o in counts}
def _validate(
*, surface: str, risk_area: str, outcome: str, evidence: str
) -> tuple[str, list[str]]:
errors: list[str] = []
if not surface.strip():
errors.append("surface cannot be empty - name the endpoint, route, file, or component")
if not risk_area.strip():
errors.append("risk_area cannot be empty - name what you were testing for")
normalized = outcome.strip().lower().replace("-", "_").replace(" ", "_")
if normalized not in VALID_OUTCOMES:
errors.append(f"Invalid outcome: {outcome!r}. Must be one of: {list(VALID_OUTCOMES)}")
elif normalized in _OUTCOMES_REQUIRING_EVIDENCE and not evidence.strip():
errors.append(
f"evidence is required for outcome '{normalized}' - name the specific control, "
"the reason it does not apply, or what is still missing"
)
return normalized, errors
def _duplicate_of_locked(surface: str, risk_area: str) -> tuple[str, dict[str, Any]] | None:
"""Find an existing row for this exact surface and risk area.
Callers must already hold ``_coverage_lock``. The uniqueness check and the
insertion that depends on it have to be one critical section: otherwise
two agents recording the same surface concurrently both see "no
duplicate", and the ledger ends up with exactly the parallel rows this
rejection exists to prevent.
"""
key = (surface.strip().lower(), risk_area.strip().lower())
for entry_id, entry in _coverage_storage.items():
existing = (
str(entry.get("surface", "")).strip().lower(),
str(entry.get("risk_area", "")).strip().lower(),
)
if existing == key:
return entry_id, dict(entry)
return None
def _record_impl(
*,
surface: str,
risk_area: str,
outcome: str,
evidence: str,
agent_id: str | None,
agent_name: str | None,
) -> dict[str, Any]:
normalized, errors = _validate(
surface=surface, risk_area=risk_area, outcome=outcome, evidence=evidence
)
if errors:
return {"success": False, "error": "Validation failed", "errors": errors}
entry: dict[str, Any] = {
"surface": surface.strip(),
"risk_area": risk_area.strip(),
"outcome": normalized,
"created_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
}
if evidence.strip():
entry["evidence"] = evidence.strip()
if agent_id:
entry["agent_id"] = agent_id
if agent_name:
entry["agent_name"] = agent_name
with _coverage_lock:
duplicate = _duplicate_of_locked(surface, risk_area)
if duplicate is not None:
existing_id, existing = duplicate
owner = existing.get("agent_name") or "another agent"
return {
"success": False,
"error": (
f"'{surface.strip()}' ({risk_area.strip()}) already has coverage entry "
f"{existing_id}, recorded by {owner} as "
f"'{existing.get('outcome', '')}'. Two rows for one surface leave the "
"report showing a stale conclusion beside its replacement. If your "
"review reached a different conclusion, move that entry with "
f"update_coverage(entry_id='{existing_id}', ...) and say in evidence "
"what changed. If you reviewed something genuinely different, name the "
"surface or risk area more precisely and record it again."
),
"existing_entry_id": existing_id,
"existing_outcome": existing.get("outcome", ""),
}
entry_id = _generate_entry_id()
if entry_id is None:
return {"success": False, "error": "Could not allocate a coverage entry id"}
_coverage_storage[entry_id] = entry
_persist_locked()
logger.info(
"Coverage recorded: id=%s outcome=%s surface=%s",
entry_id,
normalized,
entry["surface"],
)
return {
"success": True,
"entry_id": entry_id,
"outcome": normalized,
"message": f"Coverage recorded for '{entry['surface']}' ({normalized})",
}
def _update_impl(
*,
entry_id: str,
outcome: str,
evidence: str,
agent_id: str | None,
agent_name: str | None,
) -> dict[str, Any]:
key = (entry_id or "").strip()
with _coverage_lock:
existing = _coverage_storage.get(key)
if existing is None:
return {
"success": False,
"error": (
f"No coverage entry {entry_id!r}. Call list_coverage to find the "
"entry you mean - filter by surface if you only know the name."
),
}
surface = str(existing.get("surface", ""))
risk_area = str(existing.get("risk_area", ""))
normalized, errors = _validate(
surface=surface, risk_area=risk_area, outcome=outcome, evidence=evidence
)
if errors:
return {"success": False, "error": "Validation failed", "errors": errors}
previous_outcome = str(existing.get("outcome", ""))
superseded: dict[str, Any] = {
"outcome": previous_outcome,
"recorded_at": existing.get("created_at", ""),
}
if existing.get("evidence"):
superseded["evidence"] = existing["evidence"]
if existing.get("agent_name"):
superseded["agent_name"] = existing["agent_name"]
history = existing.get("history")
existing["history"] = [*history, superseded] if isinstance(history, list) else [superseded]
existing["outcome"] = normalized
existing["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
if evidence.strip():
existing["evidence"] = evidence.strip()
if agent_id:
existing["agent_id"] = agent_id
if agent_name:
existing["agent_name"] = agent_name
_persist_locked()
logger.info(
"Coverage updated: id=%s %s -> %s surface=%s",
key,
previous_outcome,
normalized,
surface,
)
return {
"success": True,
"entry_id": key,
"previous_outcome": previous_outcome,
"outcome": normalized,
"message": (
f"'{surface}' ({risk_area}) moved from {previous_outcome} to {normalized}. "
"The previous state is kept as history."
),
}
def _list_impl(
*, outcome: str | None, surface: str | None, caller_agent_id: str | None
) -> dict[str, Any]:
normalized_outcome: str | None = None
if outcome and outcome.strip():
normalized_outcome = outcome.strip().lower().replace("-", "_").replace(" ", "_")
if normalized_outcome not in VALID_OUTCOMES:
return {
"success": False,
"error": f"Invalid outcome: {outcome!r}. Must be one of: {list(VALID_OUTCOMES)}",
}
entries: list[dict[str, Any]] = []
for entry in get_coverage_entries():
if normalized_outcome and entry.get("outcome") != normalized_outcome:
continue
if surface and surface.strip().lower() not in str(entry.get("surface", "")).lower():
continue
listing = {
"entry_id": entry.get("entry_id"),
"surface": entry.get("surface", ""),
"risk_area": entry.get("risk_area", ""),
"outcome": entry.get("outcome", ""),
"created_at": entry.get("created_at", ""),
}
evidence = str(entry.get("evidence", ""))
if evidence:
listing["evidence"] = (
f"{evidence[:_EVIDENCE_PREVIEW_CHARS].rstrip()}..."
if len(evidence) > _EVIDENCE_PREVIEW_CHARS
else evidence
)
agent_name = entry.get("agent_name")
if agent_name:
listing["agent_name"] = agent_name
history = entry.get("history")
if isinstance(history, list) and history:
listing["previous_outcomes"] = [str(h.get("outcome", "")) for h in history]
if caller_agent_id is not None and entry.get("agent_id") == caller_agent_id:
listing["by_you"] = True
entries.append(listing)
return {
"success": True,
"entries": entries,
"filtered_count": len(entries),
"total_count": len(_coverage_storage),
"outcome_counts": outcome_counts(),
}
@function_tool(timeout=30)
async def record_coverage(
ctx: RunContextWrapper,
surface: str,
risk_area: str,
outcome: str,
evidence: str = "",
) -> str:
"""Record that you reviewed a surface, and how that review closed.
A scan that only reports findings cannot answer the question every
client asks: *what did you actually check?* This tool captures that
negative space. Record an entry whenever you finish assessing a
surface for a risk including (especially including) when you found
nothing.
Record coverage as you go, not in a batch at the end. Entries are
shared across every agent in the scan, and the root agent reconciles
them into the final report.
Coverage is not append-only bookkeeping: if this surface and risk
already have an entry yours or another agent's — this call is
rejected and returns that entry's id, because two rows for one
surface leave the report showing a stale conclusion next to its
replacement. Call ``update_coverage`` on the id it hands you
instead. Resolving somebody else's ``needs_follow_up`` is exactly
that case.
**Outcomes** (pick exactly one):
- ``reported`` you confirmed an issue and filed a report for it.
- ``no_issue_found`` you tested this properly and found nothing.
- ``ruled_out`` you had a specific candidate and disproved it. The
``evidence`` must name the control that makes it safe, at a
location, and confirm it runs on every attacker-reachable path.
"It looked fine" is not ``ruled_out``.
- ``not_applicable`` this risk cannot apply here (e.g. no XML
parsing on a surface, so no XXE). Say why in ``evidence``.
- ``needs_follow_up`` plausible but unresolved: you could not
confirm it and could not name a control that rules it out. This is
a legitimate outcome. Use it rather than quietly dropping a
candidate, and name the gap in ``evidence`` (missing credentials,
service you could not start, unconfirmed reachability).
Never use ``no_issue_found`` or ``ruled_out`` to close something you
were simply unsure about that is ``needs_follow_up``. Missing
information is not proof of safety.
Args:
surface: What you reviewed an endpoint, route, parameter,
file, component, or host (e.g. ``"POST /api/orders/{id}"``,
``"src/auth/session.py"``, ``"admin dashboard"``).
risk_area: What you were testing it for (e.g. ``"IDOR /
object-level authorization"``, ``"SQL injection"``,
``"SSRF"``).
outcome: One of ``reported`` / ``no_issue_found`` /
``ruled_out`` / ``not_applicable`` / ``needs_follow_up``.
evidence: How you know. Required for ``ruled_out``,
``not_applicable``, and ``needs_follow_up``; recommended
otherwise. Keep it to a sentence or two name the control,
the test performed, or the missing piece.
"""
agent_id, agent_name = _caller_identity(ctx)
result = await asyncio.to_thread(
_record_impl,
surface=surface,
risk_area=risk_area,
outcome=outcome,
evidence=evidence,
agent_id=agent_id,
agent_name=agent_name,
)
return json.dumps(result, ensure_ascii=False, default=str)
@function_tool(timeout=30)
async def update_coverage(
ctx: RunContextWrapper,
entry_id: str,
outcome: str,
evidence: str = "",
) -> str:
"""Change how an already-recorded surface closed.
Coverage is shared across the whole agent tree, and a surface's
state is not final when it is first written. Use this whenever
later work changes the answer:
- You picked up someone's ``needs_follow_up`` and resolved it —
move it to ``reported``, ``ruled_out``, or ``no_issue_found``.
- You had the credentials or running service the original agent
lacked, and could finally test it properly.
- You found the control that rules a candidate out, at a location,
on every attacker-reachable path.
- You went the other way: something recorded ``no_issue_found`` or
``ruled_out`` turns out to be exploitable, or the control you see
does not cover the path you found. Move it back.
The surface and risk area stay fixed this is the same review,
reaching a different conclusion. Do not record a fresh entry for a
surface that already has one; that leaves a stale open item next to
its own resolution. Find the id with ``list_coverage`` (filter by
``surface``), then update it.
The previous outcome, evidence, and author are kept as history, so
the ledger still shows that the surface was once open and who
closed it.
Args:
entry_id: The id of the entry to update, from ``list_coverage``.
outcome: The new outcome ``reported`` / ``no_issue_found`` /
``ruled_out`` / ``not_applicable`` / ``needs_follow_up``.
evidence: How you know, now. Required for ``ruled_out``,
``not_applicable``, and ``needs_follow_up``. Say what
changed, not just what you concluded the reader needs to
know why this closed differently the second time.
"""
agent_id, agent_name = _caller_identity(ctx)
result = await asyncio.to_thread(
_update_impl,
entry_id=entry_id,
outcome=outcome,
evidence=evidence,
agent_id=agent_id,
agent_name=agent_name,
)
return json.dumps(result, ensure_ascii=False, default=str)
@function_tool(timeout=30)
async def list_coverage(
ctx: RunContextWrapper,
outcome: str | None = None,
surface: str | None = None,
) -> str:
"""List coverage entries recorded so far in this scan.
**For the orchestrator / root agent.** Use it to see which surfaces
have been assessed, spot gaps before finishing, and pull the
unresolved ``needs_follow_up`` rows into the final report. Leaf
agents should record their own coverage and get on with testing.
Returns each entry with its ``surface``, ``risk_area``, ``outcome``,
evidence preview, and the agent that recorded it, plus
``outcome_counts`` across the whole scan.
Args:
outcome: Optional filter one of ``reported`` /
``no_issue_found`` / ``ruled_out`` / ``not_applicable`` /
``needs_follow_up``. Filter on ``needs_follow_up`` before
finishing the scan to see what is still open.
surface: Optional case-insensitive substring filter on the
surface name.
"""
caller_agent_id, _ = _caller_identity(ctx)
result = await asyncio.to_thread(
_list_impl, outcome=outcome, surface=surface, caller_agent_id=caller_agent_id
)
return json.dumps(result, ensure_ascii=False, default=str)
+1 -66
View File
@@ -22,7 +22,6 @@ def _do_finish(
methodology: str,
technical_analysis: str,
recommendations: str,
agent_graph: dict[str, Any],
) -> dict[str, Any]:
if parent_id is not None:
return {
@@ -64,7 +63,6 @@ def _do_finish(
recommendations=recommendations.strip(),
)
vuln_count = len(report_state.vulnerability_reports)
coverage_summary = _coverage_summary(agent_graph)
except (ImportError, AttributeError) as e:
logger.exception("finish_scan persistence failed")
return {"success": False, "error": f"Failed to complete scan: {e!s}"}
@@ -73,66 +71,12 @@ def _do_finish(
"finish_scan: completed scan with %d vulnerability report(s)",
vuln_count,
)
result: dict[str, Any] = {
return {
"success": True,
"scan_completed": True,
"message": "Scan completed successfully",
"vulnerabilities_found": vuln_count,
}
result.update(coverage_summary)
return result
def _coverage_summary(agent_graph: dict[str, Any]) -> dict[str, Any]:
"""Coverage counts, unresolved surfaces, and gaps the runtime can see.
The gap list is derived from the agent graph rather than from the ledger,
so it catches the failure the ledger cannot: a risk class an agent was
equipped for and never accounted for. Surfacing it here in the response
to the call that ends the scan is the last point at which the root agent
can still dispatch work or record the class as unresolved instead of
letting the report imply it was clean.
"""
from strix.report.coverage import agents_from_graph, skill_coverage_gaps
from strix.tools.coverage.tools import get_coverage_entries, outcome_counts
entries = get_coverage_entries()
if not entries:
return {
"coverage_recorded": 0,
"coverage_warning": (
"No coverage was recorded for this scan. The report cannot show which "
"surfaces were reviewed and cleared — only what was found. Use "
"record_coverage during testing so future scans can report negative space."
),
}
counts = outcome_counts()
summary: dict[str, Any] = {
"coverage_recorded": len(entries),
"coverage_outcomes": counts,
}
unresolved = [e for e in entries if e.get("outcome") == "needs_follow_up"]
if unresolved:
summary["coverage_warning"] = (
f"{len(unresolved)} surface(s) closed as 'needs_follow_up' and remain "
"unresolved. These should be represented in the report as areas requiring "
"further review rather than omitted."
)
summary["unresolved_surfaces"] = [
{"surface": e.get("surface", ""), "risk_area": e.get("risk_area", "")}
for e in unresolved
]
gaps = skill_coverage_gaps(entries, agents_from_graph(agent_graph))
if gaps:
summary["coverage_gaps"] = [gap["detail"] for gap in gaps]
summary["coverage_gap_warning"] = (
f"{len(gaps)} risk class(es) assigned to agents have no coverage entry and "
"will be published as unexamined. Record them (or a needs_follow_up row) "
"before the report goes out."
)
return summary
@function_tool(timeout=60)
@@ -197,14 +141,6 @@ async def finish_scan(
chain after a serious attempt is acceptable; skipping the
chaining reasoning, or ignoring a plausibly-related combination,
is not.
5. **Coverage reconciliation.** Call ``list_coverage`` and check
what was actually assessed against the surfaces you enumerated
during reconnaissance. Every surface you dispatched work on
should have a coverage entry; anything still open should be a
``needs_follow_up`` row, not a silent omission. If a significant
surface has no entry at all, dispatch an agent to cover it or
record it as ``needs_follow_up`` before finishing. The response
from this tool reports coverage counts and any unresolved rows.
**Calling this multiple times overwrites the previous report.**
Make the single call comprehensive.
@@ -344,7 +280,6 @@ async def finish_scan(
methodology=methodology,
technical_analysis=technical_analysis,
recommendations=recommendations,
agent_graph=await coordinator.snapshot() if coordinator is not None else {},
)
if (
result.get("success")
+2 -2
View File
@@ -1,7 +1,7 @@
"""Bound oversized tool results before they enter agent history.
Oversized results are spilled into the sandbox at
``/workspace/.tool-output/<id>.txt``; the agent sees a head + tail slice
``/workspace/.strix/tool-output/<id>.txt``; the agent sees a head + tail slice
plus the path and reads the rest back with its own file tools. The spill writer
is injected by the runner via :func:`configure_spill_writer`.
"""
@@ -25,7 +25,7 @@ _WORKSPACE_SPILL_NOTICE = (
"in the sandbox; read it with exec_command (e.g. `sed -n`, `grep`, `cat`) ...]"
)
WORKSPACE_SPILL_DIR = "/workspace/.tool-output"
WORKSPACE_SPILL_DIR = "/workspace/.strix/tool-output"
# Longest possible workspace path, used only to reserve notice bytes.
_SAMPLE_WORKSPACE_PATH = f"{WORKSPACE_SPILL_DIR}/{'0' * 32}.txt"
+1 -5
View File
@@ -189,11 +189,7 @@ def build_raw_request(
final_headers = {**headers}
final_headers.setdefault("Host", parsed.netloc)
final_headers.setdefault(
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
)
final_headers.setdefault("User-Agent", "strix")
# Framing headers inherited from the captured request describe the ORIGINAL
# body; once the body is modified for replay they are stale. We always send a
# plain (non-chunked) body with an explicit Content-Length, so drop any
+40 -382
View File
@@ -161,101 +161,9 @@ _REQUIRED_FIELDS = {
}
_VALID_FIX_EFFORT = frozenset({"trivial", "low", "medium", "high"})
_VALID_CONFIDENCE = frozenset({"high", "medium", "low"})
def _validate_required_text(fields: dict[str, str]) -> list[str]:
"""Report every ``_REQUIRED_FIELDS`` entry that arrived blank."""
return [
msg for name, msg in _REQUIRED_FIELDS.items() if not str(fields.get(name) or "").strip()
]
def _validate_cvss_breakdown(breakdown: Any) -> list[str]:
"""Check the 8 CVSS metrics are all present with legal values."""
if not isinstance(breakdown, dict) or not breakdown:
return ["cvss_breakdown: must be an object with the 8 CVSS metrics"]
return [
f"Invalid {name}: {breakdown.get(name)}. Must be one of: {valid}"
for name, valid in _CVSS_VALID.items()
if breakdown.get(name) not in valid
]
def _validate_identifiers(
cve: str | None, cwe: str | None
) -> tuple[str | None, str | None, list[str]]:
"""Normalize and validate the optional CVE / CWE identifiers."""
errors: list[str] = []
if cve:
cve = _extract_cve(cve)
cve_err = _validate_cve(cve)
if cve_err:
errors.append(cve_err)
if cwe:
cwe = _extract_cwe(cwe)
cwe_err = _validate_cwe(cwe)
if cwe_err:
errors.append(cwe_err)
return cve, cwe, errors
def _validate_analysis_fields(
*,
counterevidence: str,
confidence: str,
confidence_rationale: str | None,
severity_change_conditions: str,
) -> list[str]:
"""Validate the counterevidence / confidence closure metadata."""
errors: list[str] = []
if not str(counterevidence or "").strip():
errors.append(
"Counterevidence cannot be empty - state the strongest evidence against "
"this finding, or what you checked and found none (e.g. 'no input "
"validation, WAF, or authorization check found on this path')"
)
if not str(severity_change_conditions or "").strip():
errors.append(
"severity_change_conditions cannot be empty - state the one concrete piece "
"of evidence that would raise or lower the severity"
)
if confidence not in _VALID_CONFIDENCE:
errors.append(
f"Invalid confidence: {confidence!r}. Must be one of: {sorted(_VALID_CONFIDENCE)}"
)
elif confidence != "high" and not str(confidence_rationale or "").strip():
errors.append(
"confidence_rationale is required when confidence is not 'high' - name the "
"gap (e.g. static-only trace, unconfirmed reachability, no runtime access)"
)
return errors
def _validate_fix_verification(
locations: list[dict[str, Any]] | None,
fix_verification: str | None,
) -> list[str]:
"""Require a verification statement whenever an applyable fix is proposed."""
if not locations or not any(loc.get("fix_after") for loc in locations):
return []
if str(fix_verification or "").strip():
return []
return [
"fix_verification is REQUIRED when any code_location carries a 'fix_after' - "
"a suggestion a reviewer can click to apply must be verified first. State, in "
"order: (1) security closure - re-trace the source->sink path through the "
"PATCHED code and say why it is now blocked; (2) bypass review - re-read the "
"diff without your original rationale and name the equivalent sinks, sibling "
"call sites, and alternate malicious input classes you checked; (3) preserved "
"behavior - the legitimate inputs, APIs, and error semantics that still work; "
"(4) how each was checked (executed vs. reasoned), naming any unrun check as "
"an explicit gap. If you cannot make these statements, drop 'fix_after' and "
"leave the location informational."
]
async def _do_create(
async def _do_create( # noqa: PLR0912
*,
title: str,
description: str,
@@ -267,9 +175,6 @@ async def _do_create(
remediation_steps: str,
evidence: str,
assumptions: str,
counterevidence: str,
confidence: str,
severity_change_conditions: str,
fix_effort: str,
cvss_breakdown: dict[str, str],
endpoint: str | None,
@@ -277,36 +182,26 @@ async def _do_create(
cve: str | None,
cwe: str | None,
code_locations: list[dict[str, Any]] | None,
confidence_rationale: str | None = None,
fix_verification: str | None = None,
fix_pr_body: str | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
) -> dict[str, Any]:
errors: list[str] = _validate_required_text(
{
"title": title,
"description": description,
"impact": impact,
"target": target,
"technical_analysis": technical_analysis,
"poc_description": poc_description,
"poc_script_code": poc_script_code,
"remediation_steps": remediation_steps,
"evidence": evidence,
"assumptions": assumptions,
}
)
confidence = (confidence or "").strip().lower()
errors.extend(
_validate_analysis_fields(
counterevidence=counterevidence,
confidence=confidence,
confidence_rationale=confidence_rationale,
severity_change_conditions=severity_change_conditions,
)
)
errors: list[str] = []
fields = {
"title": title,
"description": description,
"impact": impact,
"target": target,
"technical_analysis": technical_analysis,
"poc_description": poc_description,
"poc_script_code": poc_script_code,
"remediation_steps": remediation_steps,
"evidence": evidence,
"assumptions": assumptions,
}
for name, msg in _REQUIRED_FIELDS.items():
if not str(fields.get(name) or "").strip():
errors.append(msg)
fix_effort = (fix_effort or "").strip().lower()
if fix_effort not in _VALID_FIX_EFFORT:
@@ -314,14 +209,28 @@ async def _do_create(
f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}"
)
errors.extend(_validate_cvss_breakdown(cvss_breakdown))
if not isinstance(cvss_breakdown, dict) or not cvss_breakdown:
errors.append("cvss_breakdown: must be an object with the 8 CVSS metrics")
cvss_breakdown = {}
else:
for name, valid in _CVSS_VALID.items():
value = cvss_breakdown.get(name)
if value not in valid:
errors.append(f"Invalid {name}: {value}. Must be one of: {valid}")
parsed_locations = _normalize_code_locations(code_locations)
if parsed_locations:
errors.extend(_validate_code_locations(parsed_locations))
errors.extend(_validate_fix_verification(parsed_locations, fix_verification))
cve, cwe, identifier_errors = _validate_identifiers(cve, cwe)
errors.extend(identifier_errors)
if cve:
cve = _extract_cve(cve)
cve_err = _validate_cve(cve)
if cve_err:
errors.append(cve_err)
if cwe:
cwe = _extract_cwe(cwe)
cwe_err = _validate_cwe(cwe)
if cwe_err:
errors.append(cwe_err)
if errors:
return {"success": False, "error": "Validation failed", "errors": errors}
@@ -388,10 +297,6 @@ async def _do_create(
remediation_steps=remediation_steps,
evidence=evidence,
assumptions=assumptions,
counterevidence=counterevidence,
confidence=confidence,
confidence_rationale=confidence_rationale,
severity_change_conditions=severity_change_conditions,
fix_effort=fix_effort,
cvss=cvss_score,
cvss_breakdown=cvss_breakdown,
@@ -400,7 +305,6 @@ async def _do_create(
cve=cve,
cwe=cwe,
code_locations=parsed_locations,
fix_verification=fix_verification,
fix_pr_body=fix_pr_body,
agent_id=agent_id if isinstance(agent_id, str) else None,
agent_name=agent_name if isinstance(agent_name, str) else None,
@@ -453,9 +357,6 @@ async def create_vulnerability_report(
remediation_steps: str,
evidence: str,
assumptions: str,
counterevidence: str,
confidence: str,
severity_change_conditions: str,
fix_effort: str,
cvss_breakdown: dict[str, str],
endpoint: str | None = None,
@@ -463,8 +364,6 @@ async def create_vulnerability_report(
cve: str | None = None,
cwe: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
confidence_rationale: str | None = None,
fix_verification: str | None = None,
fix_pr_body: str | None = None,
) -> str:
"""File a vulnerability report — one report per fully-verified finding.
@@ -512,15 +411,6 @@ async def create_vulnerability_report(
get a ``duplicate_of`` response, do NOT retry move on to other
areas.
**Counterevidence pass (required before filing)**: actively build the
strongest case that this finding is NOT exploitable, or less severe
than you think then record the result in ``counterevidence``, set
``confidence`` honestly, and state what would move the severity in
``severity_change_conditions``. These three fields are mandatory and
validated. A finding you could not execute is at best
``confidence: medium``, with the gap named in
``confidence_rationale``.
**Report output rules** (this content may be rendered into generated
reports):
@@ -673,31 +563,6 @@ async def create_vulnerability_report(
assumptions: Short note on the assumptions/prerequisites that
make this finding impactful or exploitable (e.g. "assumes an
authenticated low-privilege user").
counterevidence: REQUIRED. The strongest case *against* this
finding, after actively looking for it the guard you might
have missed, the deployment constraint, the precondition. If
you genuinely found nothing, say what you checked (e.g. "no
input validation, WAF, or authorization check found on this
path; tested authenticated and unauthenticated"), not just
"none". A generic trust claim ("the framework escapes this")
is not counterevidence unless you confirmed that specific
call in this context.
confidence: REQUIRED. Your calibrated confidence that this is a
real, exploitable issue: ``high`` (working PoC against the
live target, or a complete reachable sourcesink trace),
``medium`` (strong static evidence you could not fully
execute), or ``low`` (plausible with a material unresolved
gap). Do not inflate an accurate ``medium`` is more useful
than a ``high`` that fails triage.
confidence_rationale: Required when ``confidence`` is not
``high``. Name the specific gap (e.g. "static-only trace,
could not stand up the service to reproduce"; "reachability
of this route from unauthenticated traffic unconfirmed").
severity_change_conditions: REQUIRED. One concrete sentence on
what single piece of additional evidence would raise or
lower the severity (e.g. "confirmation this route is exposed
to unauthenticated internet traffic would raise this to
critical").
fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high``.
cvss_breakdown: 8-metric object per the format above.
endpoint: API path / Git path (e.g. ``/api/login``).
@@ -767,40 +632,6 @@ async def create_vulnerability_report(
- Padding ``fix_before`` with surrounding context lines
that aren't part of the fix.
- Duplicating the same change across multiple locations.
fix_verification: REQUIRED whenever any ``code_locations`` entry
carries a ``fix_after``. A reviewer can apply that
suggestion with one click, so an unverified fix ships
straight into the codebase. Before writing this field, work
the gates **in order** and never trade an earlier one for a
later one:
1. **Security closure** re-trace the source sink path
through the *patched* code and state why it is now
blocked. Re-run the PoC against the fix if you can.
2. **Bypass review** re-read the diff *without* leaning on
the rationale that produced it. Name the sibling call
sites, equivalent sinks, and alternate malicious input
classes you checked, and try at least one.
3. **Preserved behavior** name the legitimate inputs,
public APIs, and error semantics that must keep working,
and confirm the patch leaves them intact. A fix that
breaks the feature is not a fix.
4. **Repository checks** run the narrowest relevant
syntax / type / lint / test check that covers the
changed lines.
Then write what you did: the commands you ran and their
results, and every gate you could only reason about rather
than execute, marked explicitly as a gap. Do not claim a
gate passed because it looks right. If a gate fails, revise
the patch or drop ``fix_after`` and leave the location
informational never compensate for a failed security
closure with a smaller diff or extra prose.
Also use this field to record the narrowest-complete-change
judgement: prefer the smallest repository-native fix that
fully enforces the invariant, using existing helpers, with
no unrelated refactors folded in.
fix_pr_body: Optional. When source is available and you have a
concrete fix, a markdown PR-description body proposing the
fix (summary + rationale). Prose/markdown only the code
@@ -841,15 +672,6 @@ async def create_vulnerability_report(
remediation_steps:
Context-encode all user input rendered into HTML; prefer the
template engine's auto-escaping over string interpolation.
counterevidence:
No output encoding, CSP, or WAF observed on this response;
payload executed in a current browser. The parameter is
reflected on an unauthenticated route, so no privileged
position is required.
confidence: "high"
severity_change_conditions:
A restrictive CSP that blocks inline script execution would
reduce impact and lower the severity.
fix_effort: "low"
"""
agent_id, agent_name = _caller_identity(ctx)
@@ -865,10 +687,6 @@ async def create_vulnerability_report(
remediation_steps=remediation_steps,
evidence=evidence,
assumptions=assumptions,
counterevidence=counterevidence,
confidence=confidence,
confidence_rationale=confidence_rationale,
severity_change_conditions=severity_change_conditions,
fix_effort=fix_effort,
cvss_breakdown=cvss_breakdown,
endpoint=endpoint,
@@ -876,7 +694,6 @@ async def create_vulnerability_report(
cve=cve,
cwe=cwe,
code_locations=code_locations,
fix_verification=fix_verification,
fix_pr_body=fix_pr_body,
agent_id=agent_id,
agent_name=agent_name,
@@ -902,47 +719,12 @@ def _dependency_severity(advisory_cvss: float | None) -> tuple[float, str]:
return score, "none"
_VALID_REACHABILITY = frozenset(
{
"not_imported",
"imported",
"vulnerable_symbol_used",
"reachable_call_path",
"unknown",
}
)
def _validate_manifest_path(manifest_path: str | None) -> str | None:
"""Return an error message when manifest_path is missing or unsafe."""
path = (manifest_path or "").strip()
if not path:
return (
"manifest_path is required: pass the repo-relative path of the "
"lockfile/manifest where the vulnerable version was observed "
"(trivy's Target, e.g. 'package-lock.json' or "
"'services/api/pom.xml'). It binds the finding to its exact file "
"so remediation can target the right repository."
)
if path.startswith("/") or "\\" in path or path.split("/")[0].endswith(":"):
return f"manifest_path must be a relative path within the repository, got {path!r}"
segments = path.split("/")
if any(segment in ("", ".", "..") for segment in segments):
return f"manifest_path must not contain empty, '.', or '..' segments, got {path!r}"
return None
def _build_dependency_metadata(
*,
package_name: str,
installed_version: str,
package_ecosystem: str | None,
fixed_version: str | None,
introduced_by: str | None,
dependency_path: str | None,
manifest_path: str | None = None,
reachability: str | None = None,
reachability_evidence: str | None = None,
) -> dict[str, str]:
metadata = {
"package_name": package_name.strip(),
@@ -950,43 +732,17 @@ def _build_dependency_metadata(
}
if package_ecosystem and package_ecosystem.strip():
metadata["package_ecosystem"] = package_ecosystem.strip()
if manifest_path and manifest_path.strip():
metadata["manifest_path"] = manifest_path.strip()
if fixed_version and fixed_version.strip():
metadata["fixed_version"] = fixed_version.strip()
if introduced_by and introduced_by.strip():
metadata["introduced_by"] = introduced_by.strip()
if dependency_path and dependency_path.strip():
metadata["dependency_path"] = dependency_path.strip()
# "unknown" is the absent case — omitting it keeps the jsonb contract clean,
# and evidence without a level would have nothing to qualify.
if reachability and reachability.strip() and reachability.strip() != "unknown":
metadata["reachability"] = reachability.strip()
if reachability_evidence and reachability_evidence.strip():
metadata["reachability_evidence"] = reachability_evidence.strip()
return metadata
_REACHABILITY_EVIDENCE_LABELS = {
"not_imported": "not imported by application code",
"imported": "imported by application code; affected API usage unconfirmed",
"vulnerable_symbol_used": "the advisory's affected API is used in application code",
"reachable_call_path": (
"a call path from application code to the vulnerable function was proven"
),
}
def _build_dependency_evidence(
*,
cve: str,
package_name: str,
installed_version: str,
fixed_version: str | None,
introduced_by: str | None,
dependency_path: str | None,
reachability: str | None = None,
reachability_evidence: str | None = None,
) -> str:
evidence = (
f"**Advisory evidence:** `{cve}` applies to `{package_name}` "
@@ -994,22 +750,6 @@ def _build_dependency_evidence(
)
if fixed_version and fixed_version.strip():
evidence += f" The advisory is fixed in `{fixed_version.strip()}`."
if introduced_by and introduced_by.strip():
evidence += (
f"\n\n**Transitive dependency:** introduced by the direct "
f"dependency `{introduced_by.strip()}`."
)
if dependency_path and dependency_path.strip():
evidence += f"\n\n**Dependency chain:** `{dependency_path.strip()}`"
label = _REACHABILITY_EVIDENCE_LABELS.get((reachability or "").strip().lower())
if label:
evidence += f"\n\n**Usage analysis:** {label}."
if reachability_evidence and reachability_evidence.strip():
evidence += f" {reachability_evidence.strip()}"
evidence += (
" This is a prioritization signal from static analysis, not a"
" proof of exploitability or of safety."
)
return evidence
@@ -1030,11 +770,6 @@ async def _do_create_dependency( # noqa: PLR0912
advisory_cvss: float | None,
technical_analysis: str | None,
fix_effort: str,
introduced_by: str | None = None,
dependency_path: str | None = None,
manifest_path: str | None = None,
reachability: str = "unknown",
reachability_evidence: str | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
) -> dict[str, Any]:
@@ -1071,22 +806,6 @@ async def _do_create_dependency( # noqa: PLR0912
f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}"
)
manifest_err = _validate_manifest_path(manifest_path)
if manifest_err:
errors.append(manifest_err)
reachability = (reachability or "unknown").strip().lower()
if reachability not in _VALID_REACHABILITY:
errors.append(
f"Invalid reachability: {reachability!r}. Must be one of: {sorted(_VALID_REACHABILITY)}"
)
elif reachability != "unknown" and not (reachability_evidence or "").strip():
errors.append(
"reachability_evidence is required when reachability is not 'unknown': "
"cite the concrete proof (import file:line, matched symbol usage, or "
"govulncheck call path). Never claim a reachability level without evidence."
)
if advisory_cvss is None:
errors.append(
"advisory_cvss is required: read the published advisory base score "
@@ -1105,21 +824,12 @@ async def _do_create_dependency( # noqa: PLR0912
installed_version=installed_version,
package_ecosystem=package_ecosystem,
fixed_version=fixed_version,
introduced_by=introduced_by,
dependency_path=dependency_path,
manifest_path=manifest_path,
reachability=reachability,
reachability_evidence=reachability_evidence,
)
evidence = _build_dependency_evidence(
cve=parsed_cve,
package_name=package_name.strip(),
installed_version=installed_version.strip(),
fixed_version=fixed_version,
introduced_by=introduced_by,
dependency_path=dependency_path,
reachability=reachability,
reachability_evidence=reachability_evidence,
)
try:
@@ -1212,15 +922,10 @@ async def create_dependency_report(
remediation_steps: str,
assumptions: str,
package_ecosystem: str,
manifest_path: str | None = None,
fixed_version: str | None = None,
cwe: str | None = None,
technical_analysis: str | None = None,
fix_effort: str = "low",
introduced_by: str | None = None,
dependency_path: str | None = None,
reachability: str = "unknown",
reachability_evidence: str | None = None,
) -> str:
"""File a known-CVE dependency (SCA) finding — one report per CVE x package.
@@ -1245,26 +950,9 @@ async def create_dependency_report(
- Re-reporting the same CVE/package already filed.
**Reachability**: do NOT silently downgrade or suppress a finding
because the vulnerable code path may be unreachable report it, and
record what the usage analysis showed via the structured
``reachability`` + ``reachability_evidence`` fields (see the
dependency-cve-scanning skill for the analysis procedure). The level
is an evidence ladder, never an exploitability verdict:
- ``not_imported`` the package is never imported/required by
application code (strongest de-prioritization signal; still not
proof of safety dynamic loading, reflection, or framework wiring
can evade static search).
- ``imported`` application code imports the package, but usage of
the advisory's affected API was not confirmed.
- ``vulnerable_symbol_used`` the advisory's affected
function/class/API appears in application code.
- ``reachable_call_path`` a call-graph tool (e.g. ``govulncheck``)
proved a path from application code to the vulnerable function.
- ``unknown`` usage analysis was not performed or was inconclusive.
Severity is still derived solely from ``advisory_cvss`` the
reachability level never changes the rating, only prioritization.
because the vulnerable code path may be unreachable instead state
reachability as an ``assumptions`` / confidence factor. Report the
finding; let the reader weigh exploitability.
**Formatting**: use markdown in text fields (``**bold**``, ``inline
code`` for package/version identifiers, fenced code blocks for
@@ -1290,30 +978,6 @@ async def create_dependency_report(
technical_analysis: Optional deeper mechanism/root-cause detail.
fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high``
(dependency upgrades are usually ``trivial``/``low``).
introduced_by: For a **transitive** dependency, the direct
dependency (from the project's own manifest) that pulls the
vulnerable package in, as ``name@version`` (e.g.
``express@4.18.1``). Omit when the vulnerable package is
itself a direct dependency.
dependency_path: The resolution chain from the direct dependency
to the vulnerable package, joined with `` > `` (e.g.
``express@4.18.1 > body-parser@1.20.0 > qs@6.10.2``). Omit
for direct dependencies.
manifest_path: **Required.** The repo-relative path of the
lockfile/manifest where the vulnerable version was observed
trivy's ``Target`` (e.g. ``package-lock.json``,
``services/api/pom.xml``). Strip any scan-workspace or repo
checkout directory prefix so the path is relative to the
repository root. This binds the finding to its exact file so
remediation can target the right repository.
reachability: Usage-evidence level from static analysis one of
``not_imported`` / ``imported`` / ``vulnerable_symbol_used`` /
``reachable_call_path`` / ``unknown``. Claim only what the
evidence proves; when in doubt use ``unknown``.
reachability_evidence: The concrete proof for the claimed level
(required for any level other than ``unknown``): repo-relative
``file:line`` of the import or symbol usage, the matched
advisory symbols, or the govulncheck call-path excerpt.
"""
agent_id, agent_name = _caller_identity(ctx)
@@ -1333,11 +997,6 @@ async def create_dependency_report(
advisory_cvss=advisory_cvss,
technical_analysis=technical_analysis,
fix_effort=fix_effort,
introduced_by=introduced_by,
dependency_path=dependency_path,
manifest_path=manifest_path,
reachability=reachability,
reachability_evidence=reachability_evidence,
agent_id=agent_id,
agent_name=agent_name,
)
@@ -1362,7 +1021,6 @@ _REPORT_SUMMARY_FIELDS = (
"title",
"severity",
"cvss",
"confidence",
"finding_class",
"cve",
"cwe",
@@ -1564,8 +1222,8 @@ async def list_reports(
findings, and build the ``finish_scan`` executive summary.
By default each entry is compact: ``id``, ``title``, ``severity``,
``cvss``, ``confidence``, ``finding_class``, ``cve`` / ``cwe``,
``target`` / ``endpoint``, ``fix_effort``, ``agent_name`` (who filed it), ``timestamp``,
``cvss``, ``finding_class``, ``cve`` / ``cwe``, ``target`` /
``endpoint``, ``fix_effort``, ``agent_name`` (who filed it), ``timestamp``,
plus a 280-char ``description_preview``. Entries you filed yourself are
flagged ``by_you: true``. The response also carries
``total_count`` and ``severity_counts`` (counts per severity across all
+1 -5
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,10 +45,6 @@ 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)
-1
View File
@@ -1 +0,0 @@
"""Repository-scoped threat model cache, reusable across scans of the same tree."""
-659
View File
@@ -1,659 +0,0 @@
"""Target-scoped threat models — cached under ``~/.strix/threat-models``.
A threat model describes the target, not the scan: a host, an application, an
API, a repository, or whatever else the engagement is pointed at. It stays
valid across unrelated runs against the same target, so it is keyed by target
identity rather than by run id one agent derives it, every later agent in
this run and in future runs against the same target reads it back instead of
re-deriving trust boundaries from scratch.
Where the target is a checkout, the model is additionally pinned to the git
revision, so a moved ``HEAD`` marks it stale. Black-box targets have no
revision to pin to; those age out instead.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
import subprocess
import tempfile
import threading
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from agents import RunContextWrapper, function_tool
from strix.core.agents import AgentCoordinator
logger = logging.getLogger(__name__)
_CACHE_DIR = Path.home() / ".strix" / "threat-models"
_MAX_MODEL_BYTES = 512 * 1024
_MIN_MODEL_CHARS = 400
_MIN_AMENDMENT_CHARS = 80
_MAX_AMENDMENTS = 40
_GIT_TIMEOUT_SECONDS = 10
_UNVERSIONED = "unversioned"
_MAX_AGE_DAYS = 14
_DEFAULT_PORTS = {"http": "80", "https": "443"}
_cache_lock = threading.RLock()
_REQUIRED_SECTIONS = (
"overview",
"trust boundaries",
"attack surface",
"severity calibration",
)
def _git(repo: Path, args: list[str]) -> str | None:
try:
result = subprocess.run( # noqa: S603
["git", "-C", str(repo), *args], # noqa: S607
capture_output=True,
text=True,
check=False,
timeout=_GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError):
logger.debug("git %s failed in %s", args, repo, exc_info=True)
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None
def _local_directory(target: str) -> Path | None:
"""Return the target as a local directory, or None if it is not one."""
if "://" in target:
return None
try:
resolved = Path(target).expanduser().resolve()
except OSError:
return None
return resolved if resolved.is_dir() else None
def _remote_authority(target: str) -> str:
"""The ``host[:port]`` a remote target lives on, or "" if it has none."""
candidate = target if "://" in target else f"//{target}"
parts = urlsplit(candidate)
host = (parts.hostname or "").lower()
if not host:
return ""
scheme = (parts.scheme or "https").lower()
port = str(parts.port) if parts.port else _DEFAULT_PORTS.get(scheme, "")
return f"{host}:{port}" if port else host
def _normalize_remote_target(target: str) -> str:
"""Collapse the spellings of one remote target onto a single cache key."""
authority = _remote_authority(target)
if not authority:
return re.sub(r"\s+", " ", target.lower()).strip()
candidate = target if "://" in target else f"//{target}"
path = urlsplit(candidate).path.rstrip("/")
return f"{authority}{path}"
def _normalize_git_remote(remote: str) -> str:
"""Collapse a git remote URL onto the same key its clone URL would produce.
A remote reaches us in whichever spelling the clone used
``git@github.com:org/repo.git``, ``https://github.com/org/repo``,
``ssh://git@github.com/org/repo.git`` and each is the same repository.
Rewriting scp-style syntax into a URL and dropping the ``.git`` suffix and
any embedded credentials lets :func:`_normalize_remote_target` produce one
identity for all of them, and crucially the *same* identity a caller gets
when it names the repository by its remote URL rather than by a checkout
path. Without that, the model saved by an agent working in the checkout is
invisible to an agent that asks for the repository by URL, and the two
derive conflicting models of one target.
"""
candidate = remote.strip()
scp_style = re.match(r"^(?:[^@/]+@)?(?P<host>[^:/]+):(?P<path>.+)$", candidate)
if scp_style and "://" not in candidate:
candidate = f"https://{scp_style['host']}/{scp_style['path'].lstrip('/')}"
elif "://" in candidate:
# The transport a clone happened to use says nothing about which
# repository this is, and each scheme carries a different default
# port into the authority. Collapsing them all onto https keeps one
# repository on one key however it was cloned.
candidate = f"https://{candidate.split('://', 1)[1]}"
normalized = _normalize_remote_target(candidate)
return normalized.removesuffix(".git")
def _target_identity(target: str) -> tuple[str, str]:
"""Return the (stable identity, revision) pair a cached model is keyed on.
A checkout is keyed on its remote (so the same repository cloned to two
paths shares one model, and a subdirectory resolves to the whole tree) and
pinned to ``HEAD``. Everything else a host, a URL, an API base, a named
scope is keyed on its normalized form and carries no revision. Both
routes run through the same normalization, so a checkout and the URL it
was cloned from land on one key.
"""
directory = _local_directory(target)
if directory is None:
return _normalize_remote_target(target).removesuffix(".git"), _UNVERSIONED
remote = _git(directory, ["config", "--get", "remote.origin.url"])
revision = _git(directory, ["rev-parse", "HEAD"]) or _UNVERSIONED
if remote:
return _normalize_git_remote(remote), revision
toplevel = _git(directory, ["rev-parse", "--show-toplevel"])
return toplevel or str(directory), revision
def _cache_path(identity: str) -> Path:
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
return _CACHE_DIR / f"{digest}.json"
def _snap_to_scan_target(raw: str, scan_targets: list[str]) -> str:
"""Pull a target onto the scan's own spelling of it.
Agents name the same target differently one passes the URL it was given,
the next the page it happens to be testing, a third the checkout path. Left
alone those become separate cache keys, every lookup misses, and each agent
quietly derives its own model, which is the exact failure the shared model
exists to prevent. So a target that is recognisably one of the scan's own
targets is resolved to that target instead.
"""
identity, _ = _target_identity(raw)
scoped = [(target, _target_identity(target)[0]) for target in scan_targets]
if any(known == identity for _, known in scoped):
return raw
authority = _remote_authority(raw)
if authority:
hosted = [target for target, _ in scoped if _remote_authority(target) == authority]
# Two scan targets on one host are distinguished only by their paths,
# so snapping to "the host" would merge two distinct models into one.
return hosted[0] if len(hosted) == 1 else raw
directory = _local_directory(raw)
if directory is not None:
enclosing = [
target
for target, known in scoped
if known == identity or _local_directory(target) == directory
]
if enclosing:
return enclosing[0]
return raw
def _resolve_target(
target: str, scan_targets: list[str] | None = None
) -> tuple[str | None, str | None]:
raw = (target or "").strip()
known = [t for t in (scan_targets or []) if t.strip()]
if not raw:
if len(known) == 1:
return known[0], None
return None, (
"target cannot be empty - pass the host, URL, application, or "
"repository path this model describes"
+ (f". This scan is scoped to: {', '.join(known)}" if known else "")
)
return (_snap_to_scan_target(raw, known) if known else raw), None
def _is_expired(created_at: str | None) -> bool:
if not created_at:
return True
try:
created = datetime.fromisoformat(created_at)
except ValueError:
return True
if created.tzinfo is None:
created = created.replace(tzinfo=UTC)
return datetime.now(UTC) - created > timedelta(days=_MAX_AGE_DAYS)
def _missing_sections(content: str) -> list[str]:
lowered = content.lower()
return [section for section in _REQUIRED_SECTIONS if section not in lowered]
def _read_cache(path: Path) -> dict[str, Any] | None:
"""Load a cached model. Callers must already hold ``_cache_lock``."""
if not path.is_file():
return None
try:
cached = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.exception("threat model cache at %s is unreadable", path)
return None
return cached if isinstance(cached, dict) else None
def _write_cache(path: Path, payload: dict[str, Any]) -> str | None:
"""Atomically persist a model. Callers must already hold ``_cache_lock``."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(path.parent),
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as tmp:
tmp.write(json.dumps(payload, ensure_ascii=False))
tmp_path = Path(tmp.name)
tmp_path.replace(path)
except OSError as exc:
logger.exception("threat model persist to %s failed", path)
return f"Failed to persist threat model: {exc}"
return None
def _amendments_of(cached: dict[str, Any]) -> list[dict[str, Any]]:
raw = cached.get("amendments")
if not isinstance(raw, list):
return []
return [item for item in raw if isinstance(item, dict)]
def _not_found(identity: str, revision: str) -> dict[str, Any]:
return {
"success": True,
"found": False,
"target": identity,
"revision": revision,
"message": (
"No threat model cached for this target. Derive one — from the code if "
"you have it, from recon output if you do not — and persist it with "
"save_threat_model, so every agent on this scan shares one view of the "
"trust boundaries instead of each inventing their own."
),
}
def _staleness(cached: dict[str, Any], revision: str) -> tuple[bool, str | None]:
"""Decide whether a cached model can still be trusted, and why not."""
if revision != _UNVERSIONED:
if cached.get("revision") == revision:
return False, None
return True, (
"This model was derived against a different revision. Use it as a "
"starting point, re-check the boundaries it names against the current "
"tree, and save the corrected version."
)
created_at = cached.get("created_at")
if not _is_expired(created_at if isinstance(created_at, str) else None):
return False, None
return True, (
f"This model is more than {_MAX_AGE_DAYS} days old and there is no revision "
"to pin it to, so the target may have moved under it. Treat its surface "
"inventory as a lead list to re-confirm during recon, not as fact, and save "
"the corrected version."
)
def _get_impl(target: str, scan_targets: list[str] | None = None) -> dict[str, Any]:
resolved, error = _resolve_target(target, scan_targets)
if resolved is None:
return {"success": False, "error": error}
identity, revision = _target_identity(resolved)
path = _cache_path(identity)
with _cache_lock:
cached = _read_cache(path)
if cached is None:
return _not_found(identity, revision)
content = cached.get("content")
if not isinstance(content, str) or not content.strip():
return _not_found(identity, revision)
stale, stale_message = _staleness(cached, revision)
result: dict[str, Any] = {
"success": True,
"found": True,
"target": identity,
"revision": revision,
"cached_revision": cached.get("revision"),
"created_at": cached.get("created_at"),
"stale": stale,
"content": content,
}
amendments = _amendments_of(cached)
if amendments:
result["amendments"] = amendments
result["amendments_note"] = (
"Addenda recorded by agents after the base model was written. They "
"correct or extend it and have not been folded in yet - read them as "
"part of the model, and prefer the later one where they conflict."
)
if stale_message:
result["message"] = stale_message
return result
def _save_impl(
target: str,
content: str,
agent_name: str | None,
scan_targets: list[str] | None = None,
) -> dict[str, Any]:
resolved, error = _resolve_target(target, scan_targets)
if resolved is None:
return {"success": False, "error": error}
body = (content or "").strip()
if len(body) < _MIN_MODEL_CHARS:
return {
"success": False,
"error": (
f"Threat model is too thin ({len(body)} chars). It has to be usable by "
"an agent seeing this target for the first time: what it is, who the "
"actors are, where the trust boundaries sit, which inputs are "
"attacker-controlled, and what a critical bug looks like here."
),
}
if len(body.encode("utf-8")) > _MAX_MODEL_BYTES:
return {"success": False, "error": "Threat model exceeds 512KB; tighten it."}
missing = _missing_sections(body)
if missing:
return {
"success": False,
"error": (
"Threat model is missing required section(s): "
f"{', '.join(missing)}. Cover Overview, Trust Boundaries and "
"Assumptions, Attack Surface and Attacker Stories, and Severity "
"Calibration."
),
}
identity, revision = _target_identity(resolved)
path = _cache_path(identity)
payload: dict[str, Any] = {
"target": identity,
"revision": revision,
"created_at": datetime.now(UTC).isoformat(),
"created_by": agent_name,
"content": body,
}
with _cache_lock:
existing = _read_cache(path)
folded = len(_amendments_of(existing)) if existing else 0
error = _write_cache(path, payload)
if error:
return {"success": False, "error": error}
message = (
"Threat model saved. Subagents should call get_threat_model before they "
"start, and treat its trust boundaries as the shared baseline."
)
if folded:
message += (
f" This replaced a model carrying {folded} amendment(s), which are now "
"cleared - make sure what they said survives in the text you just wrote."
)
return {
"success": True,
"target": identity,
"revision": revision,
"amendments_cleared": folded,
"message": message,
}
def _append_amendment(
path: Path, amendment: dict[str, Any]
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""Add an amendment to the cached model. Returns (amendments, error)."""
with _cache_lock:
cached = _read_cache(path)
if cached is None or not str(cached.get("content", "")).strip():
return None, (
"No threat model exists for this target yet, so there is nothing to "
"amend. Derive the base model and call save_threat_model instead."
)
amendments = _amendments_of(cached)
if len(amendments) >= _MAX_AMENDMENTS:
return None, (
f"This model already carries {len(amendments)} amendments. Fold them "
"into the base model with save_threat_model before adding more."
)
amendments.append(amendment)
cached["amendments"] = amendments
if len(json.dumps(cached, ensure_ascii=False).encode("utf-8")) > _MAX_MODEL_BYTES:
return None, "Threat model with this amendment exceeds 512KB; tighten it."
return amendments, _write_cache(path, cached)
def _amend_impl(
target: str,
addendum: str,
agent_name: str | None,
scan_targets: list[str] | None = None,
) -> dict[str, Any]:
resolved, error = _resolve_target(target, scan_targets)
if resolved is None:
return {"success": False, "error": error}
body = (addendum or "").strip()
if len(body) < _MIN_AMENDMENT_CHARS:
return {
"success": False,
"error": (
f"Amendment is too thin ({len(body)} chars). Say what the base model "
"got wrong or left out, and name the endpoint, host, file, or control "
"that makes your correction true."
),
}
identity, revision = _target_identity(resolved)
amendments, amend_error = _append_amendment(
_cache_path(identity),
{
"at": datetime.now(UTC).isoformat(),
"by": agent_name,
"revision": revision,
"content": body,
},
)
if amendments is None or amend_error:
return {"success": False, "error": amend_error}
return {
"success": True,
"target": identity,
"revision": revision,
"amendment_count": len(amendments),
"message": (
"Amendment recorded. Agents calling get_threat_model will now see it "
"alongside the base model."
),
}
def _caller_agent_name(ctx: RunContextWrapper) -> str | None:
inner = ctx.context if isinstance(ctx.context, dict) else {}
agent_id = inner.get("agent_id")
coordinator = inner.get("coordinator")
if not isinstance(agent_id, str) or not isinstance(coordinator, AgentCoordinator):
return None
return coordinator.names.get(agent_id)
def _scan_targets(ctx: RunContextWrapper) -> list[str]:
"""The targets this scan was authorized against, as the runner spelled them."""
inner = ctx.context if isinstance(ctx.context, dict) else {}
targets = inner.get("scan_targets")
if not isinstance(targets, list):
return []
return [target for target in targets if isinstance(target, str) and target.strip()]
@function_tool(timeout=30)
async def get_threat_model(ctx: RunContextWrapper, target: str) -> str:
"""Read the cached threat model for a target, if one exists.
A threat model belongs to the target, not to this scan the same
trust boundaries hold across unrelated runs against the same host
or application. Call this before you start hunting so you inherit
the shared view instead of re-deriving it, and so every agent on
this run agrees on what "attacker-controlled" means here.
Works black-box or white-box. The target can be a host, a URL, an
API base, or a repository path; equivalent spellings of the same
host resolve to the same model, and a checkout resolves to its
remote, so a model derived white-box is read back by a black-box
agent testing the deployment.
Returns ``found: false`` when nothing is cached derive one and
persist it with ``save_threat_model``. ``stale: true`` means the
checkout moved to a different revision, or that a model with no
revision to pin to has aged out: use it as a starting point,
re-confirm what it claims, and save the corrected version.
Any ``amendments`` in the response are corrections other agents
recorded after the base model was written. They are part of the
model read them, and prefer the later statement where one
contradicts the base text.
Args:
target: What the model describes a host or URL
(``https://app.example.com``), or a repository path
(``/workspace/myrepo``). Use the same value the scan was
pointed at, so agents converge on one model.
"""
return json.dumps(
await asyncio.to_thread(_get_impl, target, _scan_targets(ctx)),
ensure_ascii=False,
default=str,
)
@function_tool(timeout=30)
async def save_threat_model(ctx: RunContextWrapper, target: str, content: str) -> str:
"""Persist a target-scoped threat model for reuse by other agents.
Keyed by target identity, so a later scan of the same host or tree
reads it back instead of paying to derive it again.
**This replaces the whole document, and clears any amendments**
it is for the agent establishing the baseline (normally root,
before subagents start), or for folding accumulated amendments back
into the body. If a model already exists and you only need to
correct or extend part of it, call ``amend_threat_model`` instead;
saving over it will silently discard whatever other agents added.
**Write it from whatever evidence you have.** With source, ground
it in the code and name the files, entrypoints, and controls that
make each claim true. Black-box, ground it in recon: the hosts and
ports that answered, the technology fingerprints, the observed
roles and tenants, the authentication and session model, the
endpoints and parameters you enumerated. A black-box model is
necessarily provisional say which parts are inferred rather than
observed, and let later agents amend it as the picture fills in.
**Scope it to the target, not to this scan.** Do not centre it on
the diff you were handed, the subsystem you were assigned, or the
one host that happened to answer first. With source, distinguish
real product and runtime surfaces from test, docs, example, and
developer-tooling paths in a monorepo, do not let ``tests/`` or
one-off scripts become the centre of gravity unless the code shows
they are genuinely deployed. Where the target documents its own
boundary an ``AGENTS`` file, a specific ``SECURITY.md``, a
published API spec, an engagement scope build on it rather than
inventing a competing story.
Structure the content in Markdown with these sections:
- **Overview** what the target actually is, its real-world usage,
and which parts are product/runtime versus tooling or
non-production.
- **Trust Boundaries and Assumptions** the boundaries, the actors
on either side, and the invariants that must hold. Separate
attacker-controlled, operator-controlled, and
developer-controlled inputs explicitly. Black-box, this is the
role, tenant, and privilege model: who can reach what before
authenticating, as a low-privilege user, and across tenants.
- **Attack Surface and Attacker Stories** the exposed surfaces
(hosts, endpoints, parameters, integrations, or the code-level
entrypoints and sinks), the mitigations already present that
materially change severity or reach, realistic attacker stories,
and the stories that are *not* realistic here and why.
- **Severity Calibration** what critical / high / medium / low
look like for *this* target, with a concrete example at each
level. Where a vulnerability class needs attacker control that
does not exist in real usage, say so here.
Args:
target: What the model describes a host or URL
(``https://app.example.com``), or a repository path
(``/workspace/myrepo``). Use the same value the scan was
pointed at.
content: The full threat model in Markdown.
"""
return json.dumps(
await asyncio.to_thread(
_save_impl, target, content, _caller_agent_name(ctx), _scan_targets(ctx)
),
ensure_ascii=False,
default=str,
)
@function_tool(timeout=30)
async def amend_threat_model(ctx: RunContextWrapper, target: str, addendum: str) -> str:
"""Correct or extend the existing threat model without replacing it.
The baseline is written before anyone starts hunting, so it is
written with the least information anyone will ever have. That is
doubly true black-box, where the model starts as inference over
recon output and only becomes real as agents authenticate, map
roles, and reach the surfaces behind them. When your work
contradicts the model or fills in something it missed, record that
here every agent that calls ``get_threat_model`` afterwards sees
your addendum next to the base model.
Amendments are append-only and attributed, so two agents amending
at once both survive. That is the difference from
``save_threat_model``, which overwrites the document and drops
every amendment on it.
Worth amending:
- A boundary the model calls trusted that you found is
attacker-reachable, or vice versa.
- A host, endpoint, parameter, role, sink, or shared control the
model does not mention.
- Something the model only inferred that you have now observed or
that turned out not to be true.
- A severity call the model got wrong for this target, with the
reason.
- An assumption you disproved the model says input is validated
upstream and you found the path that skips it.
Not worth amending: individual findings (those are reports), or
restating what the model already says.
Args:
target: What the model describes the same host, URL, or
repository path used to save it.
addendum: The correction, in Markdown. State what the base
model says, what is actually true, and the endpoint, host,
file, or control that proves it.
"""
return json.dumps(
await asyncio.to_thread(
_amend_impl, target, addendum, _caller_agent_name(ctx), _scan_targets(ctx)
),
ensure_ascii=False,
default=str,
)
+6 -27
View File
@@ -110,19 +110,12 @@ def _get_agent_todos(agent_id: str) -> dict[str, dict[str, Any]]:
def _normalize_priority(priority: str | None, default: str = "normal") -> str:
candidate = str(priority or default or "normal").strip().lower()
candidate = (priority or default or "normal").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()
@@ -292,16 +285,11 @@ 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"``. Anything else,
including omitting it, falls back to ``"normal"`` rather
than failing.
``"normal"`` / ``"high"`` / ``"critical"``. Defaults to
``"normal"``.
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:
@@ -314,21 +302,13 @@ 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:
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"))
task_priority = _normalize_priority(task.get("priority"))
todo_id = str(uuid.uuid4())[:6]
timestamp = datetime.now(UTC).isoformat()
agent_todos[todo_id] = {
"title": title,
"title": task["title"],
"description": task.get("description"),
"priority": task_priority,
"status": "pending",
@@ -336,7 +316,7 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
"updated_at": timestamp,
"completed_at": None,
}
created.append({"todo_id": todo_id, "title": title, "priority": task_priority})
created.append({"todo_id": todo_id, "title": task["title"], "priority": task_priority})
except (ValueError, TypeError) as e:
return json.dumps(
{"success": False, "error": f"Failed to create todo: {e}"},
@@ -350,7 +330,6 @@ 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)),
},
-312
View File
@@ -1,312 +0,0 @@
"""Recognize API specifications and extract the hosts they declare.
Supports OpenAPI 3.x, Swagger 2.0, and Postman Collection v2.1. Two things about
an API spec must be decided on the host, in code: whether a target file is a
spec at all (detection), and which base URLs it authorizes as in-scope hosts
(scope cannot be self-granted by the agent). Everything else about the contract
operations, parameters, request bodies, auth is left to the agent, which
reads the spec file directly in the sandbox, so ``$ref``, ``allOf``, and nested
schemas resolve properly instead of being re-parsed here. Collections held only
in Postman are fetched here too, so the API key stays on the host and never
enters the sandbox.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import requests
import yaml
logger = logging.getLogger(__name__)
SPEC_EXTENSIONS = frozenset({".json", ".yaml", ".yml"})
#: Guard against pathological Postman folder nesting.
_MAX_POSTMAN_DEPTH = 25
class SpecParseError(ValueError):
"""Raised when a spec cannot be read, recognized, or fetched."""
def load_spec(path: str | Path) -> dict[str, Any]:
"""Load an API spec file as a mapping.
Raises :class:`SpecParseError` if the file cannot be read or is not a
JSON/YAML mapping.
"""
p = Path(path)
try:
text = p.read_text(encoding="utf-8")
except OSError as exc:
raise SpecParseError(f"Cannot read spec {p}: {exc}") from exc
# JSON is a subset of YAML, so safe_load parses both; try JSON first for a
# clearer error and to keep the fast path fast.
try:
data: Any = json.loads(text)
except json.JSONDecodeError:
try:
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise SpecParseError(f"{p} is not valid JSON or YAML: {exc}") from exc
if not isinstance(data, dict):
raise SpecParseError(f"{p} does not contain a mapping at the top level")
return data
def classify_spec(raw: dict[str, Any]) -> str | None:
"""Return ``openapi`` / ``swagger`` / ``postman``, or ``None`` if unrecognized."""
if isinstance(raw.get("openapi"), str):
return "openapi"
if str(raw.get("swagger", "")).startswith("2"):
return "swagger"
info = raw.get("info")
if isinstance(info, dict) and ("_postman_id" in info or "item" in raw):
return "postman"
return None
def detect_spec_format(path: Path) -> str | None:
"""Return the spec format of *path*, or ``None`` if it is not a spec.
Only files whose extension is in :data:`SPEC_EXTENSIONS` are inspected; the
contents are then loaded to confirm, so an arbitrary ``.json`` config is not
mistaken for a spec.
"""
if path.suffix.lower() not in SPEC_EXTENSIONS:
return None
try:
raw = load_spec(path)
except SpecParseError:
return None
return classify_spec(raw)
def spec_title(raw: dict[str, Any]) -> str:
"""Return the spec's declared name, for display in the task and run record."""
info = raw.get("info")
if not isinstance(info, dict):
return "API"
name = info.get("title") or info.get("name") or "API"
return str(name).strip() or "API"
def _absolute_urls(candidates: list[str]) -> list[str]:
"""Keep absolute http(s) URLs, without trailing slashes, in declared order."""
urls: list[str] = []
for candidate in candidates:
split = urlsplit(candidate.strip())
if split.scheme in ("http", "https") and split.netloc:
urls.append(candidate.strip().rstrip("/"))
return list(dict.fromkeys(urls))
_SERVER_VAR_PATTERN = re.compile(r"\{([^{}/]+)\}")
def _resolve_server_url(url: str, variables: Any) -> str:
"""Substitute an OpenAPI server template's variables with their defaults."""
if "{" not in url or not isinstance(variables, dict):
return url
defaults: dict[str, str] = {}
for name, spec in variables.items():
if isinstance(spec, dict) and spec.get("default") is not None:
defaults[str(name)] = str(spec["default"])
return _SERVER_VAR_PATTERN.sub(lambda m: defaults.get(m.group(1), m.group(0)), url)
def _openapi_base_urls(raw: dict[str, Any]) -> list[str]:
servers = raw.get("servers")
if not isinstance(servers, list):
return []
return _absolute_urls(
[
_resolve_server_url(str(server["url"]), server.get("variables"))
for server in servers
if isinstance(server, dict) and server.get("url")
],
)
def _swagger_base_urls(raw: dict[str, Any]) -> list[str]:
host = str(raw.get("host", "")).strip()
if not host:
return []
base_path = str(raw.get("basePath", "")).strip()
schemes = [s for s in (raw.get("schemes") or ["https"]) if isinstance(s, str)]
return _absolute_urls([f"{scheme}://{host}{base_path}" for scheme in schemes])
_POSTMAN_VAR_PATTERN = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
def postman_variables(raw: dict[str, Any]) -> dict[str, str]:
"""Build a ``{name: value}`` map from a Postman ``variable`` block."""
variables: dict[str, str] = {}
entries = raw.get("variable")
if isinstance(entries, list):
for entry in entries:
if isinstance(entry, dict) and entry.get("key") is not None:
variables[str(entry["key"])] = str(entry.get("value", ""))
return variables
def _resolve_postman_vars(text: str, variables: dict[str, str]) -> str:
if not variables or "{{" not in text:
return text
return _POSTMAN_VAR_PATTERN.sub(lambda m: variables.get(m.group(1), m.group(0)), text)
def _postman_request_url(url: Any, variables: dict[str, str]) -> str:
if isinstance(url, str):
raw = url
elif isinstance(url, dict):
raw = str(url.get("raw", ""))
if not raw:
host = url.get("host")
raw = ".".join(str(h) for h in host) if isinstance(host, list) else str(host or "")
else:
return ""
return _resolve_postman_vars(raw, variables)
def _walk_postman_hosts(
items: Any,
variables: dict[str, str],
hosts: list[str],
depth: int = 0,
) -> None:
if depth > _MAX_POSTMAN_DEPTH or not isinstance(items, list):
return
for node in items:
if not isinstance(node, dict):
continue
if isinstance(node.get("item"), list):
_walk_postman_hosts(node["item"], variables, hosts, depth + 1)
continue
request = node.get("request")
if not isinstance(request, dict):
continue
url = _postman_request_url(request.get("url"), variables)
split = urlsplit(url)
if split.scheme and split.netloc:
hosts.append(f"{split.scheme}://{split.netloc}")
def _postman_base_urls(raw: dict[str, Any], extra_variables: dict[str, str] | None) -> list[str]:
variables = postman_variables(raw)
if extra_variables:
variables.update(extra_variables) # environment values override collection defaults
hosts: list[str] = []
_walk_postman_hosts(raw.get("item"), variables, hosts)
return _absolute_urls(sorted(set(hosts)))
def spec_base_urls(
raw: dict[str, Any],
*,
extra_variables: dict[str, str] | None = None,
) -> list[str]:
"""Return the absolute base URLs a spec declares, for scope authorization.
Relative and unresolved-template URLs are dropped: an unusable value would
otherwise be authorized as an in-scope host. Callers pair the spec with an
explicit ``--target`` host when the spec declares none.
"""
spec_format = classify_spec(raw)
if spec_format == "openapi":
return _openapi_base_urls(raw)
if spec_format == "swagger":
return _swagger_base_urls(raw)
if spec_format == "postman":
return _postman_base_urls(raw, extra_variables)
raise SpecParseError("File is not a recognized OpenAPI, Swagger, or Postman spec")
POSTMAN_API_BASE = "https://api.getpostman.com"
_POSTMAN_FETCH_TIMEOUT = 30
def _postman_api_json(url: str, api_key: str, label: str) -> dict[str, Any]:
"""GET a Postman API resource and return the parsed JSON payload.
Raises :class:`SpecParseError` with an actionable message on auth, network,
or shape errors.
"""
if not api_key:
raise SpecParseError(
"POSTMAN_API_KEY is not set. Export a Postman API key (PMAK-…) to "
"fetch from the Postman API, or pass a local collection file instead.",
)
try:
response = requests.get(
url,
headers={"X-Api-Key": api_key, "Accept": "application/json"},
timeout=_POSTMAN_FETCH_TIMEOUT,
)
except requests.RequestException as exc:
raise SpecParseError(f"Failed to reach the Postman API: {exc}") from exc
if response.status_code == 401:
raise SpecParseError("Postman API rejected the key (401). Check POSTMAN_API_KEY.")
if response.status_code == 404:
raise SpecParseError(
f"Postman {label} not found (404). Check the id and that the key can access it.",
)
if response.status_code != 200:
raise SpecParseError(f"Postman API returned HTTP {response.status_code} for {label}.")
try:
payload = response.json()
except ValueError as exc:
raise SpecParseError(f"Postman API returned non-JSON for {label}") from exc
if not isinstance(payload, dict):
raise SpecParseError(f"Unexpected Postman API response shape for {label}")
return payload
def fetch_postman_collection(collection_uid: str, api_key: str) -> dict[str, Any]:
"""Fetch a collection from the Postman API and return the raw collection dict.
Uses ``GET /collections/{uid}`` with the ``X-Api-Key`` header. The endpoint
wraps the collection under a ``collection`` key, unwrapped here so the result
matches an exported collection file.
"""
payload = _postman_api_json(
f"{POSTMAN_API_BASE}/collections/{collection_uid}",
api_key,
f"collection {collection_uid}",
)
collection = payload.get("collection", payload)
if not isinstance(collection, dict) or not collection:
raise SpecParseError(f"Postman collection {collection_uid} came back empty")
return collection
def fetch_postman_environment(environment_uid: str, api_key: str) -> dict[str, str]:
"""Fetch a Postman environment and return its enabled ``{key: value}`` pairs.
Disabled values are skipped, matching how Postman resolves an environment at
request time.
"""
payload = _postman_api_json(
f"{POSTMAN_API_BASE}/environments/{environment_uid}",
api_key,
f"environment {environment_uid}",
)
environment = payload.get("environment", payload)
values = environment.get("values") if isinstance(environment, dict) else None
if not isinstance(values, list):
return {}
return {
str(value["key"]): str(value.get("value", ""))
for value in values
if isinstance(value, dict) and value.get("key") and value.get("enabled", True)
}
-31
View File
@@ -1,31 +0,0 @@
from __future__ import annotations
import contextlib
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
SECRET_FILE_MODE = 0o600
def write_secret_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with contextlib.suppress(FileNotFoundError):
tmp.unlink()
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, SECRET_FILE_MODE)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
except BaseException:
with contextlib.suppress(OSError):
tmp.unlink()
raise
tmp.replace(path)
+1 -23
View File
@@ -70,6 +70,7 @@ 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:
@@ -78,29 +79,6 @@ 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"}'})
-290
View File
@@ -1,290 +0,0 @@
"""Tests for spec recognition and base-URL extraction in strix.utils.api_spec."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
import pytest
import requests
import yaml
from strix.utils.api_spec import (
SpecParseError,
classify_spec,
detect_spec_format,
fetch_postman_collection,
fetch_postman_environment,
load_spec,
spec_base_urls,
spec_title,
)
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
OPENAPI_YAML = """
openapi: 3.0.1
info:
title: Shop API
version: 1.0.0
servers:
- url: https://{region}.api.shop.test/{ver}
variables:
region:
default: eu
ver:
default: v1
paths:
/users/{id}:
get:
summary: Get user
"""
SWAGGER_JSON = {
"swagger": "2.0",
"info": {"title": "Legacy"},
"host": "legacy.test",
"basePath": "/api",
"schemes": ["https"],
"paths": {"/orders": {"post": {"summary": "Create order"}}},
}
POSTMAN_JSON = {
"info": {"_postman_id": "abc-123", "name": "Pet Store"},
"item": [
{
"name": "Pets",
"item": [
{
"name": "List pets",
"request": {"method": "GET", "url": {"raw": "https://petstore.test/pets"}},
}
],
},
{
"name": "Add pet",
"request": {"method": "POST", "url": "https://petstore.test/pets"},
},
],
}
def _write(tmp_path: Path, name: str, content: str) -> Path:
path = tmp_path / name
path.write_text(content, encoding="utf-8")
return path
# --- detection -----------------------------------------------------------
def test_detect_openapi_yaml(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "openapi.yaml", OPENAPI_YAML)) == "openapi"
def test_detect_swagger_json(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "swagger.json", json.dumps(SWAGGER_JSON))) == (
"swagger"
)
def test_detect_postman_json(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "collection.json", json.dumps(POSTMAN_JSON))) == (
"postman"
)
def test_detect_ignores_non_spec_extension(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "notes.txt", OPENAPI_YAML)) is None
def test_detect_ignores_non_spec_json(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "config.json", json.dumps({"foo": "bar"}))) is None
def test_classify_unrecognized_is_none() -> None:
assert classify_spec({"foo": 1}) is None
# --- loading -------------------------------------------------------------
def test_load_spec_rejects_missing_file(tmp_path: Path) -> None:
with pytest.raises(SpecParseError, match="Cannot read"):
load_spec(tmp_path / "nope.yaml")
def test_load_spec_rejects_malformed_yaml(tmp_path: Path) -> None:
with pytest.raises(SpecParseError):
load_spec(_write(tmp_path, "broken.yaml", "openapi: 3.0.0\npaths: [unclosed"))
def test_load_spec_rejects_non_mapping(tmp_path: Path) -> None:
with pytest.raises(SpecParseError, match="mapping"):
load_spec(_write(tmp_path, "list.json", json.dumps([1, 2, 3])))
def test_spec_title_reads_openapi_and_postman() -> None:
assert spec_title(yaml.safe_load(OPENAPI_YAML)) == "Shop API"
assert spec_title(POSTMAN_JSON) == "Pet Store"
assert spec_title({"info": {}}) == "API"
# --- base URL extraction -------------------------------------------------
def test_openapi_base_urls_resolve_server_variables() -> None:
raw = yaml.safe_load(OPENAPI_YAML)
# {region}/{ver} substituted with their declared defaults
assert spec_base_urls(raw) == ["https://eu.api.shop.test/v1"]
def test_openapi_drops_unresolved_relative_server() -> None:
raw = {"openapi": "3.0.0", "info": {"title": "X"}, "servers": [{"url": "/v2"}]}
# relative URL is not an authorizable host
assert spec_base_urls(raw) == []
def test_swagger_base_urls_built_from_host() -> None:
assert spec_base_urls(SWAGGER_JSON) == ["https://legacy.test/api"]
def test_swagger_without_host_yields_no_base_urls() -> None:
assert spec_base_urls({"swagger": "2.0", "info": {}, "paths": {}}) == []
def test_postman_base_urls_from_request_hosts() -> None:
assert spec_base_urls(POSTMAN_JSON) == ["https://petstore.test"]
def test_spec_base_urls_rejects_unrecognized() -> None:
with pytest.raises(SpecParseError):
spec_base_urls({"foo": 1})
# --- Postman variable / environment resolution ---------------------------
POSTMAN_WITH_VARS = {
"info": {"_postman_id": "v-1", "name": "Var Collection"},
"variable": [{"key": "baseUrl", "value": "https://api.vars.test"}],
"item": [
{"name": "Get thing", "request": {"method": "GET", "url": {"raw": "{{baseUrl}}/things/1"}}}
],
}
POSTMAN_NEEDS_ENV = {
"info": {"_postman_id": "e-1", "name": "Env Collection"},
"item": [
{"name": "Get thing", "request": {"method": "GET", "url": {"raw": "{{baseUrl}}/things/1"}}}
],
}
def test_postman_resolves_collection_variables() -> None:
assert spec_base_urls(POSTMAN_WITH_VARS) == ["https://api.vars.test"]
def test_postman_without_env_leaves_variable_unresolved() -> None:
# {{baseUrl}} never resolves -> no absolute host recovered
assert spec_base_urls(POSTMAN_NEEDS_ENV) == []
def test_postman_environment_values_resolve_base_url() -> None:
resolved = spec_base_urls(
POSTMAN_NEEDS_ENV,
extra_variables={"baseUrl": "https://api.env.test"},
)
assert resolved == ["https://api.env.test"]
# --- Postman API fetch ---------------------------------------------------
class _FakeResponse:
def __init__(self, status_code: int, payload: Any) -> None:
self.status_code = status_code
self._payload = payload
def json(self) -> Any:
return self._payload
def test_fetch_postman_collection_unwraps(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
def fake_get(url: str, headers: dict[str, str], **_kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["headers"] = headers
return _FakeResponse(200, {"collection": POSTMAN_WITH_VARS})
monkeypatch.setattr(requests, "get", fake_get)
collection = fetch_postman_collection("abc-123", "PMAK-xyz")
assert collection["info"]["name"] == "Var Collection"
assert captured["url"].endswith("/collections/abc-123")
assert captured["headers"]["X-Api-Key"] == "PMAK-xyz"
def test_fetch_postman_missing_key_raises() -> None:
with pytest.raises(SpecParseError, match="POSTMAN_API_KEY"):
fetch_postman_collection("abc-123", "")
def test_fetch_postman_404_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(404, {}))
with pytest.raises(SpecParseError, match="not found"):
fetch_postman_collection("missing", "PMAK-xyz")
def test_fetch_postman_401_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(401, {}))
with pytest.raises(SpecParseError, match="rejected the key"):
fetch_postman_collection("abc-123", "bad-key")
def test_fetch_postman_empty_collection_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(200, {"collection": {}}))
with pytest.raises(SpecParseError, match="empty"):
fetch_postman_collection("abc-123", "PMAK-xyz")
def test_fetch_postman_environment_returns_enabled_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = {
"environment": {
"name": "prod",
"values": [
{"key": "baseUrl", "value": "https://api.env.test", "enabled": True},
{"key": "secretToken", "value": "s3cr3t", "enabled": False},
],
}
}
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(200, payload))
values = fetch_postman_environment("env-1", "PMAK-xyz")
assert values == {"baseUrl": "https://api.env.test"} # disabled secret excluded
def _dispatch_get(
collection: dict[str, Any],
env: dict[str, Any],
) -> Callable[..., _FakeResponse]:
def fake_get(url: str, **_kwargs: Any) -> _FakeResponse:
if "/environments/" in url:
return _FakeResponse(200, env)
return _FakeResponse(200, {"collection": collection})
return fake_get
def test_fetch_then_resolve_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
env = {"environment": {"values": [{"key": "baseUrl", "value": "https://api.env.test"}]}}
monkeypatch.setattr(requests, "get", _dispatch_get(POSTMAN_NEEDS_ENV, env))
collection = fetch_postman_collection("coll-1", "PMAK-xyz")
variables = fetch_postman_environment("env-1", "PMAK-xyz")
assert spec_base_urls(collection, extra_variables=variables) == ["https://api.env.test"]
-152
View File
@@ -1,152 +0,0 @@
"""Integration of the ``api_spec`` target type into detection, staging, and inputs."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import pytest
from strix.core.inputs import build_root_task, build_scope_context
from strix.interface.scan_setup import build_targets_info
from strix.interface.utils import infer_target_type, stage_api_specs
OPENAPI = {
"openapi": "3.0.0",
"info": {"title": "Shop API", "version": "1"},
"servers": [{"url": "https://api.shop.test/v1"}],
"paths": {
"/users/{id}": {
"get": {
"summary": "Get user",
"parameters": [{"name": "id", "in": "path", "schema": {"type": "string"}}],
}
}
},
}
def _write_spec(directory: Path, name: str = "openapi.json") -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / name
path.write_text(json.dumps(OPENAPI), encoding="utf-8")
return path
def _resolved_targets(*spec_paths: Path) -> list[dict[str, Any]]:
"""Run spec targets through the real setup path (detection + spec resolution)."""
args = argparse.Namespace(target=[str(p) for p in spec_paths], target_list=None)
build_targets_info(args)
targets: list[dict[str, Any]] = args.targets_info
return targets
def _staged_target(tmp_path: Path, run_name: str = "test-run") -> dict[str, Any]:
targets = _resolved_targets(_write_spec(tmp_path / "src"))
stage_api_specs(targets, run_name)
return targets[0]
def test_infer_target_type_detects_api_spec(tmp_path: Path) -> None:
path = _write_spec(tmp_path)
ttype, details = infer_target_type(str(path))
assert ttype == "api_spec"
assert details["spec_format"] == "openapi"
assert Path(details["target_spec"]).is_absolute()
def test_infer_target_type_still_rejects_non_spec_file(tmp_path: Path) -> None:
path = tmp_path / "data.json"
path.write_text(json.dumps({"foo": "bar"}), encoding="utf-8")
with pytest.raises(ValueError, match="not a directory"):
infer_target_type(str(path))
def test_infer_target_type_detects_postman_uri() -> None:
ttype, details = infer_target_type("postman://12345-abcdef-uid")
assert ttype == "api_spec"
assert details["source"] == "postman_api"
assert details["collection_uid"] == "12345-abcdef-uid"
assert details["spec_format"] == "postman"
def test_infer_target_type_rejects_empty_postman_uri() -> None:
with pytest.raises(ValueError, match="collection id"):
infer_target_type("postman://")
def test_infer_target_type_parses_postman_environment() -> None:
_ttype, details = infer_target_type("postman://coll-uid?env=env-uid")
assert details["collection_uid"] == "coll-uid"
assert details["environment_uid"] == "env-uid"
def test_infer_target_type_postman_without_env_omits_key() -> None:
_ttype, details = infer_target_type("postman://coll-uid")
assert "environment_uid" not in details
def test_build_targets_info_records_title_and_base_urls(tmp_path: Path) -> None:
(target,) = _resolved_targets(_write_spec(tmp_path))
assert target["details"]["spec_title"] == "Shop API"
assert target["details"]["base_urls"] == ["https://api.shop.test/v1"]
def test_build_targets_info_rejects_unparseable_spec(tmp_path: Path) -> None:
path = tmp_path / "openapi.json"
path.write_text('{"openapi": "3.0.0", "info": {"title": "X"}, "paths"', encoding="utf-8")
args = argparse.Namespace(target=[str(path)], target_list=None)
# a broken file is not recognized as a spec, so it fails as an unusable target
with pytest.raises(ValueError, match="Invalid target"):
build_targets_info(args)
def test_stage_api_specs_copies_spec_into_workspace_dir(tmp_path: Path) -> None:
targets = _resolved_targets(_write_spec(tmp_path / "src"))
(source,) = stage_api_specs(targets, "stage-run")
assert source["workspace_subdir"] == "api-specs"
staged = Path(source["source_path"]) / "openapi.json"
assert json.loads(staged.read_text(encoding="utf-8"))["info"]["title"] == "Shop API"
assert targets[0]["details"]["workspace_path"] == "/workspace/api-specs/openapi.json"
def test_stage_api_specs_disambiguates_same_filename(tmp_path: Path) -> None:
targets = _resolved_targets(
_write_spec(tmp_path / "a"),
_write_spec(tmp_path / "b"),
)
(source,) = stage_api_specs(targets, "dupe-run")
staged_paths = [t["details"]["workspace_path"] for t in targets]
assert staged_paths == [
"/workspace/api-specs/openapi.json",
"/workspace/api-specs/openapi-2.json",
]
assert (Path(source["source_path"]) / "openapi-2.json").is_file()
def test_stage_api_specs_without_specs_returns_nothing() -> None:
assert stage_api_specs([{"type": "web_application", "details": {}}], "run") == []
def test_build_root_task_points_at_the_spec_file(tmp_path: Path) -> None:
task = build_root_task({"targets": [_staged_target(tmp_path)]})
assert "API Specifications" in task
assert "Shop API (openapi specification" in task
assert "/workspace/api-specs/openapi.json" in task
assert "https://api.shop.test/v1" in task
assert "test every operation it declares" in task
def test_build_scope_context_authorizes_base_urls(tmp_path: Path) -> None:
context = build_scope_context({"targets": [_staged_target(tmp_path)]})
authorized = context["authorized_targets"]
types = {a["type"] for a in authorized}
assert "api_spec" in types
assert "web_application" in types
assert any(a["value"] == "https://api.shop.test/v1" for a in authorized)
-284
View File
@@ -1,284 +0,0 @@
"""Tests for the scan coverage ledger."""
from __future__ import annotations
import json
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING, Any
import pytest
from strix.tools.coverage.tools import (
_list_impl,
_record_impl,
_update_impl,
get_coverage_entries,
hydrate_coverage_from_disk,
outcome_counts,
)
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def coverage_store(tmp_path: Path) -> Path:
hydrate_coverage_from_disk(tmp_path)
return tmp_path
def _record(**overrides: str) -> dict[str, Any]:
kwargs = {
"surface": "POST /api/orders/{id}",
"risk_area": "object-level authorization",
"outcome": "no_issue_found",
"evidence": "Tested with two tenants; both received 403.",
"agent_id": "agent-1",
"agent_name": "authz-tester",
}
kwargs.update(overrides)
return _record_impl(**kwargs)
def test_record_persists_entry(coverage_store: Path) -> None:
result = _record()
assert result["success"] is True
entries = get_coverage_entries()
assert len(entries) == 1
assert entries[0]["surface"] == "POST /api/orders/{id}"
assert entries[0]["outcome"] == "no_issue_found"
assert entries[0]["agent_name"] == "authz-tester"
assert (coverage_store / "coverage.json").exists()
def test_record_normalizes_outcome() -> None:
assert _record(outcome="Needs Follow-Up")["success"] is True
assert get_coverage_entries()[0]["outcome"] == "needs_follow_up"
def test_record_rejects_unknown_outcome() -> None:
result = _record(outcome="looks fine")
assert result["success"] is False
assert any("Invalid outcome" in e for e in result["errors"])
assert not get_coverage_entries()
def test_record_requires_surface_and_risk_area() -> None:
result = _record(surface=" ", risk_area="")
assert result["success"] is False
joined = " ".join(result["errors"])
assert "surface" in joined
assert "risk_area" in joined
@pytest.mark.parametrize("outcome", ["ruled_out", "not_applicable", "needs_follow_up"])
def test_evidence_required_for_asserted_outcomes(outcome: str) -> None:
result = _record(outcome=outcome, evidence=" ")
assert result["success"] is False
assert any("evidence is required" in e for e in result["errors"])
def test_evidence_optional_for_reported() -> None:
assert _record(outcome="reported", evidence="")["success"] is True
def test_outcome_counts_and_filtering() -> None:
_record(surface="/login", outcome="reported", evidence="")
_record(surface="/search", outcome="no_issue_found")
_record(surface="/upload", outcome="needs_follow_up", evidence="No credentials to test.")
assert outcome_counts() == {"reported": 1, "no_issue_found": 1, "needs_follow_up": 1}
listed = _list_impl(outcome="needs_follow_up", surface=None, caller_agent_id="agent-1")
assert listed["filtered_count"] == 1
assert listed["entries"][0]["surface"] == "/upload"
assert listed["entries"][0]["by_you"] is True
by_surface = _list_impl(outcome=None, surface="sea", caller_agent_id=None)
assert by_surface["filtered_count"] == 1
assert by_surface["entries"][0]["surface"] == "/search"
def test_list_rejects_unknown_outcome_filter() -> None:
result = _list_impl(outcome="bogus", surface=None, caller_agent_id=None)
assert result["success"] is False
def test_hydrate_reloads_from_disk(coverage_store: Path) -> None:
_record()
hydrate_coverage_from_disk(coverage_store)
entries = get_coverage_entries()
assert len(entries) == 1
assert entries[0]["risk_area"] == "object-level authorization"
def _update(entry_id: str, **overrides: str) -> dict[str, Any]:
kwargs = {
"entry_id": entry_id,
"outcome": "reported",
"evidence": "Got staging credentials and confirmed the IDOR.",
"agent_id": "agent-2",
"agent_name": "followup-tester",
}
kwargs.update(overrides)
return _update_impl(**kwargs)
def test_update_moves_outcome_and_keeps_history() -> None:
recorded = _record(outcome="needs_follow_up", evidence="No credentials to test.")
entry_id = str(recorded["entry_id"])
result = _update(entry_id)
assert result["success"] is True
assert result["previous_outcome"] == "needs_follow_up"
assert result["outcome"] == "reported"
entries = get_coverage_entries()
assert len(entries) == 1, "update must not create a parallel entry"
entry = entries[0]
assert entry["outcome"] == "reported"
assert entry["agent_name"] == "followup-tester"
assert entry["history"] == [
{
"outcome": "needs_follow_up",
"recorded_at": entry["created_at"],
"evidence": "No credentials to test.",
"agent_name": "authz-tester",
}
]
assert outcome_counts() == {"reported": 1}
def test_update_can_reopen_a_closed_entry() -> None:
recorded = _record(outcome="ruled_out", evidence="Guard at auth.py:40 covers the path.")
entry_id = str(recorded["entry_id"])
_update(
entry_id,
outcome="needs_follow_up",
evidence="The guard is skipped on the /v2 alias; reachability unproven.",
)
assert outcome_counts() == {"needs_follow_up": 1}
listed = _list_impl(outcome=None, surface=None, caller_agent_id=None)
assert listed["entries"][0]["previous_outcomes"] == ["ruled_out"]
def test_update_enforces_evidence_for_closing_outcomes() -> None:
entry_id = str(_record(outcome="needs_follow_up", evidence="unknown")["entry_id"])
result = _update(entry_id, outcome="ruled_out", evidence=" ")
assert result["success"] is False
assert get_coverage_entries()[0]["outcome"] == "needs_follow_up"
def test_update_rejects_unknown_entry() -> None:
result = _update("nope")
assert result["success"] is False
assert "list_coverage" in str(result["error"])
def test_update_persists_to_disk(coverage_store: Path) -> None:
entry_id = str(_record(outcome="needs_follow_up", evidence="No creds.")["entry_id"])
_update(entry_id)
hydrate_coverage_from_disk(coverage_store)
entry = get_coverage_entries()[0]
assert entry["outcome"] == "reported"
assert len(entry["history"]) == 1
def test_recording_a_duplicate_surface_is_refused_with_the_existing_id() -> None:
first = _record_impl(
surface="/api/invoices",
risk_area="IDOR",
outcome="needs_follow_up",
evidence="No second tenant account to test cross-tenant reads with.",
agent_id="a1",
agent_name="Recon",
)
duplicate = _record_impl(
surface=" /API/Invoices ",
risk_area="idor",
outcome="reported",
evidence="Cross-tenant read confirmed.",
agent_id="a2",
agent_name="Authz",
)
assert duplicate["success"] is False
assert duplicate["existing_entry_id"] == first["entry_id"]
assert duplicate["existing_outcome"] == "needs_follow_up"
assert "update_coverage" in duplicate["error"]
assert len(get_coverage_entries()) == 1
def test_a_different_risk_area_on_one_surface_is_still_its_own_entry() -> None:
_record_impl(
surface="/api/invoices",
risk_area="IDOR",
outcome="no_issue_found",
evidence="Tenant id read from the session.",
agent_id="a1",
agent_name="Authz",
)
second = _record_impl(
surface="/api/invoices",
risk_area="SQL injection",
outcome="no_issue_found",
evidence="Parameterized throughout.",
agent_id="a1",
agent_name="Injection",
)
assert second["success"] is True
assert len(get_coverage_entries()) == 2
def test_concurrent_records_of_one_surface_yield_a_single_row() -> None:
"""Duplicate detection and insertion must be one critical section.
Two agents recording the same surface at the same moment would otherwise
both pass the "no duplicate" check, and the report would show a stale
conclusion beside its replacement the exact outcome the rejection exists
to prevent.
"""
barrier = threading.Barrier(8)
def attempt(index: int) -> dict[str, Any]:
barrier.wait()
return _record(agent_id=f"agent-{index}", agent_name=f"tester-{index}")
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(attempt, range(8)))
assert sum(1 for result in results if result["success"]) == 1
assert len(get_coverage_entries()) == 1
def test_concurrent_records_all_survive_persistence(coverage_store: Path) -> None:
"""A writer holding an older snapshot must not win the rename.
If it did, the mirror would come back short on resume and coverage
recorded before a crash would silently disappear from the report.
"""
barrier = threading.Barrier(8)
def attempt(index: int) -> dict[str, Any]:
barrier.wait()
return _record(surface=f"GET /api/resource/{index}", agent_id=f"agent-{index}")
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(attempt, range(8)))
persisted = json.loads((coverage_store / "coverage.json").read_text(encoding="utf-8"))
assert len(persisted) == 8
hydrate_coverage_from_disk(coverage_store)
assert len(get_coverage_entries()) == 8
+7 -11
View File
@@ -31,7 +31,7 @@ from openai.types.responses import (
from strix.config import codex, loader
from strix.config.loader import load_settings
from strix.config.models import StrixProvider, _NonStreamingModel, _TurnGuardModel
from strix.config.models import StrixProvider, _NonStreamingModel
if TYPE_CHECKING:
@@ -299,11 +299,10 @@ def test_get_model_wraps_when_disabled(
load_settings()
model = StrixProvider().get_model("openai/gpt-4o-mini")
assert isinstance(model, _TurnGuardModel)
assert isinstance(model._inner, _NonStreamingModel)
assert isinstance(model, _NonStreamingModel)
def test_get_model_keeps_streaming_by_default(
def test_get_model_unwrapped_by_default(
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
) -> None:
inner = _DummyModel()
@@ -311,20 +310,17 @@ def test_get_model_keeps_streaming_by_default(
load_settings()
model = StrixProvider().get_model("openai/gpt-4o-mini")
assert isinstance(model, _TurnGuardModel)
assert model._inner is inner
assert model is inner
def test_get_model_guards_subscription_model_but_keeps_it_streaming(
def test_get_model_does_not_wrap_subscription_model(
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
) -> None:
# Subscription (ChatGPT) models are always streamed, so LLM_DISABLE_STREAMING
# must not apply — but a runaway response needs capping there too.
# Subscription (ChatGPT) models are always streamed and must not be wrapped.
monkeypatch.setattr(codex, "subscription_model", lambda *_: "gpt-5.5")
monkeypatch.setattr(codex, "get_subscription_client", lambda: AsyncOpenAI(api_key="x"))
monkeypatch.setenv("LLM_DISABLE_STREAMING", "true")
load_settings()
model = StrixProvider().get_model("gpt-5.5")
assert isinstance(model, _TurnGuardModel)
assert not isinstance(model._inner, _NonStreamingModel)
assert not isinstance(model, _NonStreamingModel)
-70
View File
@@ -855,39 +855,6 @@ async def test_structured_provider_refusal_fails_noninteractive_child(
session.close()
@pytest.mark.asyncio
async def test_crashing_noninteractive_child_settles_and_wakes_its_parent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The exception ends the child's task, so its status and the parent's wake-up
# have to be settled on the way out or the parent waits on a dead child.
def _boom(*_args: Any, **_kwargs: Any) -> Any:
raise RuntimeError("sandbox died mid-turn")
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _boom)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
with pytest.raises(RuntimeError, match="sandbox died mid-turn"):
await execution._run_cycle(
MagicMock(),
coordinator,
"child",
input_data="task",
run_config=MagicMock(),
context={"parent_id": "root"},
max_turns=5,
session=None,
interactive=False,
event_sink=None,
hooks=None,
)
assert coordinator.statuses["child"] == "crashed"
assert coordinator.pending_counts.get("root", 0) > 0
@pytest.mark.asyncio
async def test_run_agent_loop_seeds_identity_before_first_cycle(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
@@ -1228,40 +1195,3 @@ 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"]
-65
View File
@@ -1,65 +0,0 @@
"""finish_scan confronts the root agent with the coverage the runtime can see."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from strix.tools.coverage.tools import _record_impl, hydrate_coverage_from_disk
from strix.tools.finish.tool import _coverage_summary
if TYPE_CHECKING:
from pathlib import Path
_GRAPH = {
"statuses": {"agent-1": "completed"},
"names": {"agent-1": "injection-tester"},
"metadata": {"agent-1": {"skills": ["sql_injection", "xss"]}},
}
@pytest.fixture(autouse=True)
def _empty_ledger(tmp_path: Path) -> None:
hydrate_coverage_from_disk(tmp_path)
def _record(risk_area: str) -> None:
_record_impl(
surface="POST /api/orders/{id}",
risk_area=risk_area,
outcome="no_issue_found",
evidence="Parameters fuzzed; no anomalies.",
agent_id="agent-1",
agent_name="injection-tester",
)
def test_unrecorded_risk_class_is_reported_back_to_the_root_agent() -> None:
_record("SQL injection")
summary = _coverage_summary(_GRAPH)
assert summary["coverage_recorded"] == 1
assert len(summary["coverage_gaps"]) == 1
assert "xss" in summary["coverage_gaps"][0]
assert "unexamined" in summary["coverage_gap_warning"]
def test_fully_accounted_coverage_raises_no_gap_warning() -> None:
_record("SQL injection")
_record("cross-site scripting")
summary = _coverage_summary(_GRAPH)
assert "coverage_gaps" not in summary
assert "coverage_gap_warning" not in summary
def test_an_empty_ledger_still_warns_first() -> None:
summary = _coverage_summary(_GRAPH)
assert summary["coverage_recorded"] == 0
assert "No coverage was recorded" in summary["coverage_warning"]

Some files were not shown because too many files have changed in this diff Show More