mirror of
https://github.com/usestrix/strix.git
synced 2026-08-22 02:58:39 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e50a8e32c |
@@ -15,14 +15,6 @@ npx skills add usestrix/strix
|
|||||||
- `fix-security-vulnerabilities-with-strix` — remediate findings and re-run Strix to verify
|
- `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)
|
- `ci-security-scanning-with-strix` — add PR scanning to CI/CD (self-hosted CLI or managed app)
|
||||||
|
|
||||||
Target-specific workflows built on the same engine:
|
|
||||||
|
|
||||||
- `application-security-testing` — whole-product AppSec review: pick the right test per asset, then rank the results
|
|
||||||
- `web-app-penetration-testing` — black-box pentest of a live web app or staging site
|
|
||||||
- `api-security-testing` — REST/GraphQL APIs and the OWASP API Security Top 10 (BOLA/IDOR, authz)
|
|
||||||
- `owasp-top-10-testing` — systematic OWASP Top 10 assessment with honest per-category coverage
|
|
||||||
- `find-security-vulnerabilities-in-code` — white-box review of a repo or working tree
|
|
||||||
|
|
||||||
**Two ways to run, same engine — pick per situation:**
|
**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.
|
- **Open-source CLI (self-hosted):** free, fully local, BYO LLM key, needs Docker. Best for local dev loops, air-gapped/offline, and full control.
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatib
|
|||||||
npx skills add usestrix/strix
|
npx skills add usestrix/strix
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs nine 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), **ci-security-scanning-with-strix** (PR scanning in CI), plus target-specific workflows: **application-security-testing**, **web-app-penetration-testing**, **api-security-testing**, **owasp-top-10-testing**, and **find-security-vulnerabilities-in-code**. Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
This installs four skills: **penetration-testing-with-strix** (run headless scans and read results), **managed-pentesting-with-strix** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **fix-security-vulnerabilities-with-strix** (remediate + re-scan to verify), and **ci-security-scanning-with-strix** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -167,15 +167,10 @@ strix view
|
|||||||
|
|
||||||
# ...or open a specific run by name
|
# ...or open a specific run by name
|
||||||
strix view my-run-name
|
strix view my-run-name
|
||||||
|
|
||||||
# Expose the viewer on all IPv4 interfaces at a fixed port
|
|
||||||
strix view --host 0.0.0.0 --port 8080 --no-open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||||
|
|
||||||
Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `0.0.0.0` in the printed URL with the server's reachable IP or hostname. The token in that URL grants access to the selected run's scan data, history, and steering, so only share it with trusted users and restrict the port with your firewall. Requests without the token-derived session cannot read run data.
|
|
||||||
|
|
||||||
### What's in the dashboard
|
### What's in the dashboard
|
||||||
|
|
||||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ npx skills add usestrix/strix
|
|||||||
| `managed-pentesting-with-strix` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed |
|
| `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 |
|
| `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) |
|
| `ci-security-scanning-with-strix` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) |
|
||||||
| `application-security-testing` | Assess a whole product: choose the right test for each asset, then rank the findings into one remediation plan |
|
|
||||||
| `web-app-penetration-testing` | Black-box pentest of a live web app or staging site — scope, credentials, and multi-account access-control testing |
|
|
||||||
| `api-security-testing` | Test a REST/GraphQL API against the OWASP API Security Top 10 — schema-driven enumeration, BOLA/IDOR, authz |
|
|
||||||
| `owasp-top-10-testing` | Systematic OWASP Top 10 assessment with honest per-category coverage |
|
|
||||||
| `find-security-vulnerabilities-in-code` | White-box security review of a repo or working tree, with exploits to confirm findings |
|
|
||||||
|
|
||||||
Install a single skill with `npx skills add usestrix/strix --skill penetration-testing-with-strix`, or use one without installing:
|
Install a single skill with `npx skills add usestrix/strix --skill penetration-testing-with-strix`, or use one without installing:
|
||||||
|
|
||||||
|
|||||||
@@ -270,10 +270,6 @@ ignore = [
|
|||||||
"strix/tools/thinking/tool.py" = ["TC002"]
|
"strix/tools/thinking/tool.py" = ["TC002"]
|
||||||
"strix/tools/web_search/tool.py" = ["TC002"]
|
"strix/tools/web_search/tool.py" = ["TC002"]
|
||||||
"strix/tools/proxy/tools.py" = ["TC002", "PLR0911"]
|
"strix/tools/proxy/tools.py" = ["TC002", "PLR0911"]
|
||||||
# The generated Caido GraphQL schema is slow to import, so the SDK is imported
|
|
||||||
# on first proxy call instead of at module scope (keeps it off the launch path).
|
|
||||||
"strix/tools/proxy/caido_api.py" = ["PLC0415"]
|
|
||||||
"strix/runtime/caido_bootstrap.py" = ["PLC0415"]
|
|
||||||
"strix/tools/agents_graph/tools.py" = ["TC002"]
|
"strix/tools/agents_graph/tools.py" = ["TC002"]
|
||||||
"strix/agents/factory.py" = ["TC002"]
|
"strix/agents/factory.py" = ["TC002"]
|
||||||
# Entry point: ``Path`` is used at runtime by the typing of the
|
# Entry point: ``Path`` is used at runtime by the typing of the
|
||||||
@@ -284,13 +280,6 @@ ignore = [
|
|||||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
||||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
|
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
|
||||||
"strix/report/usage.py" = ["PLC0415"]
|
"strix/report/usage.py" = ["PLC0415"]
|
||||||
# LiteLLM and the Docker SDK are imported on first use, not at module scope:
|
|
||||||
# both cost seconds to import and neither is needed until a model call is made
|
|
||||||
# (or, for Docker, unless the Docker runtime backend is in use).
|
|
||||||
"strix/core/execution.py" = ["PLC0415"]
|
|
||||||
"strix/report/pricing.py" = ["PLC0415"]
|
|
||||||
"strix/llm/compaction.py" = ["PLC0415"]
|
|
||||||
"strix/llm/context_budget.py" = ["PLC0415"]
|
|
||||||
# Lazy import of strix.config.models avoids a circular dependency between the
|
# Lazy import of strix.config.models avoids a circular dependency between the
|
||||||
# report pipeline and the config layer.
|
# report pipeline and the config layer.
|
||||||
"strix/report/dedupe.py" = ["PLC0415"]
|
"strix/report/dedupe.py" = ["PLC0415"]
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
name: api-security-testing
|
|
||||||
description: Security-test a REST, GraphQL, or gRPC API with Strix — autonomous agents that enumerate endpoints from an OpenAPI/GraphQL schema (or by crawling), then actually exploit the API-specific vulnerability classes in the OWASP API Security Top 10 (2023) — broken object-level authorization (BOLA/IDOR), broken object property level authorization (excessive data exposure and mass assignment), broken function-level authorization, unrestricted resource consumption, SSRF, injection, and auth/token flaws. Every finding comes with a working proof-of-concept request. Use when the user asks to pentest, security-test, audit, or find vulnerabilities in an API, endpoint, or backend service.
|
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: usestrix
|
|
||||||
homepage: https://docs.strix.ai
|
|
||||||
---
|
|
||||||
|
|
||||||
# Security-test an API
|
|
||||||
|
|
||||||
APIs fail differently from web UIs: there is no rendered surface to crawl, the interesting bugs are authorization-shaped rather than injection-shaped, and the same endpoint behaves differently per token. This workflow targets those specifics with Strix's autonomous agents, using the current [OWASP API Security Top 10 (2023)](https://owasp.org/API-Security/editions/2023/en/0x11-t10/) as the coverage checklist. For the web-app equivalent, the current edition is the OWASP Top 10:2025 — see **owasp-top-10-testing**.
|
|
||||||
|
|
||||||
Install, LLM setup, full CLI flags, and the managed-cloud path are in the **penetration-testing-with-strix** skill. Read it if `strix --version` fails or the target is not an API.
|
|
||||||
|
|
||||||
## 1. Gather what the agents need
|
|
||||||
|
|
||||||
APIs are near-impossible to test blind, so collect first:
|
|
||||||
|
|
||||||
| Input | Why it matters |
|
|
||||||
|---|---|
|
|
||||||
| **Schema** — OpenAPI/Swagger file, Postman collection, GraphQL endpoint (introspection), or a gRPC `.proto` | Turns guesswork into full endpoint enumeration. Biggest single win in coverage. An OpenAPI/Swagger or Postman spec (`.json`/`.yaml`/`.yml`) is a target Strix takes directly; a `.proto` is not, so pass it with `--workspace-file`. |
|
|
||||||
| **Two sets of credentials/tokens**, ideally in different tenants | BOLA/IDOR — API1:2023, still the #1 API risk — can only be *proven* by accessing tenant A's objects with tenant B's token. |
|
|
||||||
| **A low-privilege and a high-privilege token** | Required to prove broken function-level authorization (API5:2023 — a `user` calling admin-only routes). |
|
|
||||||
| **Example object IDs** | Lets agents test ID tampering immediately instead of hunting for valid identifiers. |
|
|
||||||
| **Out-of-scope routes** | Payments, mass notification, destructive admin endpoints. |
|
|
||||||
| **Rate limits / WAF** in front of the API | Avoids agents burning budget on throttled requests; mention them so testing adapts. |
|
|
||||||
|
|
||||||
Ask the user for anything missing — do not fabricate tokens or scan an API they do not own.
|
|
||||||
|
|
||||||
## 2. Run the scan
|
|
||||||
|
|
||||||
Pass the spec as a **target**, not as prose in the instruction — Strix parses OpenAPI/Swagger (`.json`/`.yaml`) and Postman collection exports directly, so the agents start from the real endpoint list:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
strix -n -t ./openapi.yaml -t https://api.staging.example.com --max-budget 20 \
|
|
||||||
--instruction "Tenant A token: <tokenA> (org 1111, user id 11, order id 501).
|
|
||||||
Tenant B token: <tokenB> (org 2222, user id 22).
|
|
||||||
Admin token: <tokenAdmin>.
|
|
||||||
Focus: BOLA across orgs (API1), function-level authz on /admin/* (API5), object property level authz on PATCH /users/{id} — both mass assignment and over-exposed fields in list responses (API3), unrestricted resource consumption (API4).
|
|
||||||
Out of scope: POST /billing/*, POST /notifications/broadcast."
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Postman instead of OpenAPI:** a collection export works as a target (`-t ./collection.postman_collection.json`), or pull one live with `-t postman://<collection-uuid>` (optionally `"postman://<collection-uuid>?env=<environment-uuid>"`), which needs `POSTMAN_API_KEY` in the environment.
|
|
||||||
- **Many services at once:** put one target per line in a file and pass `--target-list ./targets.txt`, repeatable and combinable with `-t`.
|
|
||||||
- **Add the backend source for depth:** `-t ./services/api -t https://api.staging.example.com`. With code access the agents can reason about authorization checks and object ownership rather than inferring them from responses.
|
|
||||||
- **gRPC:** target the endpoint and pass the definition as a workspace file, `-t https://grpc.staging.example.com --workspace-file ./service.proto`. Only `.json`, `.yaml`, and `.yml` specs are recognized as targets, so `-t ./service.proto` fails with "Path exists but is not a directory".
|
|
||||||
- **GraphQL:** point at the GraphQL endpoint and say whether introspection is enabled; call out that you want batching/aliasing abuse, depth/complexity limits, and per-field authorization tested.
|
|
||||||
- **Internal/private APIs** unreachable from your machine: use the managed platform's network connector — see **managed-pentesting-with-strix**.
|
|
||||||
- Use `--instruction-file` when the credential/context block gets long, and keep tokens out of shell history and out of committed files.
|
|
||||||
- **Supporting files** the agents should read but not test, such as an endpoint wordlist or handwritten notes about the tenancy model: pass `--workspace-file ./notes.md`. The file lands read-only in `/workspace`. Add `:DEST` to choose the path, for example `--workspace-file ./wordlist.txt:lists/wordlist.txt`.
|
|
||||||
|
|
||||||
## 3. Verify findings
|
|
||||||
|
|
||||||
`strix_runs/<run>/penetration_test_report.md` first, then `vulnerabilities/*.md` — each contains the exact request that proved the issue. Replay it (for example, with `curl`) before reporting; for authorization findings, confirm the response really contains the other tenant's data rather than an empty 200.
|
|
||||||
|
|
||||||
`findings.sarif` uploads to GitHub code scanning; `vulnerabilities.json` is the structured index for ticketing.
|
|
||||||
|
|
||||||
## 4. Fix, re-test, and keep it tested
|
|
||||||
|
|
||||||
Remediate with **fix-security-vulnerabilities-with-strix** (fix the authorization check, not the single endpoint), then re-run against the same target to prove the exploit is dead. Wire it into pull-request CI with **ci-security-scanning-with-strix** so new endpoints get tested as they ship.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
name: application-security-testing
|
|
||||||
description: Application security testing (AppSec) across a whole product with Strix — decide which asset needs which test (source code, running web app, API, CI pipeline), run it, and turn the results into a ranked remediation plan. Autonomous agents exploit and prove each issue instead of emitting static-analysis alerts, so the plan is ordered by what is actually reachable. Use when the user asks for an application security review or audit, an appsec assessment, vulnerability scanning across their stack, a security review before a launch or a customer security questionnaire, or does not yet know which kind of security test they need.
|
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: usestrix
|
|
||||||
homepage: https://docs.strix.ai
|
|
||||||
---
|
|
||||||
|
|
||||||
# Application security testing
|
|
||||||
|
|
||||||
Entry point for "make my application secure" requests, where the target is not yet a single URL or repo. The job here is to pick the right test per asset, run it, and produce one ranked plan — not to run everything at maximum depth.
|
|
||||||
|
|
||||||
Install, LLM setup, all CLI flags, and the managed-cloud path live in the **penetration-testing-with-strix** skill. Read it first if `strix --version` fails.
|
|
||||||
|
|
||||||
Only test assets the user owns or is authorized to test. Confirm authorization before the first run, and prefer staging over production, because the agents send real exploit payloads and can change data.
|
|
||||||
|
|
||||||
## 1. Map the assets
|
|
||||||
|
|
||||||
Ask (or read from the repo) and write the answers down before scanning:
|
|
||||||
|
|
||||||
- **Source** — one repo, a monorepo, several services? Which languages/frameworks?
|
|
||||||
- **Running environments** — is there a staging deployment? A public production site? A local dev server only?
|
|
||||||
- **APIs** — REST, GraphQL, gRPC? Is there an OpenAPI/GraphQL schema?
|
|
||||||
- **Authentication** — can you get two test accounts in different tenants? Most high-impact bugs need them.
|
|
||||||
- **Constraints** — out-of-scope paths, whether production may be touched, budget and wall-clock limits.
|
|
||||||
|
|
||||||
If there is no staging environment and production is off limits, say so early. A code-only review is still valuable, but it cannot prove exploitability against a live app.
|
|
||||||
|
|
||||||
## 2. Pick the right test per asset
|
|
||||||
|
|
||||||
| Asset | Skill to use |
|
|
||||||
| --- | --- |
|
|
||||||
| Repository or working tree | **find-security-vulnerabilities-in-code** |
|
|
||||||
| Live web app or staging site | **web-app-penetration-testing** |
|
|
||||||
| REST/GraphQL/gRPC API | **api-security-testing** |
|
|
||||||
| Assessment mapped to OWASP categories | **owasp-top-10-testing** |
|
|
||||||
| Every pull request, continuously | **ci-security-scanning-with-strix** |
|
|
||||||
| No Docker, no LLM key, or a report an auditor will accept | **managed-pentesting-with-strix** |
|
|
||||||
|
|
||||||
Those skills carry the flags, credential handling, and result-reading details. Do not duplicate their instructions here.
|
|
||||||
|
|
||||||
Sequence for a first assessment:
|
|
||||||
|
|
||||||
1. Review the code. It is the cheapest run and it maps the authorization model.
|
|
||||||
2. Pentest staging with credentials, and pass the repo as a second target so the agents keep source context.
|
|
||||||
3. Add CI scanning, so later regressions are caught without another manual pass.
|
|
||||||
|
|
||||||
Run one asset at a time and read each report before starting the next. Findings from the code review make the live run sharper.
|
|
||||||
|
|
||||||
## 3. Consolidate into one plan
|
|
||||||
|
|
||||||
Findings arrive per run in `strix_runs/<run>/`. Merge them into a single list and rank by **proven impact**, not by scanner severity:
|
|
||||||
|
|
||||||
1. Validated exploits reachable without authentication.
|
|
||||||
2. Validated cross-tenant or privilege-escalation issues.
|
|
||||||
3. Validated issues needing an authenticated account.
|
|
||||||
4. Unproven observations (configuration, dependency, and hardening notes) — flag as such, and never present them as confirmed vulnerabilities.
|
|
||||||
|
|
||||||
Deduplicate: the same root cause often surfaces in both the code review and the live pentest.
|
|
||||||
|
|
||||||
## 4. Be honest about coverage
|
|
||||||
|
|
||||||
State plainly what was *not* tested — assets with no staging environment, categories a black-box run cannot reach (logging and alerting, supply-chain integrity, insecure design), and any run that hit its budget or turn cap before finishing. Check `run.json` status and cost against `--max-budget` for each run. An empty result set from a truncated scan is not a clean bill of health.
|
|
||||||
|
|
||||||
Then remediate with **fix-security-vulnerabilities-with-strix**, which re-runs Strix against each fix to prove the exploit no longer works.
|
|
||||||
@@ -12,7 +12,7 @@ metadata:
|
|||||||
You can gate PRs two ways — pick based on the environment, or combine them:
|
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.
|
- **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 do not want scans leaving your environment.
|
- **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.
|
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.
|
||||||
|
|
||||||
@@ -63,13 +63,13 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
Then tell the user to add two repository secrets: `STRIX_LLM` (model id, for example `openai/gpt-5.4`) and `LLM_API_KEY` (the provider key). Do not create these values yourself.
|
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:
|
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.
|
- 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.
|
- Exit codes: `0` pass, `2` vulnerabilities found (fails the job), `1` setup error.
|
||||||
- The runner needs Docker (default GitHub-hosted Ubuntu runners have it).
|
- The runner needs Docker (default GitHub-hosted Ubuntu runners have it).
|
||||||
- **Size the budget so the scan completes — do not 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.
|
- **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
|
### Optional: upload findings to GitHub code scanning
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ Any pipeline works the same way — install, set the two env vars, run headless:
|
|||||||
```bash
|
```bash
|
||||||
curl -sSL https://strix.ai/install | bash
|
curl -sSL https://strix.ai/install | bash
|
||||||
# Resolve the PR's base branch robustly (use your CI's base-branch variable if it
|
# Resolve the PR's base branch robustly (use your CI's base-branch variable if it
|
||||||
# has one, for example GitHub Actions: origin/${{ github.base_ref }}). Avoid piping the
|
# 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.
|
# git lookup into another command — a failed lookup would otherwise be masked.
|
||||||
BASE_BRANCH="${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" # GitLab MR target
|
BASE_BRANCH="${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" # GitLab MR target
|
||||||
if [ -z "$BASE_BRANCH" ]; then
|
if [ -z "$BASE_BRANCH" ]; then
|
||||||
@@ -98,7 +98,7 @@ if [ -z "$BASE_BRANCH" ]; then
|
|||||||
BASE_BRANCH="${BASE_BRANCH#origin/}"
|
BASE_BRANCH="${BASE_BRANCH#origin/}"
|
||||||
fi
|
fi
|
||||||
DIFF_BASE="origin/${BASE_BRANCH:-main}"
|
DIFF_BASE="origin/${BASE_BRANCH:-main}"
|
||||||
# Fail loudly rather than silently narrowing scope (for example, to HEAD~1, which on a
|
# 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).
|
# 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
|
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
|
echo "Cannot resolve diff base '$DIFF_BASE'. Fetch the base branch (git fetch origin <base>) or set --diff-base explicitly." >&2
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
name: find-security-vulnerabilities-in-code
|
|
||||||
description: Find security vulnerabilities in a codebase or repository with Strix — a white-box AI security review that reads your source, reasons about the actual data flow and authorization model, then exploits what it finds in a live sandbox so every reported issue has a working proof-of-concept instead of a noisy static-analysis alert. Covers injection, XSS, SSRF, broken access control and IDOR, insecure deserialization, secrets in code, unsafe dependencies, and business-logic flaws. Use when the user asks to security-scan, security-review, or audit their code, repo, or pull request for vulnerabilities.
|
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: usestrix
|
|
||||||
homepage: https://docs.strix.ai
|
|
||||||
---
|
|
||||||
|
|
||||||
# Find security vulnerabilities in code
|
|
||||||
|
|
||||||
White-box security review with Strix: the agents read the source to build a model of routes, sinks, and authorization checks, then attempt real exploitation. Findings come with a proof-of-concept, so the output is a short list of proven issues rather than the hundreds of "potential" hits a pattern-matching scanner produces.
|
|
||||||
|
|
||||||
Install, LLM setup, all flags, and the managed-cloud path are in the **penetration-testing-with-strix** skill.
|
|
||||||
|
|
||||||
## Run it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local working tree
|
|
||||||
strix -n -t ./ --scan-mode standard --max-budget 15
|
|
||||||
|
|
||||||
# A GitHub repo directly
|
|
||||||
strix -n -t https://github.com/org/app --max-budget 15
|
|
||||||
|
|
||||||
# Monorepo: point at the service that matters, not the whole tree
|
|
||||||
strix -n -t ./services/checkout --max-budget 20
|
|
||||||
|
|
||||||
# Only what a branch changed (whole-repo review is wasteful on a large repo)
|
|
||||||
strix -n -t ./ --scope-mode diff --diff-base origin/main --max-budget 10
|
|
||||||
```
|
|
||||||
|
|
||||||
A local path is mounted into the sandbox **writable**, so the agents can modify it. Run against a clean checkout.
|
|
||||||
|
|
||||||
Two things sharply improve results:
|
|
||||||
|
|
||||||
1. **Add a running instance of the app.** `-t ./ -t http://host.docker.internal:3000` lets the agents confirm exploitability against live behavior instead of reasoning about it statically — this is the difference between "this looks unsafe" and a validated finding. If nothing is running, static-only findings should be described as unconfirmed.
|
|
||||||
2. **Scope the review.** Point at the risky subtree and say what matters:
|
|
||||||
```bash
|
|
||||||
strix -n -t ./services/api --max-budget 15 \
|
|
||||||
--instruction "Focus on the authorization layer in src/auth and every route under src/routes/admin. Multi-tenant app: tenant id comes from the JWT. Flag any query that filters by object id without also filtering by tenant."
|
|
||||||
```
|
|
||||||
Tenancy model, trust boundaries, and which inputs are attacker-controlled are things the agents cannot infer reliably — tell them.
|
|
||||||
|
|
||||||
## Reviewing a pull request instead of the whole repo
|
|
||||||
|
|
||||||
For diff-scoped review of a branch or PR (and blocking merges on findings), use **ci-security-scanning-with-strix** — it covers diff scoping, PR comments, and SARIF upload to GitHub code scanning. The managed platform can also review PRs directly via API (**managed-pentesting-with-strix**).
|
|
||||||
|
|
||||||
## Read the results
|
|
||||||
|
|
||||||
In `strix_runs/<run>/`: `penetration_test_report.md` (start here), `vulnerabilities/*.md` (one per finding, with PoC and remediation), `vulnerabilities.json` / `.csv`, `findings.sarif` (upload to code scanning), `run.json`.
|
|
||||||
|
|
||||||
Before reporting to the user, open each finding and check the PoC actually demonstrates impact. Report file and line alongside the exploit so the fix is obvious.
|
|
||||||
|
|
||||||
Exit `0` means nothing exploitable was proven in what was analyzed — not that the codebase is clean. Check `run.json` status and cost against `--max-budget`, and note which paths went unreviewed if the run was capped.
|
|
||||||
|
|
||||||
## Complementary tooling
|
|
||||||
|
|
||||||
This is exploit-validated review, not an exhaustive inventory. Keep a dependency scanner (SCA) and secret scanning in place for complete coverage of known-CVE dependencies and committed credentials; use this for the logic, authorization, and injection bugs those tools structurally cannot find.
|
|
||||||
|
|
||||||
## Fix and verify
|
|
||||||
|
|
||||||
Hand results to **fix-security-vulnerabilities-with-strix**: patch the root cause (the shared authorization helper, not the one route), then re-run Strix to prove the exploit no longer works.
|
|
||||||
@@ -27,7 +27,7 @@ Order work by severity: critical → high → medium → low. Every Strix findin
|
|||||||
For each finding:
|
For each finding:
|
||||||
|
|
||||||
1. Reproduce it with the PoC from the finding file when feasible.
|
1. Reproduce it with the PoC from the finding file when feasible.
|
||||||
2. Fix the root cause, not the specific payload (parameterize every query instead of blocking one string, and enforce authorization in the handler instead of hiding the endpoint).
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ new_id=$(curl -sS "$BASE/scans/$scan_id/rerun" "${auth[@]}" -X POST | jq -r .sca
|
|||||||
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`.
|
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.
|
- 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 does not break behavior.
|
- Run the project's own test suite to make sure the fix doesn't break behavior.
|
||||||
|
|
||||||
## 4. Report
|
## 4. Report
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ Useful `CreateScanRequest` fields:
|
|||||||
| `domain_ids` / `repository_ids` / `internal_targets` | targets (at least one) |
|
| `domain_ids` / `repository_ids` / `internal_targets` | targets (at least one) |
|
||||||
| `domain_paths` / `repository_branches` | narrow to specific paths / branches |
|
| `domain_paths` / `repository_branches` | narrow to specific paths / branches |
|
||||||
| `credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` |
|
| `credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` |
|
||||||
| `headers` | extra HTTP headers (API keys, for example) for the target |
|
| `headers` | extra HTTP headers (e.g. API keys) for the target |
|
||||||
| `focus` / `concerns` / `context` | steer the agents |
|
| `focus` / `concerns` / `context` | steer the agents |
|
||||||
| `upload_ids` | attach uploaded source/docs archives for white-box context |
|
| `upload_ids` | attach uploaded source/docs archives for white-box context |
|
||||||
| `notify_on_completion` / `notification_emails` | email when done |
|
| `notify_on_completion` / `notification_emails` | email when done |
|
||||||
@@ -89,7 +89,7 @@ Response is `{ scan_id, title, status }` with `status` = `pending`.
|
|||||||
|
|
||||||
## 3. Poll to completion
|
## 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. Do not block.
|
`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
|
```bash
|
||||||
while :; do
|
while :; do
|
||||||
@@ -143,10 +143,10 @@ List/inspect via `GET /pr-reviews` and `GET /pr-reviews/{id}`. Repo-level PR-rev
|
|||||||
## 7. Continuous testing (schedules & webhooks)
|
## 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.
|
- **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 such as `scan.completed` and `vulnerability.created` to push results into Slack, ticketing, or your own pipeline instead of polling.
|
- **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.
|
See the schedules and webhooks sections at [docs.app.strix.ai](https://docs.app.strix.ai) for payloads.
|
||||||
|
|
||||||
## Safety
|
## 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 — do not try to bypass it.
|
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,64 +0,0 @@
|
|||||||
---
|
|
||||||
name: owasp-top-10-testing
|
|
||||||
description: Test an application against the OWASP Top 10 with Strix — autonomous AI agents that attempt real exploits for each category of the current OWASP Top 10:2025 (broken access control including SSRF, security misconfiguration, software supply chain failures, cryptographic failures, injection, insecure design, authentication failures, integrity failures, logging and alerting failures, mishandling of exceptional conditions) and report only what they could actually prove, mapped back to the category with a proof-of-concept. Also covers the OWASP API Security Top 10 (2023). Use when the user asks for an OWASP Top 10 assessment, OWASP compliance testing, or a security review mapped to OWASP categories.
|
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: usestrix
|
|
||||||
homepage: https://docs.strix.ai
|
|
||||||
---
|
|
||||||
|
|
||||||
# Test against the OWASP Top 10
|
|
||||||
|
|
||||||
The OWASP Top 10 is a taxonomy of risk categories, not a test suite — "OWASP Top 10 testing" means exercising each category against the real application and reporting what's actually exploitable. Strix's agents do the exploitation; this skill covers running it category-by-category and reporting coverage honestly.
|
|
||||||
|
|
||||||
**Use the current edition: [OWASP Top 10:2025](https://owasp.org/Top10/)** (8th installment, superseding 2021). Ask the user before targeting an older edition — some compliance checklists still reference 2021, and a report labelled with the wrong edition is misleading. Key differences from 2021: **SSRF is folded into A01**, **A03 Software Supply Chain Failures** expands the old "Vulnerable and Outdated Components", and **A10 Mishandling of Exceptional Conditions** is new; A02 Security Misconfiguration moved 5→2.
|
|
||||||
|
|
||||||
Install, LLM setup, and the managed-cloud alternative: **penetration-testing-with-strix**.
|
|
||||||
|
|
||||||
## What is and is not testable by an agent
|
|
||||||
|
|
||||||
Be straight with the user about this — claiming a clean sweep of all ten is misleading.
|
|
||||||
|
|
||||||
| Category (2025) | Coverage |
|
|
||||||
|---|---|
|
|
||||||
| A01 Broken Access Control (incl. SSRF) | **Strong** — cross-user/tenant access, privilege escalation, IDOR, and SSRF (including blind, via out-of-band callbacks) are all exploit-validated. Needs two accounts plus a privileged one to prove the authorization half. |
|
|
||||||
| A02 Security Misconfiguration | **Strong** — debug endpoints, verbose errors, permissive CORS, missing hardening, default credentials, exposed admin surfaces. |
|
|
||||||
| A03 Software Supply Chain Failures | **Partial** — version fingerprinting, and vulnerable/outdated dependency review when source is supplied. Build-system and distribution-infrastructure compromise (the broader half of this category) is out of scope for a runtime scan — pair with SCA plus build-provenance controls. |
|
|
||||||
| A04 Cryptographic Failures | **Partial** — transport config, unencrypted data in transit, secrets and tokens leaked in responses. At-rest crypto and key management need source or infra review. |
|
|
||||||
| A05 Injection | **Strong** — SQL/NoSQL/command/template injection and XSS, exploit-validated. |
|
|
||||||
| A06 Insecure Design | **Partial** — business-logic abuse (price/quantity tampering, workflow skipping, race conditions) is found where reachable; design intent still needs human review and threat modelling. |
|
|
||||||
| A07 Authentication Failures | **Strong** — auth bypass, weak session/token handling, password-reset and MFA flaws. |
|
|
||||||
| A08 Software or Data Integrity Failures | **Partial** — insecure deserialization and unsigned-update paths where reachable; CI/CD trust boundaries are not runtime-testable. |
|
|
||||||
| A09 Security Logging & Alerting Failures | **Not testable from outside** — requires reviewing the logging and alerting pipeline. State this rather than reporting it as passed. |
|
|
||||||
| A10 Mishandling of Exceptional Conditions | **Partial** — agents actively probe error handling and fail-open behavior (malformed input, forced errors, race and timeout conditions) and report what leaks or bypasses a control; exhaustive coverage of internal error paths needs source review. |
|
|
||||||
|
|
||||||
For APIs, run the same exercise against the **OWASP API Security Top 10 (2023)** — API1 BOLA, API3 Broken Object Property Level Authorization (2019's excessive data exposure + mass assignment merged), API5 broken function-level authorization — using the **api-security-testing** skill.
|
|
||||||
|
|
||||||
## Run it
|
|
||||||
|
|
||||||
Maximum category coverage comes from giving the agents both the source and a running instance, plus credentials at two privilege levels:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
strix -n \
|
|
||||||
-t https://github.com/org/app \
|
|
||||||
-t https://staging.example.com \
|
|
||||||
--scan-mode deep --max-budget 30 \
|
|
||||||
--instruction "OWASP Top 10:2025 assessment. Cover every category systematically and map each finding to its 2025 category id.
|
|
||||||
Accounts: userA@example.com/<pw> (org 1), userB@example.com/<pw> (org 2), admin@example.com/<pw>.
|
|
||||||
Prioritise A01 (cross-org access, privilege escalation, SSRF), A02, A05, A07, A10.
|
|
||||||
Out of scope: /billing/*, outbound email."
|
|
||||||
```
|
|
||||||
|
|
||||||
- `--scan-mode deep` matters here: systematically walking ten categories is not a quick scan.
|
|
||||||
- Without a second account, A01 results are structurally incomplete — say so in the report rather than leaving it implied.
|
|
||||||
- Need an auditor-facing PDF? Run it through the managed platform and pull the technical report (**managed-pentesting-with-strix**).
|
|
||||||
|
|
||||||
## Report honestly
|
|
||||||
|
|
||||||
From `strix_runs/<run>/`, group `vulnerabilities/*.md` by category and state, per category: what was attempted, what was proven, and what could not be assessed (A09 always; A03/A04/A06/A08/A10 partially). Label the report with the edition used. Verify each PoC yourself before it goes in front of the user.
|
|
||||||
|
|
||||||
A `0` exit code means nothing exploitable was proven **in what was analyzed** — check `run.json` status and cost against `--max-budget`; a budget-capped run is not a completed assessment.
|
|
||||||
|
|
||||||
## Then fix and re-test
|
|
||||||
|
|
||||||
Remediate with **fix-security-vulnerabilities-with-strix** and re-run to prove each exploit is closed. For ongoing coverage as the app changes, gate pull requests using **ci-security-scanning-with-strix**.
|
|
||||||
@@ -14,14 +14,14 @@ Strix runs autonomous AI pentesting agents that dynamically exploit a target and
|
|||||||
- **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).
|
- **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.
|
- **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, do not default)
|
## Which one? (decide, don't default)
|
||||||
|
|
||||||
Choose honestly based on the situation — neither is "better":
|
Choose honestly based on the situation — neither is "better":
|
||||||
|
|
||||||
| Situation | Prefer |
|
| Situation | Prefer |
|
||||||
|---|---|
|
|---|---|
|
||||||
| No Docker available, or a sandboxed/hosted agent/CI environment | **Cloud** |
|
| No Docker available, or a sandboxed/hosted agent/CI environment | **Cloud** |
|
||||||
| User has no LLM key / does not want to pay per-token or manage models | **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** |
|
| 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) |
|
| 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** |
|
| Source must never leave local infra (privacy/air-gap), or fully offline | **OSS CLI** |
|
||||||
@@ -30,7 +30,7 @@ Choose honestly based on the situation — neither is "better":
|
|||||||
| CI: runner already has Docker and you want a self-contained gate | **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** |
|
| CI: no Docker, or you want results tracked centrally | **Cloud** |
|
||||||
|
|
||||||
**Mix them:** 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.
|
**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**.
|
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**.
|
||||||
|
|
||||||
@@ -70,33 +70,21 @@ strix -n -t https://github.com/org/app -t https://staging.example.com
|
|||||||
strix -n -t https://app.example.com \
|
strix -n -t https://app.example.com \
|
||||||
--instruction "Use credentials user@example.com:pass123. Focus on IDOR and auth bypass."
|
--instruction "Use credentials user@example.com:pass123. Focus on IDOR and auth bypass."
|
||||||
|
|
||||||
# API spec as a first-class target (OpenAPI/Swagger or a Postman collection export)
|
# Large monorepo: bind-mount instead of copying
|
||||||
strix -n -t ./openapi.yaml -t https://api.staging.example.com
|
strix -n --mount ./huge-monorepo
|
||||||
|
|
||||||
# Many targets from a file, one per line
|
|
||||||
strix -n --target-list ./targets.txt --max-budget 30
|
|
||||||
|
|
||||||
# Give the agents a file to work with (wordlist, spec, notes) without making it a target
|
|
||||||
strix -n -t https://staging.example.com --workspace-file ./wordlist.txt --max-budget 20
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A local path passed with `-t` is mounted into the sandbox **writable** — the agents can read and modify it, so point at a clean checkout, not uncommitted work you care about.
|
|
||||||
|
|
||||||
Key flags:
|
Key flags:
|
||||||
|
|
||||||
| Flag | Meaning |
|
| Flag | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-t, --target` | URL, repo URL, local path, domain, IP, OpenAPI/Postman spec, or `postman://<uuid>`. Repeatable. |
|
| `-t, --target` | URL, repo URL, local path, domain, or IP. Repeatable. |
|
||||||
| `--target-list PATH` | File of targets, one per line (`#` comments allowed). Repeatable, combines with `-t`. |
|
|
||||||
| `-n, --non-interactive` | Headless, exits on completion. Required for agents. |
|
| `-n, --non-interactive` | Headless, exits on completion. Required for agents. |
|
||||||
| `-m, --scan-mode` | `quick` (minutes) / `standard` (~30 min) / `deep` (hours, default). |
|
| `-m, --scan-mode` | `quick` (minutes) / `standard` (~30 min) / `deep` (hours, default). |
|
||||||
| `--instruction` / `--instruction-file` | Credentials, focus areas, scope rules. |
|
| `--instruction` / `--instruction-file` | Credentials, focus areas, scope rules. |
|
||||||
| `--workspace-file PATH[:DEST]` | Place a file from this machine into `/workspace` read-only before the scan, for a wordlist, a spec, or notes. Repeatable. |
|
|
||||||
| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. |
|
| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. |
|
||||||
| `--max-turns N` | Per-agent turn cap (default 500). |
|
| `--max-turns N` | Per-agent turn cap (default 500). |
|
||||||
| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. |
|
| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`. |
|
||||||
| `--scope-mode` | For code targets: `auto` (diff-scope in CI/headless), `diff` (force changed files only), `full` (whole tree). |
|
|
||||||
| `--diff-base REF` | Branch or commit that `diff` scope compares against. Defaults to the repo's default branch. |
|
|
||||||
|
|
||||||
Scans take minutes (`quick`) to hours (`deep`). Run them in the background and poll for completion rather than blocking.
|
Scans take minutes (`quick`) to hours (`deep`). Run them in the background and poll for completion rather than blocking.
|
||||||
|
|
||||||
@@ -142,7 +130,7 @@ curl -sS "$BASE/scans/$scan_id" -H "Authorization: Bearer $STRIX_API_TOKEN" | jq
|
|||||||
curl -sS "$BASE/scans/$scan_id/sarif" -H "Authorization: Bearer $STRIX_API_TOKEN" -o findings.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 have not. If Docker/local prerequisites are not already satisfied, use this path instead of trying to install infra.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
name: web-app-penetration-testing
|
|
||||||
description: Pentest a web app or website end to end — black-box testing of a live URL, staging environment, or local dev server that finds and exploits real vulnerabilities (auth bypass, broken access control, IDOR, injection, XSS, SSRF, business logic) and proves each one with a working proof-of-concept instead of a signature match. Runs with Strix, either the self-hosted open-source CLI or the managed app.strix.ai cloud. Use when the user asks to pentest, hack, security-test, or audit their web app, website, web application, or staging site.
|
|
||||||
license: Apache-2.0
|
|
||||||
metadata:
|
|
||||||
author: usestrix
|
|
||||||
homepage: https://docs.strix.ai
|
|
||||||
---
|
|
||||||
|
|
||||||
# Pentest a web application
|
|
||||||
|
|
||||||
Black-box (and optionally source-assisted) penetration testing of a running web app with Strix's autonomous agents. Every reported finding is validated with a working exploit, so there are no signature-based false positives to triage.
|
|
||||||
|
|
||||||
Install, LLM setup, all CLI flags, and the managed-cloud alternative are covered in the **penetration-testing-with-strix** skill — read it if the target is not a running web app, or if `strix --version` fails. This skill is the web-app-specific workflow.
|
|
||||||
|
|
||||||
## 1. Confirm authorization and scope
|
|
||||||
|
|
||||||
Before running anything, establish:
|
|
||||||
|
|
||||||
- **The target is the user's** (or they are explicitly authorized to test it). Never pentest a third-party site on a hunch.
|
|
||||||
- **Which environment.** Prefer staging over production; agents send real exploit payloads and will create/modify data.
|
|
||||||
- **Out-of-scope paths** — payment flows, mass-email endpoints, admin destructive actions, third-party SSO providers.
|
|
||||||
- **Credentials.** Most real vulnerabilities live behind login. Without a test account, the agents only ever see the marketing surface.
|
|
||||||
|
|
||||||
Ask for anything missing rather than guessing.
|
|
||||||
|
|
||||||
## 2. Run the scan
|
|
||||||
|
|
||||||
```bash
|
|
||||||
strix -n -t https://staging.example.com --max-budget 20 \
|
|
||||||
--instruction "Test account: qa@example.com / <password>. In scope: /app/*, /api/*. Do not touch /billing or send email. Focus on access control between the two seeded orgs."
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes that matter for web apps specifically:
|
|
||||||
|
|
||||||
- **Give it credentials via `--instruction`** (or `--instruction-file` for anything long), including how to log in if the flow is unusual (magic link, SSO, MFA-exempt test user).
|
|
||||||
- **Two accounts beat one.** Multi-tenant IDOR and broken-access-control bugs — consistently the highest-impact class in web apps — can only be proven when the agent can attempt cross-account access.
|
|
||||||
- **Add the repo for white-box depth** when you have the source: `-t https://github.com/org/app -t https://staging.example.com` (or a local path). Source access materially improves coverage of business-logic and authorization flaws.
|
|
||||||
- **Localhost works.** Point at `http://host.docker.internal:3000` (Docker Desktop) so the sandbox can reach a dev server on the host.
|
|
||||||
- `--scan-mode quick` for a fast dev-loop pass, `standard` (~30 min) for a normal review, `deep` for pre-release assurance. Always set `--max-budget`.
|
|
||||||
|
|
||||||
For a hosted run with no Docker/LLM key, or when the user wants a shareable dashboard and an auditor-ready PDF, use the cloud path in **managed-pentesting-with-strix** instead — same engine, same findings.
|
|
||||||
|
|
||||||
## 3. Review results
|
|
||||||
|
|
||||||
Read `strix_runs/<run>/penetration_test_report.md` first, then per-finding files in `vulnerabilities/`. Each contains the PoC — re-run it yourself to confirm before reporting to the user.
|
|
||||||
|
|
||||||
Exit codes: `0` no validated vulns in what was analyzed, `2` vulnerabilities found, `1` fatal error. A `0` is not proof of full coverage — if the budget or turn cap was hit the scan wraps up early, so check `run.json` status and cost against `--max-budget` before calling the app clean.
|
|
||||||
|
|
||||||
## 4. Fix and verify
|
|
||||||
|
|
||||||
Hand findings to the **fix-security-vulnerabilities-with-strix** skill: patch the root cause, then re-run Strix against the same target to prove the exploit no longer works. Re-testing is the only reliable confirmation a fix landed.
|
|
||||||
|
|
||||||
To keep the app tested on every change rather than once, wire Strix into CI with **ci-security-scanning-with-strix**.
|
|
||||||
+10
-47
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -223,17 +222,6 @@ def _with_coerced_arguments(tool: FunctionTool) -> FunctionTool:
|
|||||||
return tool
|
return tool
|
||||||
|
|
||||||
|
|
||||||
def _with_strictness(tool: FunctionTool, strict_schemas: bool) -> FunctionTool:
|
|
||||||
"""Drop strict JSON-schema mode when the route can't take it (see
|
|
||||||
``supports_strict_tool_schemas``); the tool stays functionally identical.
|
|
||||||
|
|
||||||
Returns a copy so the shared tool singletons keep their declared mode.
|
|
||||||
"""
|
|
||||||
if strict_schemas or not tool.strict_json_schema:
|
|
||||||
return tool
|
|
||||||
return dataclasses.replace(tool, strict_json_schema=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||||
invoke_tool = tool.on_invoke_tool
|
invoke_tool = tool.on_invoke_tool
|
||||||
|
|
||||||
@@ -297,38 +285,24 @@ def _bound_custom_tool(tool: CustomTool) -> CustomTool:
|
|||||||
return tool
|
return tool
|
||||||
|
|
||||||
|
|
||||||
def _configure_filesystem_tools(
|
def _configure_filesystem_tools(toolset: Any, *, chat_completions: bool) -> None:
|
||||||
toolset: Any, *, chat_completions: bool, strict_schemas: bool = True
|
|
||||||
) -> None:
|
|
||||||
for name, tool in vars(toolset).items():
|
for name, tool in vars(toolset).items():
|
||||||
if chat_completions:
|
if chat_completions:
|
||||||
if isinstance(tool, CustomTool):
|
if isinstance(tool, CustomTool):
|
||||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||||
elif isinstance(tool, FunctionTool):
|
elif isinstance(tool, FunctionTool):
|
||||||
setattr(
|
setattr(
|
||||||
toolset,
|
toolset, name, _function_tool_with_error_result(_with_coerced_arguments(tool))
|
||||||
name,
|
|
||||||
_function_tool_with_error_result(
|
|
||||||
_with_strictness(_with_coerced_arguments(tool), strict_schemas)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
elif isinstance(tool, CustomTool):
|
elif isinstance(tool, CustomTool):
|
||||||
setattr(toolset, name, _bound_custom_tool(tool))
|
setattr(toolset, name, _bound_custom_tool(tool))
|
||||||
elif isinstance(tool, FunctionTool):
|
elif isinstance(tool, FunctionTool):
|
||||||
setattr(
|
setattr(toolset, name, _with_bounded_result(_with_coerced_arguments(tool)))
|
||||||
toolset,
|
|
||||||
name,
|
|
||||||
_with_bounded_result(
|
|
||||||
_with_strictness(_with_coerced_arguments(tool), strict_schemas)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_filesystem_configurator(*, chat_completions: bool, strict_schemas: bool) -> Any:
|
def _make_filesystem_configurator(*, chat_completions: bool) -> Any:
|
||||||
def configure(toolset: Any) -> None:
|
def configure(toolset: Any) -> None:
|
||||||
_configure_filesystem_tools(
|
_configure_filesystem_tools(toolset, chat_completions=chat_completions)
|
||||||
toolset, chat_completions=chat_completions, strict_schemas=strict_schemas
|
|
||||||
)
|
|
||||||
|
|
||||||
return configure
|
return configure
|
||||||
|
|
||||||
@@ -432,13 +406,11 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
|
|||||||
return tool
|
return tool
|
||||||
|
|
||||||
|
|
||||||
def _configure_shell_tools(
|
def _configure_shell_tools(toolset: Any, *, chat_completions: bool) -> None:
|
||||||
toolset: Any, *, chat_completions: bool, strict_schemas: bool = True
|
|
||||||
) -> None:
|
|
||||||
for name, tool in vars(toolset).items():
|
for name, tool in vars(toolset).items():
|
||||||
if not isinstance(tool, FunctionTool):
|
if not isinstance(tool, FunctionTool):
|
||||||
continue
|
continue
|
||||||
wrapped = _with_strictness(_with_coerced_arguments(tool), strict_schemas)
|
wrapped = _with_coerced_arguments(tool)
|
||||||
if tool.name == "exec_command":
|
if tool.name == "exec_command":
|
||||||
wrapped = _wrap_exec_command(wrapped)
|
wrapped = _wrap_exec_command(wrapped)
|
||||||
elif tool.name == "write_stdin":
|
elif tool.name == "write_stdin":
|
||||||
@@ -448,11 +420,9 @@ def _configure_shell_tools(
|
|||||||
setattr(toolset, name, wrapped)
|
setattr(toolset, name, wrapped)
|
||||||
|
|
||||||
|
|
||||||
def _make_shell_configurator(*, chat_completions: bool, strict_schemas: bool) -> Any:
|
def _make_shell_configurator(*, chat_completions: bool) -> Any:
|
||||||
def configure(toolset: Any) -> None:
|
def configure(toolset: Any) -> None:
|
||||||
_configure_shell_tools(
|
_configure_shell_tools(toolset, chat_completions=chat_completions)
|
||||||
toolset, chat_completions=chat_completions, strict_schemas=strict_schemas
|
|
||||||
)
|
|
||||||
|
|
||||||
return configure
|
return configure
|
||||||
|
|
||||||
@@ -598,7 +568,6 @@ def build_strix_agent(
|
|||||||
is_whitebox: bool = False,
|
is_whitebox: bool = False,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
chat_completions_tools: bool = False,
|
chat_completions_tools: bool = False,
|
||||||
strict_tool_schemas: bool = True,
|
|
||||||
system_prompt_context: dict[str, Any] | None = None,
|
system_prompt_context: dict[str, Any] | None = None,
|
||||||
extra_tools: Sequence[Tool] | None = None,
|
extra_tools: Sequence[Tool] | None = None,
|
||||||
instructions_override: str | None = None,
|
instructions_override: str | None = None,
|
||||||
@@ -608,8 +577,6 @@ def build_strix_agent(
|
|||||||
Args:
|
Args:
|
||||||
chat_completions_tools: Wrap SDK custom tools as function tools
|
chat_completions_tools: Wrap SDK custom tools as function tools
|
||||||
when the selected backend cannot accept Responses custom tools.
|
when the selected backend cannot accept Responses custom tools.
|
||||||
strict_tool_schemas: Send function tools as strict-schema tools. Off
|
|
||||||
for routes that reject a toolset this size as strict.
|
|
||||||
extra_tools: Additional tools for this scan agent only, on top of any
|
extra_tools: Additional tools for this scan agent only, on top of any
|
||||||
registered via ``register_agent_tools``.
|
registered via ``register_agent_tools``.
|
||||||
instructions_override: Use this verbatim as the system prompt instead
|
instructions_override: Use this verbatim as the system prompt instead
|
||||||
@@ -637,7 +604,7 @@ def build_strix_agent(
|
|||||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||||
_ensure_unique_tool_names(tools)
|
_ensure_unique_tool_names(tools)
|
||||||
tools = [
|
tools = [
|
||||||
_with_bounded_result(_with_strictness(_with_coerced_arguments(tool), strict_tool_schemas))
|
_with_bounded_result(_with_coerced_arguments(tool))
|
||||||
if isinstance(tool, FunctionTool)
|
if isinstance(tool, FunctionTool)
|
||||||
else tool
|
else tool
|
||||||
for tool in tools
|
for tool in tools
|
||||||
@@ -663,13 +630,11 @@ def build_strix_agent(
|
|||||||
Filesystem(
|
Filesystem(
|
||||||
configure_tools=_make_filesystem_configurator(
|
configure_tools=_make_filesystem_configurator(
|
||||||
chat_completions=chat_completions_tools,
|
chat_completions=chat_completions_tools,
|
||||||
strict_schemas=strict_tool_schemas,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Shell(
|
Shell(
|
||||||
configure_tools=_make_shell_configurator(
|
configure_tools=_make_shell_configurator(
|
||||||
chat_completions=chat_completions_tools,
|
chat_completions=chat_completions_tools,
|
||||||
strict_schemas=strict_tool_schemas,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -682,7 +647,6 @@ def make_child_factory(
|
|||||||
is_whitebox: bool = False,
|
is_whitebox: bool = False,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
chat_completions_tools: bool = False,
|
chat_completions_tools: bool = False,
|
||||||
strict_tool_schemas: bool = True,
|
|
||||||
system_prompt_context: dict[str, Any] | None = None,
|
system_prompt_context: dict[str, Any] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Return the runner-owned builder used by ``spawn_child_agent``.
|
"""Return the runner-owned builder used by ``spawn_child_agent``.
|
||||||
@@ -701,7 +665,6 @@ def make_child_factory(
|
|||||||
is_whitebox=is_whitebox,
|
is_whitebox=is_whitebox,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
chat_completions_tools=chat_completions_tools,
|
chat_completions_tools=chat_completions_tools,
|
||||||
strict_tool_schemas=strict_tool_schemas,
|
|
||||||
system_prompt_context=system_prompt_context,
|
system_prompt_context=system_prompt_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -749,18 +749,6 @@ def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bo
|
|||||||
return not model_supports_reasoning(model_name)
|
return not model_supports_reasoning(model_name)
|
||||||
|
|
||||||
|
|
||||||
def supports_strict_tool_schemas(model_name: str) -> bool:
|
|
||||||
"""Return whether the route accepts strict tool schemas for Strix's toolset.
|
|
||||||
|
|
||||||
Claude caps a request at 20 strict tools and 16 union-typed parameters
|
|
||||||
across all strict schemas. Strix ships ~30 tools and the strict dialect
|
|
||||||
turns every optional parameter into a nullable union, so both caps are
|
|
||||||
exceeded and the request is rejected outright.
|
|
||||||
"""
|
|
||||||
name = model_name.strip().lower()
|
|
||||||
return not any(marker in name for marker in _ANTHROPIC_MODEL_MARKERS)
|
|
||||||
|
|
||||||
|
|
||||||
def model_supports_reasoning(model_name: str) -> bool:
|
def model_supports_reasoning(model_name: str) -> bool:
|
||||||
import litellm
|
import litellm
|
||||||
|
|
||||||
@@ -857,9 +845,6 @@ def is_known_openai_bare_model(model_name: str) -> bool:
|
|||||||
return bool(entry and entry.get("litellm_provider") == "openai")
|
return bool(entry and entry.get("litellm_provider") == "openai")
|
||||||
|
|
||||||
|
|
||||||
_ANTHROPIC_MODEL_MARKERS = ("anthropic", "claude", "sonnet", "opus", "haiku")
|
|
||||||
|
|
||||||
|
|
||||||
def is_claude_model(model_name: str) -> bool:
|
def is_claude_model(model_name: str) -> bool:
|
||||||
return "claude" in (model_name or "").strip().lower()
|
return "claude" in (model_name or "").strip().lower()
|
||||||
|
|
||||||
|
|||||||
+3
-17
@@ -7,12 +7,13 @@ import contextlib
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import cache
|
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
|
import litellm
|
||||||
from agents import RunConfig, Runner
|
from agents import RunConfig, Runner
|
||||||
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
||||||
from agents.sandbox.errors import ExecTransportError
|
from agents.sandbox.errors import ExecTransportError
|
||||||
|
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
||||||
from openai import (
|
from openai import (
|
||||||
APIConnectionError,
|
APIConnectionError,
|
||||||
APIError,
|
APIError,
|
||||||
@@ -55,19 +56,6 @@ _INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
|||||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def _teardown_sandbox_errors() -> tuple[type[BaseException], ...]:
|
|
||||||
"""Sandbox-gone errors, tolerated during shutdown.
|
|
||||||
|
|
||||||
The Docker SDK is imported here rather than at module scope: it is only
|
|
||||||
reachable with the Docker runtime backend, and importing it eagerly puts it
|
|
||||||
on every launch's critical path.
|
|
||||||
"""
|
|
||||||
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
|
||||||
|
|
||||||
return (ExecTransportError, docker_errors.NotFound)
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderRefusalError(AgentsException):
|
class ProviderRefusalError(AgentsException):
|
||||||
"""Raised when a provider returns a structured refusal instead of an exception."""
|
"""Raised when a provider returns a structured refusal instead of an exception."""
|
||||||
|
|
||||||
@@ -138,8 +126,6 @@ def _is_transient_model_error(exc: BaseException) -> bool:
|
|||||||
return True
|
return True
|
||||||
code = _model_error_status_code(exc)
|
code = _model_error_status_code(exc)
|
||||||
if code is not None:
|
if code is not None:
|
||||||
import litellm
|
|
||||||
|
|
||||||
return bool(litellm._should_retry(code))
|
return bool(litellm._should_retry(code))
|
||||||
return isinstance(exc, APIError)
|
return isinstance(exc, APIError)
|
||||||
|
|
||||||
@@ -706,7 +692,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
"Ignoring LiteLLM end-of-stream shutdown race for %s",
|
"Ignoring LiteLLM end-of-stream shutdown race for %s",
|
||||||
agent_id,
|
agent_id,
|
||||||
)
|
)
|
||||||
except _teardown_sandbox_errors():
|
except (ExecTransportError, docker_errors.NotFound):
|
||||||
if not coordinator.is_shutting_down:
|
if not coordinator.is_shutting_down:
|
||||||
raise
|
raise
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from strix.config import load_settings
|
|||||||
from strix.config.models import (
|
from strix.config.models import (
|
||||||
StrixProvider,
|
StrixProvider,
|
||||||
configure_sdk_model_defaults,
|
configure_sdk_model_defaults,
|
||||||
supports_strict_tool_schemas,
|
|
||||||
uses_chat_completions_tool_schema,
|
uses_chat_completions_tool_schema,
|
||||||
)
|
)
|
||||||
from strix.config.settings import DEFAULT_MAX_TURNS
|
from strix.config.settings import DEFAULT_MAX_TURNS
|
||||||
@@ -176,9 +175,6 @@ async def run_strix_scan(
|
|||||||
)
|
)
|
||||||
logger.info("LLM model resolved: %s", resolved_model)
|
logger.info("LLM model resolved: %s", resolved_model)
|
||||||
chat_completions_tools = uses_chat_completions_tool_schema(resolved_model, settings)
|
chat_completions_tools = uses_chat_completions_tool_schema(resolved_model, settings)
|
||||||
strict_tool_schemas = supports_strict_tool_schemas(resolved_model)
|
|
||||||
if not strict_tool_schemas:
|
|
||||||
logger.info("Sending non-strict tool schemas: %s caps strict tools", resolved_model)
|
|
||||||
|
|
||||||
if coordinator is None:
|
if coordinator is None:
|
||||||
coordinator = AgentCoordinator()
|
coordinator = AgentCoordinator()
|
||||||
@@ -310,7 +306,6 @@ async def run_strix_scan(
|
|||||||
is_whitebox=is_whitebox,
|
is_whitebox=is_whitebox,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
chat_completions_tools=chat_completions_tools,
|
chat_completions_tools=chat_completions_tools,
|
||||||
strict_tool_schemas=strict_tool_schemas,
|
|
||||||
system_prompt_context=root_context,
|
system_prompt_context=root_context,
|
||||||
instructions_override=root_instructions,
|
instructions_override=root_instructions,
|
||||||
)
|
)
|
||||||
@@ -329,7 +324,6 @@ async def run_strix_scan(
|
|||||||
is_whitebox=is_whitebox,
|
is_whitebox=is_whitebox,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
chat_completions_tools=chat_completions_tools,
|
chat_completions_tools=chat_completions_tools,
|
||||||
strict_tool_schemas=strict_tool_schemas,
|
|
||||||
system_prompt_context=scope_context,
|
system_prompt_context=scope_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
state = read_run_record(run_dir)
|
state = read_run_record(run_dir)
|
||||||
except (RuntimeError, TypeError) as exc:
|
except RuntimeError as exc:
|
||||||
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
|
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
|
||||||
|
|
||||||
args.targets_info = state.get("targets_info") or []
|
args.targets_info = state.get("targets_info") or []
|
||||||
|
|||||||
@@ -431,10 +431,6 @@ def main() -> None:
|
|||||||
|
|
||||||
sys.exit(run_auth(sys.argv[2:]))
|
sys.exit(run_auth(sys.argv[2:]))
|
||||||
|
|
||||||
from strix.llm.warmup import start_import_warmup
|
|
||||||
|
|
||||||
start_import_warmup()
|
|
||||||
|
|
||||||
args = parse_arguments()
|
args = parse_arguments()
|
||||||
|
|
||||||
start_background_check()
|
start_background_check()
|
||||||
|
|||||||
@@ -146,9 +146,7 @@ def bounded_state_projection(state: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
for message in state["messages"][-5:]
|
for message in state["messages"][-5:]
|
||||||
]
|
]
|
||||||
state["usage"] = {
|
state["usage"] = {}
|
||||||
key: state["usage"][key] for key in ("total_tokens", "cost") if key in state["usage"]
|
|
||||||
}
|
|
||||||
state["error"] = terminal_projection(state["error"], max_string=512)
|
state["error"] = terminal_projection(state["error"], max_string=512)
|
||||||
state["model_warning"] = terminal_projection(state["model_warning"], max_string=256)
|
state["model_warning"] = terminal_projection(state["model_warning"], max_string=256)
|
||||||
state["caido_url"] = terminal_projection(state["caido_url"], max_string=256)
|
state["caido_url"] = terminal_projection(state["caido_url"], max_string=256)
|
||||||
@@ -175,7 +173,7 @@ def bounded_state_projection(state: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"model_warning": "",
|
"model_warning": "",
|
||||||
"caido_url": None,
|
"caido_url": None,
|
||||||
"messages": [],
|
"messages": [],
|
||||||
"usage": state["usage"],
|
"usage": {},
|
||||||
"subscription": state["subscription"],
|
"subscription": state["subscription"],
|
||||||
"viewer_status": state["viewer_status"],
|
"viewer_status": state["viewer_status"],
|
||||||
"viewer_url": None,
|
"viewer_url": None,
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func applyMarkdownStyles(text string) string {
|
|||||||
case strings.HasPrefix(line, "- "), strings.HasPrefix(line, "* "):
|
case strings.HasPrefix(line, "- "), strings.HasPrefix(line, "* "):
|
||||||
out.WriteString(Col(Green).Render("• ") + inlineFormat(line[2:]))
|
out.WriteString(Col(Green).Render("• ") + inlineFormat(line[2:]))
|
||||||
case len(line) > 2 && line[0] >= '0' && line[0] <= '9' && (line[1:3] == ". " || line[1:3] == ") "):
|
case len(line) > 2 && line[0] >= '0' && line[0] <= '9' && (line[1:3] == ". " || line[1:3] == ") "):
|
||||||
out.WriteString(Col(Green).Render(line[:2]+" ") + inlineFormat(line[3:]))
|
out.WriteString(Col(Green).Render(string(line[0])+". ") + inlineFormat(line[2:]))
|
||||||
case line == "---" || line == "***" || line == "___":
|
case line == "---" || line == "***" || line == "___":
|
||||||
out.WriteString(Col(Green).Render(strings.Repeat("─", 40)))
|
out.WriteString(Col(Green).Render(strings.Repeat("─", 40)))
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -72,19 +72,6 @@ func TestNonTablePipeLinesAreLeftAlone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarkdownOrderedListsUseSingleSpaceAfterMarker(t *testing.T) {
|
|
||||||
out := renderAssistantMarkdown("1. hello\n2) world")
|
|
||||||
plain := ansi.Strip(out)
|
|
||||||
for _, want := range []string{"1. hello", "2) world"} {
|
|
||||||
if !strings.Contains(plain, want) {
|
|
||||||
t.Fatalf("ordered list item %q missing: %q", want, plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(plain, "1. hello") || strings.Contains(plain, "2) world") {
|
|
||||||
t.Fatalf("double space after the list marker: %q", plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInlineFormatKeepsNonEmphasisMarkers(t *testing.T) {
|
func TestInlineFormatKeepsNonEmphasisMarkers(t *testing.T) {
|
||||||
literal := []string{
|
literal := []string{
|
||||||
"ls *.py *.go",
|
"ls *.py *.go",
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import docker
|
||||||
import requests
|
import requests
|
||||||
|
from docker.errors import DockerException, ImageNotFound
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
@@ -1597,9 +1599,6 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None)
|
|||||||
|
|
||||||
|
|
||||||
def check_docker_connection() -> Any:
|
def check_docker_connection() -> Any:
|
||||||
import docker
|
|
||||||
from docker.errors import DockerException
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return docker.from_env()
|
return docker.from_env()
|
||||||
except DockerException:
|
except DockerException:
|
||||||
@@ -1625,8 +1624,6 @@ def check_docker_connection() -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def image_exists(client: Any, image_name: str) -> bool:
|
def image_exists(client: Any, image_name: str) -> bool:
|
||||||
from docker.errors import ImageNotFound
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client.images.get(image_name)
|
client.images.get(image_name)
|
||||||
except ImageNotFound:
|
except ImageNotFound:
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ def run_view(argv: list[str]) -> None:
|
|||||||
default=0,
|
default=0,
|
||||||
help="Port to serve on (default: an available ephemeral port).",
|
help="Port to serve on (default: an available ephemeral port).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
||||||
"--host",
|
|
||||||
default="127.0.0.1",
|
|
||||||
help="Host to bind to (default: 127.0.0.1; use 0.0.0.0 for all IPv4 interfaces).",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-open",
|
"--no-open",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|||||||
@@ -135,9 +135,8 @@ class _ViewerState:
|
|||||||
# exchanged for a session cookie only when presented on the initial page
|
# exchanged for a session cookie only when presented on the initial page
|
||||||
# load. It is the request-level authorization the review asked for:
|
# load. It is the request-level authorization the review asked for:
|
||||||
# reachability of the port (e.g. when bound with ``--host``) is not
|
# reachability of the port (e.g. when bound with ``--host``) is not
|
||||||
# enough to read run data, steer a live scan, trigger a report, or
|
# enough to steer a live scan, trigger a report, or browse history --
|
||||||
# browse history -- the token is never handed to a caller who merely
|
# the token is never handed to a caller who merely reaches ``/``.
|
||||||
# reaches ``/``.
|
|
||||||
self.session_token = secrets.token_urlsafe(32)
|
self.session_token = secrets.token_urlsafe(32)
|
||||||
# Finalized in ``serve()`` once the port is known (the server binds
|
# Finalized in ``serve()`` once the port is known (the server binds
|
||||||
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
||||||
@@ -235,11 +234,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||||
# The cross-run history list (/api/runs) unlocks its entries only for
|
# The launched run is always viewable with no verification. The
|
||||||
# a caller that holds this process's session capability *and* is
|
# cross-run history list (/api/runs) unlocks its entries only for a
|
||||||
# email verified, so merely reaching an exposed --host port never
|
# caller that holds this process's session capability *and* is email
|
||||||
# leaks the run list (the payload still advertises the count as a
|
# verified, so merely reaching an exposed --host port never leaks the
|
||||||
# teaser).
|
# run list (the payload still advertises the count as a teaser).
|
||||||
if path == "/api/runs":
|
if path == "/api/runs":
|
||||||
unlocked = self._has_session() and auth.is_verified()
|
unlocked = self._has_session() and auth.is_verified()
|
||||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||||
@@ -254,13 +253,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self._handle_auth_status()
|
self._handle_auth_status()
|
||||||
return
|
return
|
||||||
|
|
||||||
# All remaining GET endpoints expose run metadata or scan output.
|
|
||||||
# Require the capability even for the run used to launch the viewer;
|
|
||||||
# reachability of an exposed --host port must not grant data access.
|
|
||||||
if not self._has_session():
|
|
||||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
|
||||||
return
|
|
||||||
|
|
||||||
run_values = query.get("run")
|
run_values = query.get("run")
|
||||||
run_param = run_values[0] if run_values else None
|
run_param = run_values[0] if run_values else None
|
||||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||||
@@ -268,12 +260,18 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Any run other than the one used to launch the viewer is part of the
|
# The launched run is always viewable. Any *other* run's data is part
|
||||||
# email-gated history. The session check above applies to both paths;
|
# of the gated history: it needs this process's session capability
|
||||||
# verification adds a second gate for historical run data.
|
# (so merely reaching an exposed --host port is not enough) *and*
|
||||||
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
# email verification -- otherwise knowing a run name would leak its
|
||||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
# metadata, vulnerabilities, report, and transcript.
|
||||||
return
|
if run_dir.resolve() != state.run_dir.resolve():
|
||||||
|
if not self._has_session():
|
||||||
|
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||||
|
return
|
||||||
|
if not auth.is_verified():
|
||||||
|
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/api/run":
|
if path == "/api/run":
|
||||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||||
@@ -387,7 +385,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
except auth.RelayError as exc:
|
except auth.RelayError as exc:
|
||||||
self._send_relay_error(exc)
|
self._send_relay_error(exc)
|
||||||
return
|
return
|
||||||
# The password is returned only to a session-authorized browser.
|
# The password is returned only to the local (127.0.0.1) browser.
|
||||||
self._send_json(
|
self._send_json(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
{"ok": True, "password": password, "filename": filename},
|
{"ok": True, "password": password, "filename": filename},
|
||||||
|
|||||||
+3
-16
@@ -10,11 +10,11 @@ pairing so the trimmed history is still valid provider input.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from functools import cache
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from agents.model_settings import ModelSettings
|
from agents.model_settings import ModelSettings
|
||||||
from agents.models.interface import ModelTracing
|
from agents.models.interface import ModelTracing
|
||||||
|
from litellm.exceptions import BadRequestError, ContextWindowExceededError
|
||||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
@@ -63,18 +63,6 @@ _OVERFLOW_MARKERS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def _overflow_error_types() -> tuple[type[BaseException], type[BaseException]]:
|
|
||||||
"""``(ContextWindowExceededError, BadRequestError)``, imported on first use.
|
|
||||||
|
|
||||||
LiteLLM costs seconds to import, and nothing needs it until a model call is
|
|
||||||
actually made, so it stays off the launch path.
|
|
||||||
"""
|
|
||||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError
|
|
||||||
|
|
||||||
return ContextWindowExceededError, BadRequestError
|
|
||||||
|
|
||||||
|
|
||||||
def is_context_overflow(exc: BaseException) -> bool:
|
def is_context_overflow(exc: BaseException) -> bool:
|
||||||
"""Whether ``exc`` is a model context-window-overflow error.
|
"""Whether ``exc`` is a model context-window-overflow error.
|
||||||
|
|
||||||
@@ -82,10 +70,9 @@ def is_context_overflow(exc: BaseException) -> bool:
|
|||||||
OpenRouter branch raises a plain BadRequestError, so for that we fall back to
|
OpenRouter branch raises a plain BadRequestError, so for that we fall back to
|
||||||
matching the provider message.
|
matching the provider message.
|
||||||
"""
|
"""
|
||||||
context_window_exceeded, bad_request = _overflow_error_types()
|
if isinstance(exc, ContextWindowExceededError):
|
||||||
if isinstance(exc, context_window_exceeded):
|
|
||||||
return True
|
return True
|
||||||
if isinstance(exc, bad_request):
|
if isinstance(exc, BadRequestError):
|
||||||
msg = str(exc).lower()
|
msg = str(exc).lower()
|
||||||
if any(x in msg for x in _OVERFLOW_EXCLUSIONS):
|
if any(x in msg for x in _OVERFLOW_EXCLUSIONS):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import logging
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -36,8 +38,6 @@ def _lookup_key(model: str) -> str:
|
|||||||
|
|
||||||
def _safe_get_model_info(model: str) -> dict[str, Any] | None:
|
def _safe_get_model_info(model: str) -> dict[str, Any] | None:
|
||||||
try:
|
try:
|
||||||
import litellm
|
|
||||||
|
|
||||||
return dict(litellm.get_model_info(model))
|
return dict(litellm.get_model_info(model))
|
||||||
except Exception: # noqa: BLE001 - unmapped models raise; caller falls back.
|
except Exception: # noqa: BLE001 - unmapped models raise; caller falls back.
|
||||||
return None
|
return None
|
||||||
@@ -82,8 +82,6 @@ def count_tokens(model: str, text: str) -> int:
|
|||||||
if not text:
|
if not text:
|
||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
import litellm
|
|
||||||
|
|
||||||
return int(litellm.token_counter(model=_lookup_key(model), text=text))
|
return int(litellm.token_counter(model=_lookup_key(model), text=text))
|
||||||
except Exception: # noqa: BLE001 - tokenizer may be unavailable for some models.
|
except Exception: # noqa: BLE001 - tokenizer may be unavailable for some models.
|
||||||
return len(text.encode("utf-8"))
|
return len(text.encode("utf-8"))
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Background pre-import of the heavy scan dependencies.
|
|
||||||
|
|
||||||
The scan engine's import graph (the agents SDK, OpenAI client, LiteLLM, the
|
|
||||||
Caido SDK, the Docker SDK) costs seconds to import cold, but none of it is
|
|
||||||
needed until a scan actually starts. Importing it on a daemon thread at CLI
|
|
||||||
entry overlaps that cost with the I/O-bound startup work that always precedes
|
|
||||||
a scan (argument parsing, Docker checks, image pull, TUI setup), so by the
|
|
||||||
time the scan begins the modules are already in ``sys.modules``. Any thread
|
|
||||||
that needs one of them before the warm-up finishes just blocks on the normal
|
|
||||||
import lock, so behaviour is unchanged either way.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
WARMUP_MODULES = (
|
|
||||||
"strix.core.runner",
|
|
||||||
"litellm",
|
|
||||||
"caido_sdk_client",
|
|
||||||
"docker",
|
|
||||||
)
|
|
||||||
|
|
||||||
_lock = threading.Lock()
|
|
||||||
_thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _warm(modules: tuple[str, ...]) -> None:
|
|
||||||
for name in modules:
|
|
||||||
try:
|
|
||||||
importlib.import_module(name)
|
|
||||||
except Exception: # noqa: BLE001 - a failed warm-up must never fail the run.
|
|
||||||
logger.debug("Import warm-up for %r failed", name, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.Thread:
|
|
||||||
"""Start importing the heavy scan dependencies in the background, once.
|
|
||||||
|
|
||||||
``modules`` lets embedders that never touch some backends (e.g. a cloud
|
|
||||||
runtime that has no local Docker) warm a narrower set.
|
|
||||||
"""
|
|
||||||
global _thread # noqa: PLW0603
|
|
||||||
with _lock:
|
|
||||||
if _thread is not None:
|
|
||||||
return _thread
|
|
||||||
_thread = threading.Thread(
|
|
||||||
target=_warm, args=(modules,), name="strix-import-warmup", daemon=True
|
|
||||||
)
|
|
||||||
_thread.start()
|
|
||||||
return _thread
|
|
||||||
@@ -15,10 +15,12 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from caido_sdk_client import Client, TokenAuthOptions
|
||||||
|
from caido_sdk_client.types import CreateProjectOptions
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from agents.sandbox.session import BaseSandboxSession
|
from agents.sandbox.session import BaseSandboxSession
|
||||||
from caido_sdk_client import Client
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -85,28 +87,20 @@ async def bootstrap_caido(
|
|||||||
container_url: str,
|
container_url: str,
|
||||||
) -> Client:
|
) -> Client:
|
||||||
"""Connect to the in-container Caido sidecar and select a fresh project."""
|
"""Connect to the in-container Caido sidecar and select a fresh project."""
|
||||||
# The Caido SDK (and its generated GraphQL schema) is slow to import and is
|
|
||||||
# only needed once a sandbox is actually being bootstrapped, so it is
|
|
||||||
# imported here rather than at module scope.
|
|
||||||
from caido_sdk_client import Client, TokenAuthOptions
|
|
||||||
from caido_sdk_client.types import CreateProjectOptions
|
|
||||||
|
|
||||||
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
|
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
|
||||||
|
|
||||||
access_token = await _login_as_guest(session, container_url=container_url)
|
access_token = await _login_as_guest(session, container_url=container_url)
|
||||||
|
|
||||||
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
|
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# connect() is inside the guard as well: a cancellation there (scan
|
|
||||||
# teardown while the bootstrap is still in flight) would otherwise
|
|
||||||
# leave the half-connected transport behind.
|
|
||||||
await client.connect()
|
|
||||||
project = await client.project.create(
|
project = await client.project.create(
|
||||||
CreateProjectOptions(name="sandbox", temporary=True),
|
CreateProjectOptions(name="sandbox", temporary=True),
|
||||||
)
|
)
|
||||||
await client.project.select(project.id)
|
await client.project.select(project.id)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# The client never reaches the session bundle if connect or project
|
# The connected client never reaches the session bundle if project
|
||||||
# setup fails, so close it here to avoid leaking the transport.
|
# setup fails, so close it here to avoid leaking the transport.
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Handle for a Caido bootstrap running concurrently with the scan start.
|
|
||||||
|
|
||||||
The Caido sidecar login + project setup costs a couple of seconds of
|
|
||||||
guest-side polling, and nothing needs the client until the first proxy
|
|
||||||
tool call (or the first traffic poll). :class:`CaidoBootstrapHandle`
|
|
||||||
wraps the in-flight bootstrap task so session bring-up can return as
|
|
||||||
soon as the container is up; consumers resolve the client at first use.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from caido_sdk_client import Client
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CaidoBootstrapHandle:
|
|
||||||
"""Resolves to the connected Caido client once the bootstrap finishes.
|
|
||||||
|
|
||||||
A failed bootstrap is surfaced (once) to every ``get()`` caller as the
|
|
||||||
original exception; proxy tools degrade to their "client unavailable"
|
|
||||||
result instead of the failure killing the scan at bring-up.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, task: asyncio.Task[Client]) -> None:
|
|
||||||
self._task = task
|
|
||||||
|
|
||||||
async def get(self) -> Client:
|
|
||||||
"""Wait for the bootstrap and return the client.
|
|
||||||
|
|
||||||
Shielded so one caller's cancellation (e.g. a tool timeout) does not
|
|
||||||
cancel the shared bootstrap for everyone else.
|
|
||||||
"""
|
|
||||||
return await asyncio.shield(self._task)
|
|
||||||
|
|
||||||
def peek(self) -> Client | None:
|
|
||||||
"""Return the client if the bootstrap already finished cleanly."""
|
|
||||||
if self._task.done() and not self._task.cancelled() and self._task.exception() is None:
|
|
||||||
return self._task.result()
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
"""Cancel an in-flight bootstrap or close the finished client."""
|
|
||||||
if not self._task.done():
|
|
||||||
self._task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
||||||
await self._task
|
|
||||||
return
|
|
||||||
client = self.peek()
|
|
||||||
if client is not None:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await client.aclose()
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -16,7 +15,6 @@ from strix.config import load_settings
|
|||||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||||
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
|
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
|
||||||
from strix.runtime.caido_bootstrap import bootstrap_caido
|
from strix.runtime.caido_bootstrap import bootstrap_caido
|
||||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -335,19 +333,10 @@ async def create_or_reuse(
|
|||||||
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
|
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
|
||||||
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
|
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
|
||||||
|
|
||||||
# The Caido login + project setup polls the guest for a couple of seconds
|
caido_client = await bootstrap_caido(
|
||||||
# and nothing needs the client before the first proxy tool call, so it
|
session,
|
||||||
# runs concurrently with the rest of scan start; consumers resolve the
|
host_url=host_caido_url,
|
||||||
# handle at first use (see CaidoBootstrapHandle).
|
container_url=container_caido_url,
|
||||||
caido_client = CaidoBootstrapHandle(
|
|
||||||
asyncio.create_task(
|
|
||||||
bootstrap_caido(
|
|
||||||
session,
|
|
||||||
host_url=host_caido_url,
|
|
||||||
container_url=container_caido_url,
|
|
||||||
),
|
|
||||||
name=f"caido-bootstrap-{scan_id}",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
bundle = {
|
bundle = {
|
||||||
|
|||||||
@@ -10,16 +10,20 @@ import urllib.request
|
|||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
from caido_sdk_client import Client, TokenAuthOptions
|
||||||
|
from caido_sdk_client.types import (
|
||||||
|
ConnectionInfoInput,
|
||||||
|
CreateScopeOptions,
|
||||||
|
ReplaySendOptions,
|
||||||
|
RequestGetOptions,
|
||||||
|
UpdateScopeOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# The generated Caido GraphQL schema module is slow to import and is only needed
|
|
||||||
# once a proxy tool actually runs, so the SDK is imported on first use rather
|
|
||||||
# than at module scope, which would put it on every launch's critical path.
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from caido_sdk_client import Client
|
|
||||||
from caido_sdk_client import Client as CaidoClient
|
from caido_sdk_client import Client as CaidoClient
|
||||||
from caido_sdk_client.types import ConnectionInfoInput
|
|
||||||
|
|
||||||
|
|
||||||
RequestPart = Literal["request", "response"]
|
RequestPart = Literal["request", "response"]
|
||||||
@@ -81,8 +85,6 @@ def _login_as_guest() -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _new_client() -> Client:
|
async def _new_client() -> Client:
|
||||||
from caido_sdk_client import Client, TokenAuthOptions
|
|
||||||
|
|
||||||
token = await asyncio.to_thread(_login_as_guest)
|
token = await asyncio.to_thread(_login_as_guest)
|
||||||
client = Client(caido_url(), auth=TokenAuthOptions(token=token))
|
client = Client(caido_url(), auth=TokenAuthOptions(token=token))
|
||||||
await client.connect()
|
await client.connect()
|
||||||
@@ -161,8 +163,6 @@ async def get_request_with_client(
|
|||||||
# Passing False for either causes pydantic validation to fail with
|
# Passing False for either causes pydantic validation to fail with
|
||||||
# "Field required" on the missing raw field. Always request both —
|
# "Field required" on the missing raw field. Always request both —
|
||||||
# the caller picks which one to surface via ``part``.
|
# the caller picks which one to surface via ``part``.
|
||||||
from caido_sdk_client.types import RequestGetOptions
|
|
||||||
|
|
||||||
opts = RequestGetOptions(request_raw=True, response_raw=True)
|
opts = RequestGetOptions(request_raw=True, response_raw=True)
|
||||||
return await client.request.get(request_id, opts)
|
return await client.request.get(request_id, opts)
|
||||||
|
|
||||||
@@ -206,8 +206,6 @@ def build_raw_request(
|
|||||||
if body:
|
if body:
|
||||||
final_headers["Content-Length"] = str(len(body.encode("utf-8")))
|
final_headers["Content-Length"] = str(len(body.encode("utf-8")))
|
||||||
|
|
||||||
from caido_sdk_client.types import ConnectionInfoInput
|
|
||||||
|
|
||||||
lines = [f"{method.upper()} {path} HTTP/1.1"]
|
lines = [f"{method.upper()} {path} HTTP/1.1"]
|
||||||
lines.extend(f"{k}: {v}" for k, v in final_headers.items())
|
lines.extend(f"{k}: {v}" for k, v in final_headers.items())
|
||||||
raw = ("\r\n".join(lines) + "\r\n\r\n" + body).encode("utf-8")
|
raw = ("\r\n".join(lines) + "\r\n\r\n" + body).encode("utf-8")
|
||||||
@@ -336,8 +334,6 @@ async def replay_send_raw(
|
|||||||
raw: bytes,
|
raw: bytes,
|
||||||
connection: ConnectionInfoInput,
|
connection: ConnectionInfoInput,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from caido_sdk_client.types import ReplaySendOptions
|
|
||||||
|
|
||||||
started = time.time()
|
started = time.time()
|
||||||
# Create an empty replay session, then dispatch via ``send()``.
|
# Create an empty replay session, then dispatch via ``send()``.
|
||||||
# Passing ``CreateReplaySessionFromRaw`` here would also seed a stored
|
# Passing ``CreateReplaySessionFromRaw`` here would also seed a stored
|
||||||
@@ -395,8 +391,6 @@ async def scope_create(
|
|||||||
allowlist: list[str] | None = None,
|
allowlist: list[str] | None = None,
|
||||||
denylist: list[str] | None = None,
|
denylist: list[str] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from caido_sdk_client.types import CreateScopeOptions
|
|
||||||
|
|
||||||
return await client.scope.create(
|
return await client.scope.create(
|
||||||
CreateScopeOptions(
|
CreateScopeOptions(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -414,8 +408,6 @@ async def scope_update(
|
|||||||
allowlist: list[str] | None = None,
|
allowlist: list[str] | None = None,
|
||||||
denylist: list[str] | None = None,
|
denylist: list[str] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from caido_sdk_client.types import UpdateScopeOptions
|
|
||||||
|
|
||||||
return await client.scope.update(
|
return await client.scope.update(
|
||||||
scope_id,
|
scope_id,
|
||||||
UpdateScopeOptions(
|
UpdateScopeOptions(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||||||
|
|
||||||
from agents import RunContextWrapper, function_tool
|
from agents import RunContextWrapper, function_tool
|
||||||
|
|
||||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
|
||||||
from strix.tools.proxy import caido_api
|
from strix.tools.proxy import caido_api
|
||||||
|
|
||||||
|
|
||||||
@@ -48,16 +47,9 @@ ScopeAction = Literal["get", "list", "create", "update", "delete"]
|
|||||||
_CAIDO_CALL_LOCK = asyncio.Lock()
|
_CAIDO_CALL_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
async def _ctx_client(ctx: RunContextWrapper) -> Client | None:
|
def _ctx_client(ctx: RunContextWrapper) -> Client | None:
|
||||||
inner: dict[str, Any] = ctx.context if isinstance(ctx.context, dict) else {}
|
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||||
client: Client | CaidoBootstrapHandle | None = inner.get("caido_client")
|
return inner.get("caido_client")
|
||||||
if isinstance(client, CaidoBootstrapHandle):
|
|
||||||
try:
|
|
||||||
return await client.get()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
logger.warning("Caido bootstrap failed; proxy tools unavailable", exc_info=True)
|
|
||||||
return None
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
async def _call[T](client: Client, fn: Callable[[Client], Awaitable[T]]) -> T:
|
async def _call[T](client: Client, fn: Callable[[Client], Awaitable[T]]) -> T:
|
||||||
@@ -163,7 +155,7 @@ async def list_requests(
|
|||||||
sort_order: ``asc`` or ``desc``.
|
sort_order: ``asc`` or ``desc``.
|
||||||
scope_id: Restrict to a Caido scope (managed via ``scope_rules``).
|
scope_id: Restrict to a Caido scope (managed via ``scope_rules``).
|
||||||
"""
|
"""
|
||||||
client = await _ctx_client(ctx)
|
client = _ctx_client(ctx)
|
||||||
if client is None:
|
if client is None:
|
||||||
return _no_client()
|
return _no_client()
|
||||||
|
|
||||||
@@ -269,7 +261,7 @@ async def view_request(
|
|||||||
page: 1-indexed page number (only when no ``search_pattern``).
|
page: 1-indexed page number (only when no ``search_pattern``).
|
||||||
page_size: Lines per page.
|
page_size: Lines per page.
|
||||||
"""
|
"""
|
||||||
client = await _ctx_client(ctx)
|
client = _ctx_client(ctx)
|
||||||
if client is None:
|
if client is None:
|
||||||
return _no_client()
|
return _no_client()
|
||||||
|
|
||||||
@@ -387,7 +379,7 @@ async def repeat_request(
|
|||||||
- ``body`` — replace the body string entirely.
|
- ``body`` — replace the body string entirely.
|
||||||
- ``cookies`` — dict of cookies to add/update.
|
- ``cookies`` — dict of cookies to add/update.
|
||||||
"""
|
"""
|
||||||
client = await _ctx_client(ctx)
|
client = _ctx_client(ctx)
|
||||||
if client is None:
|
if client is None:
|
||||||
return _no_client()
|
return _no_client()
|
||||||
mods = modifications or {}
|
mods = modifications or {}
|
||||||
@@ -469,7 +461,7 @@ async def list_sitemap(
|
|||||||
(recursive subtree). Only meaningful with ``parent_id``.
|
(recursive subtree). Only meaningful with ``parent_id``.
|
||||||
page: 1-indexed page (30 entries per page).
|
page: 1-indexed page (30 entries per page).
|
||||||
"""
|
"""
|
||||||
client = await _ctx_client(ctx)
|
client = _ctx_client(ctx)
|
||||||
if client is None:
|
if client is None:
|
||||||
return _no_client()
|
return _no_client()
|
||||||
try:
|
try:
|
||||||
@@ -503,7 +495,7 @@ async def view_sitemap_entry(
|
|||||||
Args:
|
Args:
|
||||||
entry_id: ID from ``list_sitemap`` (or any nested entry).
|
entry_id: ID from ``list_sitemap`` (or any nested entry).
|
||||||
"""
|
"""
|
||||||
client = await _ctx_client(ctx)
|
client = _ctx_client(ctx)
|
||||||
if client is None:
|
if client is None:
|
||||||
return _no_client()
|
return _no_client()
|
||||||
try:
|
try:
|
||||||
@@ -562,7 +554,7 @@ async def scope_rules(
|
|||||||
scope_id: Required for ``get`` / ``update`` / ``delete``.
|
scope_id: Required for ``get`` / ``update`` / ``delete``.
|
||||||
scope_name: Required for ``create`` / ``update``.
|
scope_name: Required for ``create`` / ``update``.
|
||||||
"""
|
"""
|
||||||
client = await _ctx_client(ctx)
|
client = _ctx_client(ctx)
|
||||||
if client is None:
|
if client is None:
|
||||||
return _no_client()
|
return _no_client()
|
||||||
|
|
||||||
|
|||||||
@@ -112,19 +112,3 @@ def test_wait_for_agents_is_available_in_both_modes() -> None:
|
|||||||
for interactive in (True, False):
|
for interactive in (True, False):
|
||||||
agent = factory.build_strix_agent(is_root=True, interactive=interactive)
|
agent = factory.build_strix_agent(is_root=True, interactive=interactive)
|
||||||
assert "wait_for_agents" in [t.name for t in agent.tools]
|
assert "wait_for_agents" in [t.name for t in agent.tools]
|
||||||
|
|
||||||
|
|
||||||
def test_strict_tool_schemas_can_be_disabled_per_route() -> None:
|
|
||||||
"""Claude routes cap strict tools; the toolset must be sendable without strict."""
|
|
||||||
agent = factory.build_strix_agent(is_root=True, strict_tool_schemas=False)
|
|
||||||
|
|
||||||
function_tools = [t for t in agent.tools if isinstance(t, FunctionTool)]
|
|
||||||
assert function_tools
|
|
||||||
assert not any(t.strict_json_schema for t in function_tools)
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabling_strict_leaves_shared_tools_untouched() -> None:
|
|
||||||
factory.build_strix_agent(is_root=True, strict_tool_schemas=False)
|
|
||||||
agent = factory.build_strix_agent(is_root=True)
|
|
||||||
|
|
||||||
assert any(t.strict_json_schema for t in agent.tools if isinstance(t, FunctionTool))
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
"""A bootstrap that dies mid-setup must not leave its transport behind.
|
|
||||||
|
|
||||||
The bootstrap now runs concurrently with the scan start, so teardown can
|
|
||||||
cancel it at any await — including inside ``Client.connect()``, where the
|
|
||||||
client exists but no caller will ever see it to close it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.runtime.caido_bootstrap import bootstrap_caido
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeExecResult:
|
|
||||||
stderr = b""
|
|
||||||
exit_code = 0
|
|
||||||
|
|
||||||
def __init__(self, stdout: str) -> None:
|
|
||||||
self.stdout = stdout
|
|
||||||
|
|
||||||
def ok(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeSession:
|
|
||||||
async def exec(self, *_args: Any, **_kwargs: Any) -> _FakeExecResult:
|
|
||||||
return _FakeExecResult('{"data":{"loginAsGuest":{"token":{"accessToken":"t"}}}}')
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeClient:
|
|
||||||
def __init__(self, connect_error: BaseException) -> None:
|
|
||||||
self.connect_error = connect_error
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
raise self.connect_error
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
async def _bootstrap_expecting(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, error: BaseException
|
|
||||||
) -> _FakeClient:
|
|
||||||
"""Run a bootstrap whose ``connect()`` fails with ``error``."""
|
|
||||||
client = _FakeClient(error)
|
|
||||||
# The SDK is imported inside bootstrap_caido (it is slow to import), so the
|
|
||||||
# fakes are injected as the modules it imports.
|
|
||||||
sdk = types.ModuleType("caido_sdk_client")
|
|
||||||
sdk.Client = lambda *_a, **_k: client # type: ignore[attr-defined]
|
|
||||||
sdk.TokenAuthOptions = lambda token: token # type: ignore[attr-defined]
|
|
||||||
sdk_types = types.ModuleType("caido_sdk_client.types")
|
|
||||||
sdk_types.CreateProjectOptions = lambda **_k: None # type: ignore[attr-defined]
|
|
||||||
monkeypatch.setitem(sys.modules, "caido_sdk_client", sdk)
|
|
||||||
monkeypatch.setitem(sys.modules, "caido_sdk_client.types", sdk_types)
|
|
||||||
|
|
||||||
with pytest.raises(type(error)):
|
|
||||||
await bootstrap_caido(
|
|
||||||
_FakeSession(), # type: ignore[arg-type]
|
|
||||||
host_url="http://host",
|
|
||||||
container_url="http://container",
|
|
||||||
)
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
async def test_cancellation_during_connect_closes_the_client(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
client = await _bootstrap_expecting(monkeypatch, asyncio.CancelledError())
|
|
||||||
assert client.closed
|
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_connect_closes_the_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
client = await _bootstrap_expecting(monkeypatch, RuntimeError("no listener"))
|
|
||||||
assert client.closed
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""Tests for the concurrent Caido bootstrap handle."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeClient:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
def _handle(coro: Any) -> CaidoBootstrapHandle:
|
|
||||||
return CaidoBootstrapHandle(asyncio.ensure_future(coro))
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_waits_for_the_bootstrap() -> None:
|
|
||||||
client = _FakeClient()
|
|
||||||
started = asyncio.Event()
|
|
||||||
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
started.set()
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
return client
|
|
||||||
|
|
||||||
handle = _handle(_bootstrap())
|
|
||||||
await started.wait()
|
|
||||||
assert handle.peek() is None
|
|
||||||
assert await handle.get() is client
|
|
||||||
assert handle.peek() is client
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_reraises_bootstrap_failure_to_every_caller() -> None:
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
raise RuntimeError("caido never came up")
|
|
||||||
|
|
||||||
handle = _handle(_bootstrap())
|
|
||||||
for _ in range(2):
|
|
||||||
with pytest.raises(RuntimeError, match="caido never came up"):
|
|
||||||
await handle.get()
|
|
||||||
assert handle.peek() is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_caller_cancellation_does_not_cancel_the_shared_bootstrap() -> None:
|
|
||||||
client = _FakeClient()
|
|
||||||
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
return client
|
|
||||||
|
|
||||||
handle = _handle(_bootstrap())
|
|
||||||
|
|
||||||
with pytest.raises(TimeoutError):
|
|
||||||
await asyncio.wait_for(handle.get(), timeout=0.01)
|
|
||||||
|
|
||||||
assert await handle.get() is client
|
|
||||||
|
|
||||||
|
|
||||||
async def test_aclose_closes_a_finished_client() -> None:
|
|
||||||
client = _FakeClient()
|
|
||||||
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
return client
|
|
||||||
|
|
||||||
handle = _handle(_bootstrap())
|
|
||||||
await handle.get()
|
|
||||||
await handle.aclose()
|
|
||||||
assert client.closed is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_aclose_cancels_an_in_flight_bootstrap() -> None:
|
|
||||||
cancelled = asyncio.Event()
|
|
||||||
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
cancelled.set()
|
|
||||||
raise
|
|
||||||
return _FakeClient()
|
|
||||||
|
|
||||||
handle = _handle(_bootstrap())
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
await handle.aclose()
|
|
||||||
assert cancelled.is_set()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_aclose_swallows_a_failed_bootstrap() -> None:
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
handle = _handle(_bootstrap())
|
|
||||||
with pytest.raises(RuntimeError, match="boom"):
|
|
||||||
await handle.get()
|
|
||||||
await handle.aclose()
|
|
||||||
@@ -227,18 +227,3 @@ def test_resume_still_requires_targets_or_a_workspace(
|
|||||||
cli_main.parse_arguments()
|
cli_main.parse_arguments()
|
||||||
|
|
||||||
assert "has no targets_info" in capsys.readouterr().err
|
assert "has no targets_info" in capsys.readouterr().err
|
||||||
|
|
||||||
def test_resume_non_object_run_json_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
run_dir = tmp_path / "strix_runs" / "pentest_abcd"
|
|
||||||
run_dir.mkdir(parents=True)
|
|
||||||
(run_dir / "run.json").write_text("[]", encoding="utf-8")
|
|
||||||
|
|
||||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"])
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
|
||||||
cli_main.parse_arguments()
|
|
||||||
|
|
||||||
assert exc_info.value.code == 2
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "run.json unreadable" in captured.err
|
|
||||||
assert "not an object" in captured.err
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def test_context_window_chatgpt_prefix_skips_provider_auth(
|
|||||||
calls.append(model)
|
calls.append(model)
|
||||||
return {"max_input_tokens": 1_050_000, "max_output_tokens": 128_000}
|
return {"max_input_tokens": 1_050_000, "max_output_tokens": 128_000}
|
||||||
|
|
||||||
monkeypatch.setattr("litellm.get_model_info", _model_info)
|
monkeypatch.setattr("strix.llm.context_budget.litellm.get_model_info", _model_info)
|
||||||
try:
|
try:
|
||||||
assert context_budget.context_window("chatgpt/gpt-5.6-luna") == 1_050_000
|
assert context_budget.context_window("chatgpt/gpt-5.6-luna") == 1_050_000
|
||||||
assert calls == ["gpt-5.6-luna"]
|
assert calls == ["gpt-5.6-luna"]
|
||||||
@@ -45,7 +45,7 @@ def test_context_window_unmapped_uses_fallback(monkeypatch: pytest.MonkeyPatch)
|
|||||||
def _raise(_model: str) -> dict[str, int]:
|
def _raise(_model: str) -> dict[str, int]:
|
||||||
raise ValueError("This model isn't mapped yet.")
|
raise ValueError("This model isn't mapped yet.")
|
||||||
|
|
||||||
monkeypatch.setattr("litellm.get_model_info", _raise)
|
monkeypatch.setattr("strix.llm.context_budget.litellm.get_model_info", _raise)
|
||||||
expected = load_settings().context.fallback_context_tokens
|
expected = load_settings().context.fallback_context_tokens
|
||||||
assert context_budget.context_window("totally-made-up-model") == expected
|
assert context_budget.context_window("totally-made-up-model") == expected
|
||||||
context_budget._model_info.cache_clear()
|
context_budget._model_info.cache_clear()
|
||||||
@@ -55,7 +55,7 @@ def test_count_tokens_fallback_on_error(monkeypatch: pytest.MonkeyPatch) -> None
|
|||||||
def _raise(**_kwargs: object) -> int:
|
def _raise(**_kwargs: object) -> int:
|
||||||
raise RuntimeError("no tokenizer")
|
raise RuntimeError("no tokenizer")
|
||||||
|
|
||||||
monkeypatch.setattr("litellm.token_counter", _raise)
|
monkeypatch.setattr("strix.llm.context_budget.litellm.token_counter", _raise)
|
||||||
# Falls back to UTF-8 byte length (upper bound on tokens).
|
# Falls back to UTF-8 byte length (upper bound on tokens).
|
||||||
assert context_budget.count_tokens("weird-model", "x" * 400) == 400
|
assert context_budget.count_tokens("weird-model", "x" * 400) == 400
|
||||||
assert context_budget.count_tokens("weird-model", "😀" * 10) == 40
|
assert context_budget.count_tokens("weird-model", "😀" * 10) == 40
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from strix.config.models import (
|
|||||||
RECOMMENDED_MODEL_NAMES,
|
RECOMMENDED_MODEL_NAMES,
|
||||||
is_recommended_or_frontier_model,
|
is_recommended_or_frontier_model,
|
||||||
request_timeout_extra_args,
|
request_timeout_extra_args,
|
||||||
supports_strict_tool_schemas,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,24 +90,3 @@ def test_frontier_model_families_are_accepted(model_name: str) -> None:
|
|||||||
)
|
)
|
||||||
def test_non_frontier_models_are_rejected(model_name: str) -> None:
|
def test_non_frontier_models_are_rejected(model_name: str) -> None:
|
||||||
assert not is_recommended_or_frontier_model(model_name)
|
assert not is_recommended_or_frontier_model(model_name)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"model_name",
|
|
||||||
[
|
|
||||||
"anthropic/claude-sonnet-4-6",
|
|
||||||
"bedrock/anthropic.claude-opus-4-8-v1:0",
|
|
||||||
"vertex_ai/claude-sonnet-5",
|
|
||||||
"Sonnet-5",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_claude_routes_reject_strict_tool_schemas(model_name: str) -> None:
|
|
||||||
assert not supports_strict_tool_schemas(model_name)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"model_name",
|
|
||||||
["openai/gpt-5.4", "gpt-5.4", "gemini/gemini-3.1-pro-preview", "deepseek/deepseek-v4"],
|
|
||||||
)
|
|
||||||
def test_other_routes_keep_strict_tool_schemas(model_name: str) -> None:
|
|
||||||
assert supports_strict_tool_schemas(model_name)
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any, cast
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
|
||||||
from strix.tools.proxy import caido_api, tools
|
from strix.tools.proxy import caido_api, tools
|
||||||
|
|
||||||
|
|
||||||
@@ -199,31 +198,12 @@ class _Ctx:
|
|||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
|
|
||||||
async def test_ctx_client_returns_client_when_present() -> None:
|
def test_ctx_client_returns_client_when_present() -> None:
|
||||||
client = _FakeClient("host")
|
client = _FakeClient("host")
|
||||||
got = await tools._ctx_client(cast("Any", _Ctx({"caido_client": client})))
|
got = tools._ctx_client(cast("Any", _Ctx({"caido_client": client})))
|
||||||
assert got is client
|
assert got is client
|
||||||
|
|
||||||
|
|
||||||
async def test_ctx_client_returns_none_without_client() -> None:
|
def test_ctx_client_returns_none_without_client() -> None:
|
||||||
assert await tools._ctx_client(cast("Any", _Ctx({}))) is None
|
assert tools._ctx_client(cast("Any", _Ctx({}))) is None
|
||||||
assert await tools._ctx_client(cast("Any", _Ctx(None))) is None
|
assert tools._ctx_client(cast("Any", _Ctx(None))) is None
|
||||||
|
|
||||||
|
|
||||||
async def test_ctx_client_resolves_bootstrap_handle() -> None:
|
|
||||||
client = _FakeClient("host")
|
|
||||||
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
return client
|
|
||||||
|
|
||||||
handle = CaidoBootstrapHandle(asyncio.ensure_future(_bootstrap()))
|
|
||||||
got = await tools._ctx_client(cast("Any", _Ctx({"caido_client": handle})))
|
|
||||||
assert got is client
|
|
||||||
|
|
||||||
|
|
||||||
async def test_ctx_client_degrades_when_bootstrap_failed() -> None:
|
|
||||||
async def _bootstrap() -> Any:
|
|
||||||
raise RuntimeError("caido never came up")
|
|
||||||
|
|
||||||
handle = CaidoBootstrapHandle(asyncio.ensure_future(_bootstrap()))
|
|
||||||
assert await tools._ctx_client(cast("Any", _Ctx({"caido_client": handle}))) is None
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from agents.tool import ToolOutputImage
|
|||||||
|
|
||||||
from strix.config.settings import DEFAULT_MAX_TURNS
|
from strix.config.settings import DEFAULT_MAX_TURNS
|
||||||
from strix.interface.tui.backend.controller import TuiController
|
from strix.interface.tui.backend.controller import TuiController
|
||||||
from strix.interface.tui.backend.projection import bounded_state_projection, terminal_projection
|
from strix.interface.tui.backend.projection import terminal_projection
|
||||||
from strix.interface.tui.backend.protocol import (
|
from strix.interface.tui.backend.protocol import (
|
||||||
MAX_COMMAND_BYTES,
|
MAX_COMMAND_BYTES,
|
||||||
PROTOCOL_CAPABILITIES,
|
PROTOCOL_CAPABILITIES,
|
||||||
@@ -215,11 +215,7 @@ def test_unicode_heavy_setup_state_stays_within_control_frame_limit() -> None:
|
|||||||
"Any",
|
"Any",
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
caido_url="https://例え.example/" + "道" * 10_000,
|
caido_url="https://例え.example/" + "道" * 10_000,
|
||||||
get_total_llm_usage=lambda: {
|
get_total_llm_usage=lambda: {f"model-{index}": "費" * 10_000 for index in range(20)},
|
||||||
"total_tokens": 720_400,
|
|
||||||
"cost": 20.0,
|
|
||||||
**{f"model-{index}": "🔒" * 10_000 for index in range(20)},
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
server = TuiBackendServer(controller)
|
server = TuiBackendServer(controller)
|
||||||
@@ -230,26 +226,6 @@ def test_unicode_heavy_setup_state_stays_within_control_frame_limit() -> None:
|
|||||||
assert len(encoded) <= MAX_COMMAND_BYTES
|
assert len(encoded) <= MAX_COMMAND_BYTES
|
||||||
assert "🔒".encode() in encoded
|
assert "🔒".encode() in encoded
|
||||||
assert snapshot["projection_truncated"] is True
|
assert snapshot["projection_truncated"] is True
|
||||||
assert snapshot["usage"] == {"total_tokens": 720_400, "cost": 20.0}
|
|
||||||
|
|
||||||
|
|
||||||
def test_defensive_state_projection_preserves_usage_summary() -> None:
|
|
||||||
controller = TuiController(args())
|
|
||||||
controller.report_state = cast(
|
|
||||||
"Any",
|
|
||||||
SimpleNamespace(
|
|
||||||
caido_url=None,
|
|
||||||
get_total_llm_usage=lambda: {"total_tokens": 720_400, "cost": 20.0},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
state = controller.snapshot()
|
|
||||||
state["provider"] = None
|
|
||||||
state["future_oversized_field"] = "x" * 100_000
|
|
||||||
|
|
||||||
snapshot = bounded_state_projection(state)
|
|
||||||
|
|
||||||
assert snapshot["projection_truncated"] is True
|
|
||||||
assert snapshot["usage"] == {"total_tokens": 720_400, "cost": 20.0}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+7
-49
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
from strix.core.paths import latest_run_dir, runs_base_dir
|
||||||
from strix.interface.viewer.cli import run_view
|
|
||||||
from strix.interface.viewer.server import serve
|
from strix.interface.viewer.server import serve
|
||||||
from strix.interface.viewer.transcript import (
|
from strix.interface.viewer.transcript import (
|
||||||
build_run_state,
|
build_run_state,
|
||||||
@@ -49,31 +48,6 @@ def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.Mo
|
|||||||
assert runs_base_dir() == tmp_path / "strix_runs"
|
assert runs_base_dir() == tmp_path / "strix_runs"
|
||||||
|
|
||||||
|
|
||||||
def test_view_cli_help_includes_host(capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
try:
|
|
||||||
run_view(["--help"])
|
|
||||||
except SystemExit as exc:
|
|
||||||
assert exc.code == 0
|
|
||||||
else:
|
|
||||||
raise AssertionError("--help should exit")
|
|
||||||
|
|
||||||
help_text = capsys.readouterr().out
|
|
||||||
assert "--host HOST" in help_text
|
|
||||||
assert "0.0.0.0" in help_text
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_can_bind_all_ipv4_interfaces(tmp_path: Path) -> None:
|
|
||||||
run_dir = _make_run(tmp_path, "remote", status="running", end_time=None)
|
|
||||||
|
|
||||||
httpd, url, _ = serve(run_dir, host="0.0.0.0", open_browser=False)
|
|
||||||
try:
|
|
||||||
assert httpd.server_address[0] == "0.0.0.0"
|
|
||||||
assert url == f"http://0.0.0.0:{httpd.server_address[1]}"
|
|
||||||
finally:
|
|
||||||
httpd.shutdown()
|
|
||||||
httpd.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_latest_run_dir_picks_newest_by_record_mtime(
|
def test_latest_run_dir_picks_newest_by_record_mtime(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -199,15 +173,14 @@ def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.Monkey
|
|||||||
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||||
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
||||||
|
|
||||||
httpd, url, token = serve(run_dir, open_browser=False)
|
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||||
try:
|
try:
|
||||||
cookie = _session_cookie(url, token)
|
status, ctype, body = _get(f"{url}/api/run")
|
||||||
status, ctype, body = _get(f"{url}/api/run", cookie=cookie)
|
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert "application/json" in ctype
|
assert "application/json" in ctype
|
||||||
assert json.loads(body)["finished"] is True
|
assert json.loads(body)["finished"] is True
|
||||||
|
|
||||||
status, _, body = _get(f"{url}/api/transcript", cookie=cookie)
|
status, _, body = _get(f"{url}/api/transcript")
|
||||||
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
||||||
|
|
||||||
# Real asset is served.
|
# Real asset is served.
|
||||||
@@ -456,22 +429,6 @@ def test_unauthorized_client_cannot_acquire_capability(
|
|||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
def test_run_data_requires_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
run_dir = _make_run(tmp_path, "private", status="completed", end_time="2026-01-01T00:00:00Z")
|
|
||||||
_bundle(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
httpd, url, token = serve(run_dir, open_browser=False)
|
|
||||||
try:
|
|
||||||
cookie = _session_cookie(url, token)
|
|
||||||
for path in ("/api/run", "/api/vulnerabilities", "/api/report", "/api/transcript"):
|
|
||||||
assert _get_status(url + path) == 403, path
|
|
||||||
assert _get_status(url + path, cookie=f"{_cookie_name(url)}=wrong") == 403, path
|
|
||||||
assert _get_status(url + path, cookie=cookie) == 200, path
|
|
||||||
finally:
|
|
||||||
httpd.shutdown()
|
|
||||||
httpd.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
||||||
_bundle(tmp_path, monkeypatch)
|
_bundle(tmp_path, monkeypatch)
|
||||||
@@ -604,10 +561,11 @@ def test_historical_run_data_requires_verification(
|
|||||||
|
|
||||||
httpd, url, token = serve(launched, open_browser=False)
|
httpd, url, token = serve(launched, open_browser=False)
|
||||||
try:
|
try:
|
||||||
# The launched run needs the session capability, but not email verification.
|
# The launched run is always viewable, no verification and no cookie.
|
||||||
assert _get_status(f"{url}/api/run") == 403
|
status, _, _ = _get(f"{url}/api/run")
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
cookie = _session_cookie(url, token)
|
cookie = _session_cookie(url, token)
|
||||||
assert _get_status(f"{url}/api/run", cookie=cookie) == 200
|
|
||||||
|
|
||||||
# A different run needs the session capability first: a cookie-less
|
# A different run needs the session capability first: a cookie-less
|
||||||
# caller is forbidden even once the machine is verified.
|
# caller is forbidden even once the machine is verified.
|
||||||
|
|||||||
Reference in New Issue
Block a user