mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
296
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2fd1d6103 | ||
|
|
4b76c73c3f | ||
|
|
e5ae8b8ae1 | ||
|
|
b313d78f60 | ||
|
|
e037d8d727 | ||
|
|
fade37025d | ||
|
|
f968f8e5a7 | ||
|
|
ac0014fe65 | ||
|
|
86282e83a8 | ||
|
|
37c7f5a6ba | ||
|
|
082d4ae62c | ||
|
|
c55a8fa4ba | ||
|
|
47617969d3 | ||
|
|
27f9750cdc | ||
|
|
427cdcd9d4 | ||
|
|
384338cf31 | ||
|
|
3b79e97f00 | ||
|
|
66a283b71b | ||
|
|
74f334cb93 | ||
|
|
8e9a6bf903 | ||
|
|
0ebd3c6230 | ||
|
|
6bda366065 | ||
|
|
1f36f5d401 | ||
|
|
a70a87f272 | ||
|
|
d2fbcb726d | ||
|
|
8169e177de | ||
|
|
589bade39a | ||
|
|
d1e8225d5f | ||
|
|
8157ccba27 | ||
|
|
95d2e5fba9 | ||
|
|
21243486e2 | ||
|
|
97ed7e79a1 | ||
|
|
31c18f8f75 | ||
|
|
f23fadfbff | ||
|
|
08126eb518 | ||
|
|
d4f4697533 | ||
|
|
57304b0084 | ||
|
|
cd8270c98b | ||
|
|
93af2b94a2 | ||
|
|
960caf86aa | ||
|
|
7e02b8d8da | ||
|
|
137a42c3e3 | ||
|
|
473b3c4af1 | ||
|
|
d1a73a24f8 | ||
|
|
a2f5e3acb6 | ||
|
|
e8c2564595 | ||
|
|
78594e1645 | ||
|
|
8ec54d9e2b | ||
|
|
1f7373714b | ||
|
|
ef07bad945 | ||
|
|
89a707ff51 | ||
|
|
59f49a1fa2 | ||
|
|
2bb730c366 | ||
|
|
48b4821f69 | ||
|
|
f600f99103 | ||
|
|
6a3e0597ce | ||
|
|
ad27f0c67e | ||
|
|
f967e6017b | ||
|
|
f9890a672d | ||
|
|
599f7c7526 | ||
|
|
8cd9abba21 | ||
|
|
230324d2b8 | ||
|
|
7d5a67d234 | ||
|
|
88ad3e4472 | ||
|
|
cf7689e927 | ||
|
|
3bb95ab43d | ||
|
|
9aa151c687 | ||
|
|
b9c2592b53 | ||
|
|
f54ecb74f9 | ||
|
|
96ca7e544d | ||
|
|
e4548cb28c | ||
|
|
df97c86f8f | ||
|
|
af65796ec0 | ||
|
|
e2eb39a02e | ||
|
|
3a50a5ab0e | ||
|
|
a529d7f73a | ||
|
|
f6bd617964 | ||
|
|
6786d24aca | ||
|
|
89ee7b9e5e | ||
|
|
98990bae45 | ||
|
|
4b619d57a0 | ||
|
|
38c2936f69 | ||
|
|
16982646df | ||
|
|
575e10a404 | ||
|
|
84185db23b | ||
|
|
899e07d3a2 | ||
|
|
914207ffb3 | ||
|
|
40f4e67320 | ||
|
|
d44ca88a18 | ||
|
|
91d9a84716 | ||
|
|
e69c8f6633 | ||
|
|
81a8b2139b | ||
|
|
b959d528a2 | ||
|
|
daf39a2305 | ||
|
|
5304baa424 | ||
|
|
993fd41f32 | ||
|
|
b7a1259593 | ||
|
|
48521deb62 | ||
|
|
d6cefc176a | ||
|
|
a87bfb4881 | ||
|
|
24279e3279 | ||
|
|
4537f33f11 | ||
|
|
ee779987d3 | ||
|
|
a5f28c6b4b | ||
|
|
4b46a748e4 | ||
|
|
205e0b3707 | ||
|
|
c13960ae01 | ||
|
|
22d327d21f | ||
|
|
f528a6d265 | ||
|
|
054725ccb6 | ||
|
|
882664f70b | ||
|
|
e1abac0f0f | ||
|
|
df4bfafcd3 | ||
|
|
5c6cbe0884 | ||
|
|
dd29d99b85 | ||
|
|
9f6d0b106b | ||
|
|
e53b0bd11f | ||
|
|
0bf992ecbf | ||
|
|
f7fa54c12d | ||
|
|
b9994e2e0e | ||
|
|
0fb005c73f | ||
|
|
e1940769de | ||
|
|
9f278b9a5c | ||
|
|
375fc9c3d0 | ||
|
|
754508c70b | ||
|
|
a5112f9433 | ||
|
|
f28ebe3668 | ||
|
|
90cab1bbe3 | ||
|
|
aec5f14455 | ||
|
|
302efedca6 | ||
|
|
7e808f7d34 | ||
|
|
c3997cdb35 | ||
|
|
dc8b790cf8 | ||
|
|
5a1e63aef7 | ||
|
|
e6ca4d2be6 | ||
|
|
5ee34481fe | ||
|
|
f342808d2b | ||
|
|
f554523378 | ||
|
|
69e82f0258 | ||
|
|
52ca641679 | ||
|
|
60d68d85f3 | ||
|
|
777005a42b | ||
|
|
8cdf0683a3 | ||
|
|
7141ccff62 | ||
|
|
962d4459d9 | ||
|
|
11e5d1c2b3 | ||
|
|
cc23eeb65d | ||
|
|
7217abfe23 | ||
|
|
f7e3af49bd | ||
|
|
6202131028 | ||
|
|
45409cef0d | ||
|
|
250fe2cf3e | ||
|
|
6c99829325 | ||
|
|
1c9ab993bb | ||
|
|
04eb03febe | ||
|
|
ac0fef2ed7 | ||
|
|
dcf3155a9a | ||
|
|
36b374bd1b | ||
|
|
1a329e8972 | ||
|
|
143b9e7040 | ||
|
|
3665a7899f | ||
|
|
232711be8c | ||
|
|
712c64f630 | ||
|
|
dee2a03d07 | ||
|
|
1473fc7336 | ||
|
|
dd1f816f7c | ||
|
|
9ab70c6d61 | ||
|
|
13046cc74a | ||
|
|
1aad460f6e | ||
|
|
d0321510d2 | ||
|
|
3bd9d56814 | ||
|
|
63faecd3b5 | ||
|
|
d50827c2d4 | ||
|
|
9c20a8f911 | ||
|
|
8414c59557 | ||
|
|
054eedf53f | ||
|
|
763df86f17 | ||
|
|
d3ab3f836b | ||
|
|
393548c6e8 | ||
|
|
867a0c81fc | ||
|
|
8ed5311b8e | ||
|
|
c88b2bbb99 | ||
|
|
565fd70d08 | ||
|
|
7e117bc500 | ||
|
|
a451766d97 | ||
|
|
418eedcd41 | ||
|
|
fdd8b71f4e | ||
|
|
4f62adf820 | ||
|
|
136e2e6ac2 | ||
|
|
a51e7820f9 | ||
|
|
5921fec16c | ||
|
|
940319f28a | ||
|
|
bdc3c5470e | ||
|
|
2380cf55cb | ||
|
|
dc395316ae | ||
|
|
6b9bd4d5f2 | ||
|
|
e1f38f8339 | ||
|
|
6942ecb33e | ||
|
|
8574119f4d | ||
|
|
67050d9133 | ||
|
|
6f17c7de17 | ||
|
|
a75ad2960e | ||
|
|
6da7315aa3 | ||
|
|
c4d76d72bc | ||
|
|
a61b5a02c5 | ||
|
|
756457f108 | ||
|
|
88fc7be8c1 | ||
|
|
ef50c2dfa6 | ||
|
|
4791feb08e | ||
|
|
af826e1281 | ||
|
|
0a5be6be3f | ||
|
|
629ea60b02 | ||
|
|
c163ef882b | ||
|
|
9f45121dce | ||
|
|
e8b172bd2a | ||
|
|
1d0da89090 | ||
|
|
bd40884fcf | ||
|
|
dc03f1f4ed | ||
|
|
5ec1e0786f | ||
|
|
53188a7583 | ||
|
|
0518599f29 | ||
|
|
ead54ba82c | ||
|
|
8bbb31e075 | ||
|
|
c011c66889 | ||
|
|
e83522cec5 | ||
|
|
671c69327b | ||
|
|
5fd2a64562 | ||
|
|
fb6fdffb40 | ||
|
|
b5ee0c283c | ||
|
|
1c4cb4dc8a | ||
|
|
d538acf66b | ||
|
|
81703e286f | ||
|
|
f8213452ea | ||
|
|
46ff025209 | ||
|
|
9d7f754b59 | ||
|
|
767dc83581 | ||
|
|
72d932f6c4 | ||
|
|
5253332906 | ||
|
|
6bdaa843d9 | ||
|
|
25decb0685 | ||
|
|
f4834cd6f7 | ||
|
|
5896f25cec | ||
|
|
1afd1766cb | ||
|
|
00f5ab33d6 | ||
|
|
fc96716956 | ||
|
|
8f1f473eb8 | ||
|
|
494e6fab0d | ||
|
|
95865401ae | ||
|
|
1e641e56ce | ||
|
|
346cc477a7 | ||
|
|
8a11f9dab5 | ||
|
|
b3f7cfd040 | ||
|
|
6990fd4ef1 | ||
|
|
fe5f749e13 | ||
|
|
295d43b3ab | ||
|
|
5d8436cbbb | ||
|
|
ab3da5c0b0 | ||
|
|
cd1bb46d50 | ||
|
|
2c2ab13c8f | ||
|
|
5449af2456 | ||
|
|
9b31e9fd29 | ||
|
|
df51eeedd0 | ||
|
|
12baf2d792 | ||
|
|
28416c5ae9 | ||
|
|
b65e4ebd52 | ||
|
|
a6d578c4a8 | ||
|
|
49c38de3b2 | ||
|
|
d959fe2163 | ||
|
|
f08ad2a634 | ||
|
|
369fa56148 | ||
|
|
4146174503 | ||
|
|
e4be5f9588 | ||
|
|
6435e07dc2 | ||
|
|
dc9b9f5f9c | ||
|
|
572ef2a2af | ||
|
|
af42499b95 | ||
|
|
d8881498ee | ||
|
|
4e0d0f35d9 | ||
|
|
f0e254c1fd | ||
|
|
1d86e4506a | ||
|
|
1ac32df817 | ||
|
|
044e4e82ae | ||
|
|
57478e5d0d | ||
|
|
6e5d96af34 | ||
|
|
375389b8bc | ||
|
|
3652b449d1 | ||
|
|
d9748a44db | ||
|
|
a35a4a22b1 | ||
|
|
9fb101282f | ||
|
|
60abc09ff9 | ||
|
|
8841294d94 | ||
|
|
5c13348393 | ||
|
|
15c95718e6 | ||
|
|
62e9af36d2 | ||
|
|
38b2700553 | ||
|
|
e78c931e4e |
@@ -6,6 +6,9 @@ on:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -16,29 +19,47 @@ jobs:
|
||||
target: macos-arm64
|
||||
- os: macos-15-intel
|
||||
target: macos-x86_64
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-22.04
|
||||
target: linux-x86_64
|
||||
- os: ubuntu-22.04-arm
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
target: windows-x86_64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: snok/install-poetry@v1
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
poetry install --with dev
|
||||
poetry run pyinstaller strix.spec --noconfirm
|
||||
uv sync --frozen
|
||||
uv run pyinstaller strix.spec --noconfirm
|
||||
|
||||
VERSION=$(poetry version -s)
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
dist/strix.exe --version
|
||||
else
|
||||
dist/strix --version
|
||||
fi
|
||||
|
||||
if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then
|
||||
file dist/strix
|
||||
file dist/strix | grep -q "ARM aarch64" || {
|
||||
echo "::error::linux-arm64 artifact is not an ARM aarch64 binary"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
mkdir -p dist/release
|
||||
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
@@ -50,7 +71,7 @@ jobs:
|
||||
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: strix-${{ matrix.target }}
|
||||
path: |
|
||||
@@ -65,13 +86,13 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
with:
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
generate_release_notes: true
|
||||
|
||||
+12
-16
@@ -1,17 +1,25 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/interface/viewer/static/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/interface/viewer/frontend/node_modules/
|
||||
strix/interface/viewer/frontend/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
# Anchored to the repo root: these are Python build-artifact dir names, but
|
||||
# unanchored they also match nested source dirs (e.g. the viewer's src/lib).
|
||||
/build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -39,18 +47,6 @@ 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
|
||||
@@ -58,7 +54,7 @@ htmlcov/
|
||||
.env.production.local
|
||||
|
||||
# MongoDB
|
||||
data/
|
||||
/data/
|
||||
mongod.log
|
||||
*.mongodb
|
||||
*.mongorc.js
|
||||
|
||||
@@ -11,7 +11,7 @@ repos:
|
||||
|
||||
# MyPy for static type checking
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.16.0
|
||||
rev: v1.17.1
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [
|
||||
@@ -19,6 +19,8 @@ repos:
|
||||
types-python-dateutil,
|
||||
pydantic,
|
||||
fastapi,
|
||||
pytest,
|
||||
"openai-agents[litellm]==0.14.6",
|
||||
]
|
||||
args: [--install-types, --non-interactive]
|
||||
|
||||
@@ -31,6 +33,7 @@ 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
|
||||
@@ -44,7 +47,7 @@ repos:
|
||||
|
||||
# Additional Python code quality checks
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.20.0
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
|
||||
+18
-4
@@ -8,7 +8,7 @@ Thank you for your interest in contributing to Strix! This guide will help you g
|
||||
|
||||
- Python 3.12+
|
||||
- Docker (running)
|
||||
- Poetry (for dependency management)
|
||||
- [uv](https://docs.astral.sh/uv/) (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:
|
||||
poetry install --with=dev
|
||||
poetry run pre-commit install
|
||||
uv sync
|
||||
uv 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
|
||||
poetry run strix --target https://example.com
|
||||
uv run strix --target https://example.com
|
||||
```
|
||||
|
||||
## 📚 Contributing Skills
|
||||
@@ -99,6 +99,20 @@ We welcome feature ideas! Please:
|
||||
- Consider implementation approach
|
||||
- Be open to discussion
|
||||
|
||||
## 🖥️ Local viewer SPA
|
||||
|
||||
`strix view` serves a prebuilt web UI whose source lives in
|
||||
`strix/interface/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/interface/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/interface/viewer/frontend/`, rebuild
|
||||
and commit the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/interface/viewer/static/`.
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
- **Discord**: [Join our community](https://discord.gg/strix-ai)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev viewer
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@@ -8,83 +8,69 @@ help:
|
||||
@echo ""
|
||||
@echo "Code Quality:"
|
||||
@echo " format - Format code with ruff"
|
||||
@echo " lint - Lint code with ruff and pylint"
|
||||
@echo " lint - Lint code with ruff"
|
||||
@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 " viewer - Rebuild the local-viewer SPA (commit the output)"
|
||||
@echo " clean - Clean up cache files and artifacts"
|
||||
|
||||
install:
|
||||
poetry install --only=main
|
||||
uv sync --no-dev
|
||||
|
||||
dev-install:
|
||||
poetry install --with=dev
|
||||
uv sync
|
||||
|
||||
setup-dev: dev-install
|
||||
poetry run pre-commit install
|
||||
uv 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..."
|
||||
poetry run ruff format .
|
||||
uv run ruff format .
|
||||
@echo "✅ Code formatting complete!"
|
||||
|
||||
lint:
|
||||
@echo "🔍 Linting code with ruff..."
|
||||
poetry run ruff check . --fix
|
||||
@echo "📝 Running additional linting with pylint..."
|
||||
poetry run pylint strix/ --score=no --reports=no
|
||||
uv run ruff check . --fix
|
||||
@echo "✅ Linting complete!"
|
||||
|
||||
type-check:
|
||||
@echo "🔍 Type checking with mypy..."
|
||||
poetry run mypy strix/
|
||||
uv run mypy strix/
|
||||
@echo "🔍 Type checking with pyright..."
|
||||
poetry run pyright strix/
|
||||
uv run pyright strix/
|
||||
@echo "✅ Type checking complete!"
|
||||
|
||||
security:
|
||||
@echo "🔒 Running security checks with bandit..."
|
||||
poetry run bandit -r strix/ -c pyproject.toml
|
||||
uv 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..."
|
||||
poetry run pre-commit run --all-files
|
||||
uv 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 test
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/interface/viewer/static/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
# Strix
|
||||
|
||||
### Open-source AI hackers to find and fix your app’s vulnerabilities.
|
||||
### The open-source AI pentesting tool. Autonomous AI hackers that find and fix your app’s vulnerabilities.
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -27,29 +27,29 @@
|
||||
<a href="https://x.com/strix_ai"><img src="https://github.com/usestrix/.github/raw/main/imgs/X.png" height="40" alt="Follow on X"></a>
|
||||
|
||||
|
||||
<a href="https://trendshift.io/repositories/15362?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-15362" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/15362/weekly" alt="usestrix%2Fstrix | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/15362" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15362" alt="usestrix/strix | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
</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!
|
||||
> **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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Strix Overview
|
||||
|
||||
Strix are autonomous AI agents that act just like real hackers - they run your code dynamically, find vulnerabilities, and validate them through actual proof-of-concepts. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
|
||||
Strix are autonomous AI penetration testing agents that act just like real hackers - they run your code dynamically, find vulnerabilities, and validate them through actual proofs-of-concept. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
|
||||
|
||||
**Key Capabilities:**
|
||||
|
||||
- **Full hacker toolkit** out of the box
|
||||
- **Teams of agents** that collaborate and scale
|
||||
- **Real validation** with PoCs, not false positives
|
||||
- **Developer‑first** CLI with actionable reports
|
||||
- **Auto‑fix & reporting** to accelerate remediation
|
||||
- **Full pentesting toolkit** - reconnaissance, exploitation, and validation out of the box
|
||||
- **Multi-agent orchestration** - teams of AI pentesters that collaborate and scale
|
||||
- **Real exploit validation** - working PoCs, not false positives like legacy vulnerability scanners
|
||||
- **Developer‑first CLI** - actionable findings with remediation guidance
|
||||
- **Auto‑fix & reporting** - generate patches and compliance-ready pentest reports
|
||||
|
||||
|
||||
<br>
|
||||
@@ -96,13 +96,13 @@ strix --target ./app-directory
|
||||
|
||||
## ☁️ Strix Platform
|
||||
|
||||
Try the Strix full-stack security platform at **[app.strix.ai](https://app.strix.ai)** — sign up for free, connect your repos and domains, and launch a pentest in minutes.
|
||||
Try the Strix full-stack penetration testing platform at **[app.strix.ai](https://app.strix.ai)** - sign up for free, connect your repos and domains, and launch a pentest in minutes.
|
||||
|
||||
- **Validated findings with PoCs** and reproduction steps
|
||||
- **One-click autofix** as ready-to-merge pull requests
|
||||
- **Continuous monitoring** across code, cloud, and infrastructure
|
||||
- **Integrations** with GitHub, Slack, Jira, Linear, and CI/CD pipelines
|
||||
- **Continuous learning** that builds on past findings and remediations
|
||||
- **Validated findings with PoCs** - every vulnerability includes a working proof-of-concept exploit and reproduction steps
|
||||
- **One-click autofix** - AI-generated security patches as ready-to-merge pull requests
|
||||
- **Continuous pentesting** - always-on vulnerability scanning that keeps pace with your deployments
|
||||
- **DevSecOps integrations** - GitHub, GitLab, Bitbucket, Slack, Jira, Linear, and CI/CD pipelines
|
||||
- **Continuous learning** - AI that builds on past findings, adapts to your codebase, and reduces false positives over time
|
||||
|
||||
[**Start your first pentest →**](https://app.strix.ai)
|
||||
|
||||
@@ -110,37 +110,63 @@ Try the Strix full-stack security platform at **[app.strix.ai](https://app.strix
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Agentic Security Tools
|
||||
### Agentic Pentesting Tools
|
||||
|
||||
Strix agents come equipped with a comprehensive security testing toolkit:
|
||||
Strix agents come equipped with a comprehensive offensive security toolkit - the same tools used by professional penetration testers and ethical hackers:
|
||||
|
||||
- **Full HTTP Proxy** - Full request/response manipulation and analysis
|
||||
- **Browser Automation** - Multi-tab browser for testing of XSS, CSRF, auth flows
|
||||
- **Terminal Environments** - Interactive shells for command execution and testing
|
||||
- **Python Runtime** - Custom exploit development and validation
|
||||
- **Reconnaissance** - Automated OSINT and attack surface mapping
|
||||
- **Code Analysis** - Static and dynamic analysis capabilities
|
||||
- **Knowledge Management** - Structured findings and attack documentation
|
||||
- **HTTP Interception Proxy** - Full request/response manipulation and analysis with Caido
|
||||
- **Browser Exploitation** - Automated browser for testing XSS, CSRF, clickjacking, and auth bypass flows
|
||||
- **Shell & Command Execution** - Interactive terminal for exploit development and post-exploitation
|
||||
- **Custom Exploit Runtime** - Python sandbox for writing and validating proof-of-concept exploits
|
||||
- **Reconnaissance & OSINT** - Automated attack surface mapping, subdomain enumeration, and fingerprinting
|
||||
- **Static & Dynamic Code Analysis** - SAST + DAST capabilities for comprehensive application security testing
|
||||
- **Vulnerability Knowledge Base** - Structured findings with CVSS scoring and OWASP classification
|
||||
|
||||
### Comprehensive Vulnerability Detection
|
||||
### Comprehensive Vulnerability Scanner
|
||||
|
||||
Strix can identify and validate a wide range of security vulnerabilities:
|
||||
Strix identifies, validates, and exploits a wide range of security vulnerabilities across the OWASP Top 10 and beyond:
|
||||
|
||||
- **Access Control** - IDOR, privilege escalation, auth bypass
|
||||
- **Injection Attacks** - SQL, NoSQL, command injection
|
||||
- **Server-Side** - SSRF, XXE, deserialization flaws
|
||||
- **Client-Side** - XSS, prototype pollution, DOM vulnerabilities
|
||||
- **Business Logic** - Race conditions, workflow manipulation
|
||||
- **Authentication** - JWT vulnerabilities, session management
|
||||
- **Infrastructure** - Misconfigurations, exposed services
|
||||
- **Broken Access Control** - IDOR, privilege escalation, auth bypass
|
||||
- **Injection Attacks** - SQL injection, NoSQL injection, OS command injection, SSTI
|
||||
- **Server-Side Vulnerabilities** - SSRF, XXE, insecure deserialization, RCE
|
||||
- **Client-Side Attacks** - XSS (stored/reflected/DOM), prototype pollution, CSRF
|
||||
- **Business Logic Flaws** - Race conditions, payment manipulation, workflow bypass
|
||||
- **Authentication & Session** - JWT attacks, session fixation, credential stuffing vectors
|
||||
- **Infrastructure & Cloud** - Misconfigurations, exposed services, cloud security issues
|
||||
- **API Security** - Broken authentication, mass assignment, rate limiting bypass
|
||||
|
||||
### Graph of Agents
|
||||
### Graph of Agents (Multi-Agent Pentesting)
|
||||
|
||||
Advanced multi-agent orchestration for comprehensive security testing:
|
||||
Advanced multi-agent orchestration for comprehensive automated penetration testing:
|
||||
|
||||
- **Distributed Workflows** - Specialized agents for different attacks and assets
|
||||
- **Scalable Testing** - Parallel execution for fast comprehensive coverage
|
||||
- **Dynamic Coordination** - Agents collaborate and share discoveries
|
||||
- **Distributed Pentesting** - Specialized AI agents for recon, exploitation, and post-exploitation
|
||||
- **Scalable Security Testing** - Parallel execution across multiple targets for fast, comprehensive coverage
|
||||
- **Dynamic Coordination** - Agents share discoveries, chain vulnerabilities, and collaborate like a red team
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Local Web Viewer
|
||||
|
||||
Every scan writes its results to disk as it runs. Bring them up in a local dashboard with a single command:
|
||||
|
||||
```bash
|
||||
# Open the most recent run
|
||||
strix view
|
||||
|
||||
# ...or open a specific run by name
|
||||
strix view my-run-name
|
||||
```
|
||||
|
||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||
|
||||
### What's in the dashboard
|
||||
|
||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities**: each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph**: a live map of the multi-agent team, showing which agent is doing what.
|
||||
- **Steering**: send instructions to a live scan from the browser to redirect the agents mid-run.
|
||||
- **History**: browse past runs on this machine and jump between them.
|
||||
- **Reports**: generate a shareable report and email it to yourself or your team.
|
||||
|
||||
---
|
||||
|
||||
@@ -168,16 +194,25 @@ strix --target https://your-app.com --instruction "Perform authenticated testing
|
||||
# Multi-target testing (source code + deployed app)
|
||||
strix -t https://github.com/org/app -t https://your-app.com
|
||||
|
||||
# Targets from a file, one target per non-empty, non-comment line
|
||||
strix --target-list ./targets.txt
|
||||
|
||||
# White-box source-aware scan (local repository)
|
||||
strix --target ./app-directory --scan-mode standard
|
||||
|
||||
# Focused testing with custom instructions
|
||||
strix --target api.your-app.com --instruction "Focus on business logic flaws and IDOR vulnerabilities"
|
||||
|
||||
# Provide detailed instructions through file (e.g., rules of engagement, scope, exclusions)
|
||||
strix --target api.your-app.com --instruction-file ./instruction.md
|
||||
|
||||
# Force PR diff-scope against a specific base branch
|
||||
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||
```
|
||||
|
||||
### Headless Mode
|
||||
|
||||
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag—perfect for servers and automated jobs. The CLI prints real-time vulnerability findings, and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
|
||||
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag - perfect for servers and automated jobs. The CLI prints real-time vulnerability findings and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
|
||||
|
||||
```bash
|
||||
strix -n --target https://your-app.com
|
||||
@@ -198,6 +233,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Strix
|
||||
run: curl -sSL https://strix.ai/install | bash
|
||||
@@ -210,6 +247,11 @@ jobs:
|
||||
run: strix -n -t ./ --scan-mode quick
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> In CI pull request runs, Strix automatically scopes quick reviews to changed files.
|
||||
> If diff-scope cannot resolve, ensure checkout uses full history (`fetch-depth: 0`) or pass
|
||||
> `--diff-base` explicitly.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
@@ -225,21 +267,35 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high,
|
||||
> [!NOTE]
|
||||
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
||||
|
||||
#### Sign in with a ChatGPT subscription
|
||||
|
||||
Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription:
|
||||
|
||||
```bash
|
||||
strix auth login chatgpt # sign in with your ChatGPT account
|
||||
|
||||
export STRIX_LLM="chatgpt/gpt-5.4" # chatgpt/<model> runs on the subscription
|
||||
strix --target ./app-directory
|
||||
|
||||
strix auth status # show the active sign-in
|
||||
strix auth logout # forget the sign-in
|
||||
```
|
||||
|
||||
**Recommended models for best results:**
|
||||
|
||||
- [OpenAI GPT-5.4](https://openai.com/api/) — `openai/gpt-5.4`
|
||||
- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) — `anthropic/claude-sonnet-4-6`
|
||||
- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) — `vertex_ai/gemini-3-pro-preview`
|
||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||
- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) - `anthropic/claude-sonnet-4-6`
|
||||
- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) - `vertex_ai/gemini-3-pro-preview`
|
||||
|
||||
See the [LLM Providers documentation](https://docs.strix.ai/llm-providers/overview) for all supported providers including Vertex AI, Bedrock, Azure, and local models.
|
||||
|
||||
## Enterprise
|
||||
## Enterprise Pentesting
|
||||
|
||||
Get the same Strix experience with [enterprise-grade](https://strix.ai/demo) controls: SSO (SAML/OIDC), custom compliance reports, dedicated support & SLA, custom deployment options (VPC/self-hosted), BYOK model support, and tailored agents optimized for your environment. [Learn more](https://strix.ai/demo).
|
||||
Get the same Strix experience with [enterprise-grade](https://strix.ai/demo) controls: SSO (SAML/OIDC), custom compliance-ready penetration testing reports (SOC 2, ISO 27001, PCI DSS), dedicated support & SLA, custom deployment options (VPC/self-hosted), BYOK model support, and tailored AI pentesting agents optimized for your environment. [Learn more](https://strix.ai/demo).
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available at **[docs.strix.ai](https://docs.strix.ai)** — including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.
|
||||
Full documentation is available at **[docs.strix.ai](https://docs.strix.ai)** - including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+124
-57
@@ -1,3 +1,26 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builder stage: compile the Go tools here so the Go toolchain (~225MB) and the
|
||||
# module/build caches never reach the runtime image. The resulting binaries are
|
||||
# statically linked and copied into the final stage.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM kalilinux/kali-rolling:latest AS gobuilder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y kali-archive-keyring && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends golang-go git ca-certificates
|
||||
|
||||
ENV GOBIN=/out/bin
|
||||
RUN mkdir -p /out/bin && \
|
||||
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM kalilinux/kali-rolling:latest
|
||||
|
||||
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
|
||||
@@ -12,38 +35,28 @@ RUN useradd -m -s /bin/bash pentester && \
|
||||
echo "pentester ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers && \
|
||||
touch /home/pentester/.hushlogin
|
||||
|
||||
RUN mkdir -p /home/pentester/configs \
|
||||
/home/pentester/wordlists \
|
||||
/home/pentester/output \
|
||||
/home/pentester/scripts \
|
||||
/home/pentester/tools \
|
||||
/app/runtime \
|
||||
/app/tools \
|
||||
/app/certs && \
|
||||
RUN mkdir -p /home/pentester/tools /app/certs && \
|
||||
chown -R pentester:pentester /app/certs /home/pentester/tools
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl git vim nano unzip tar \
|
||||
apt-transport-https ca-certificates gnupg lsb-release \
|
||||
build-essential software-properties-common \
|
||||
gcc libc6-dev pkg-config libpcap-dev libssl-dev \
|
||||
python3 python3-pip python3-dev python3-venv python3-setuptools \
|
||||
golang-go \
|
||||
software-properties-common \
|
||||
gcc libc6-dev \
|
||||
python3 python3-pip python3-venv python3-setuptools \
|
||||
net-tools dnsutils whois \
|
||||
file xxd \
|
||||
jq parallel ripgrep grep \
|
||||
less man-db procps htop \
|
||||
less procps htop \
|
||||
iproute2 iputils-ping netcat-traditional \
|
||||
nmap ncat ndiff \
|
||||
sqlmap nuclei subfinder naabu ffuf \
|
||||
nodejs npm pipx \
|
||||
libcap2-bin \
|
||||
gdb \
|
||||
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
|
||||
libnss3-tools \
|
||||
chromium fonts-liberation
|
||||
|
||||
|
||||
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
|
||||
@@ -70,26 +83,19 @@ USER root
|
||||
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
|
||||
update-ca-certificates
|
||||
|
||||
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
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||
|
||||
USER pentester
|
||||
WORKDIR /tmp
|
||||
|
||||
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
# Go tools are built in the gobuilder stage; copy the static binaries only.
|
||||
COPY --from=gobuilder --chown=pentester:pentester /out/bin/ /home/pentester/go/bin/
|
||||
|
||||
RUN nuclei -update-templates
|
||||
|
||||
RUN pipx install arjun && \
|
||||
pipx install dirsearch && \
|
||||
pipx inject dirsearch setuptools && \
|
||||
pipx inject dirsearch 'setuptools<81' && \
|
||||
pipx install wafw00f
|
||||
|
||||
ENV NPM_CONFIG_PREFIX=/home/pentester/.npm-global
|
||||
@@ -97,7 +103,46 @@ RUN mkdir -p /home/pentester/.npm-global
|
||||
|
||||
RUN npm install -g retire@latest && \
|
||||
npm install -g eslint@latest && \
|
||||
npm install -g js-beautify@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 && \
|
||||
npm cache clean --force && \
|
||||
# ast-grep ships two identical binaries (`ast-grep` and `sg`); dedupe (~52MB)
|
||||
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
|
||||
|
||||
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
|
||||
|
||||
RUN set -eux; \
|
||||
TS_PARSER_DIR="/home/pentester/.tree-sitter/parsers"; \
|
||||
mkdir -p "${TS_PARSER_DIR}"; \
|
||||
for repo in tree-sitter-java tree-sitter-javascript tree-sitter-python tree-sitter-go tree-sitter-bash tree-sitter-json tree-sitter-yaml tree-sitter-typescript; do \
|
||||
if [ "$repo" = "tree-sitter-yaml" ]; then \
|
||||
repo_url="https://github.com/tree-sitter-grammars/${repo}.git"; \
|
||||
else \
|
||||
repo_url="https://github.com/tree-sitter/${repo}.git"; \
|
||||
fi; \
|
||||
if [ ! -d "${TS_PARSER_DIR}/${repo}" ]; then \
|
||||
git clone --depth 1 "${repo_url}" "${TS_PARSER_DIR}/${repo}"; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ -d "${TS_PARSER_DIR}/tree-sitter-typescript/typescript" ]; then \
|
||||
ln -sfn "${TS_PARSER_DIR}/tree-sitter-typescript/typescript" "${TS_PARSER_DIR}/tree-sitter-typescript-typescript"; \
|
||||
fi; \
|
||||
if [ -d "${TS_PARSER_DIR}/tree-sitter-typescript/tsx" ]; then \
|
||||
ln -sfn "${TS_PARSER_DIR}/tree-sitter-typescript/tsx" "${TS_PARSER_DIR}/tree-sitter-typescript-tsx"; \
|
||||
fi; \
|
||||
tree-sitter init-config >/dev/null 2>&1 || true; \
|
||||
TS_CONFIG="/home/pentester/.config/tree-sitter/config.json"; \
|
||||
mkdir -p "$(dirname "${TS_CONFIG}")"; \
|
||||
[ -f "${TS_CONFIG}" ] || printf '{}\n' > "${TS_CONFIG}"; \
|
||||
TMP_CFG="$(mktemp)"; \
|
||||
jq --arg p "${TS_PARSER_DIR}" '.["parser-directories"] = ((.["parser-directories"] // []) + [$p] | unique)' "${TS_CONFIG}" > "${TMP_CFG}"; \
|
||||
mv "${TMP_CFG}" "${TS_CONFIG}"
|
||||
|
||||
WORKDIR /home/pentester/tools
|
||||
RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
|
||||
@@ -109,9 +154,26 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
|
||||
|
||||
USER root
|
||||
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
RUN apt-get update && apt-get install -y zaproxy
|
||||
# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update
|
||||
# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot
|
||||
# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with
|
||||
# "cannot move binary" and aborts the scan. Pin the initial version for
|
||||
# reproducible builds; self-update then pulls fresh detectors at runtime.
|
||||
ARG TRUFFLEHOG_VERSION=3.95.9
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \
|
||||
chown -R pentester:pentester /home/pentester/.local
|
||||
RUN set -eux; \
|
||||
ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
x86_64) GITLEAKS_ARCH="x64" ;; \
|
||||
aarch64|arm64) GITLEAKS_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
TAG="$(curl -fsSL https://api.github.com/repos/gitleaks/gitleaks/releases/latest | jq -r .tag_name)"; \
|
||||
curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/${TAG}/gitleaks_${TAG#v}_linux_${GITLEAKS_ARCH}.tar.gz" -o /tmp/gitleaks.tgz; \
|
||||
tar -xzf /tmp/gitleaks.tgz -C /tmp; \
|
||||
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks; \
|
||||
rm -f /tmp/gitleaks /tmp/gitleaks.tgz
|
||||
|
||||
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
@@ -128,14 +190,19 @@ USER root
|
||||
|
||||
RUN apt-get autoremove -y && \
|
||||
apt-get autoclean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
# Purge non-English locales (~160MB)
|
||||
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name 'en' ! -name 'en_US' ! -name 'C' -exec rm -rf {} + && \
|
||||
# Remove package documentation and man pages not needed at runtime (~95MB)
|
||||
rm -rf /usr/share/doc/* /usr/share/doc-base/* /usr/share/man/*
|
||||
|
||||
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"
|
||||
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
|
||||
ENV VIRTUAL_ENV="/app/.venv"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG CAIDO_VERSION=0.56.0
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
CAIDO_ARCH="x86_64"; \
|
||||
@@ -144,39 +211,39 @@ RUN ARCH=$(uname -m) && \
|
||||
else \
|
||||
echo "Unsupported architecture: $ARCH" && exit 1; \
|
||||
fi && \
|
||||
wget -O caido-cli.tar.gz https://caido.download/releases/v0.48.0/caido-cli-v0.48.0-linux-${CAIDO_ARCH}.tar.gz && \
|
||||
wget -O caido-cli.tar.gz "https://caido.download/releases/v${CAIDO_VERSION}/caido-cli-v${CAIDO_VERSION}-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
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
|
||||
USER pentester
|
||||
RUN poetry install --no-root --without dev --extras sandbox
|
||||
RUN poetry run playwright install chromium
|
||||
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 \
|
||||
requests httpx beautifulsoup4 lxml pyjwt cryptography && \
|
||||
/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
|
||||
|
||||
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
|
||||
COPY --chown=pentester:pentester strix/tools/proxy/caido_api.py /opt/strix-python/caido_api.py
|
||||
ENV PYTHONPATH=/opt/strix-python
|
||||
|
||||
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
|
||||
# Login shells (e.g. `bash -lc`) source /etc/profile, which on Debian/Kali
|
||||
# hard-resets PATH and drops the image's ENV PATH entries. Re-add the same
|
||||
# directories here — including /app/.venv/bin — so `python3`/`pip` resolve to
|
||||
# the venv (which ships requests, httpx, bs4, lxml, pyjwt, cryptography, and the
|
||||
# Caido SDK) instead of the externally-managed system interpreter.
|
||||
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.bashrc && \
|
||||
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.profile
|
||||
|
||||
USER root
|
||||
COPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
@@ -9,10 +9,24 @@ if [ ! -f /app/certs/ca.p12 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Caido enforces a Host allowlist (DNS-rebinding protection) and rejects requests
|
||||
# whose Host header is a hostname it doesn't recognize. To reach Caido over a
|
||||
# hostname (rather than an IP literal), set STRIX_CAIDO_ALLOWED_DOMAINS to a
|
||||
# comma-separated list of hostnames to allow. Unset by default.
|
||||
# See https://docs.caido.io/app/guides/domain_allowlist
|
||||
CAIDO_UI_DOMAIN_ARGS=()
|
||||
if [ -n "${STRIX_CAIDO_ALLOWED_DOMAINS:-}" ]; then
|
||||
IFS=',' read -ra _caido_domains <<< "${STRIX_CAIDO_ALLOWED_DOMAINS}"
|
||||
for _d in "${_caido_domains[@]}"; do
|
||||
[ -n "$_d" ] && CAIDO_UI_DOMAIN_ARGS+=(--ui-domain "$_d")
|
||||
done
|
||||
fi
|
||||
|
||||
caido-cli --listen 0.0.0.0:${CAIDO_PORT} \
|
||||
--allow-guests \
|
||||
--no-logging \
|
||||
--no-open \
|
||||
"${CAIDO_UI_DOMAIN_ARGS[@]}" \
|
||||
--import-ca-cert /app/certs/ca.p12 \
|
||||
--import-ca-cert-pass "" > "$CAIDO_LOG" 2>&1 &
|
||||
|
||||
@@ -47,68 +61,7 @@ fi
|
||||
|
||||
sleep 2
|
||||
|
||||
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 "Caido is up — host bootstraps the guest token + project via the Python SDK."
|
||||
|
||||
echo "Configuring system-wide proxy settings..."
|
||||
|
||||
@@ -118,9 +71,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
|
||||
@@ -129,7 +82,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}
|
||||
CAIDO_API_TOKEN=${TOKEN}
|
||||
NO_PROXY=localhost,127.0.0.1
|
||||
EOF
|
||||
|
||||
cat << EOF | sudo tee /etc/wgetrc
|
||||
@@ -138,10 +91,13 @@ http_proxy=http://127.0.0.1:${CAIDO_PORT}
|
||||
https_proxy=http://127.0.0.1:${CAIDO_PORT}
|
||||
EOF
|
||||
|
||||
echo "source /etc/profile.d/proxy.sh" >> ~/.bashrc
|
||||
echo "source /etc/profile.d/proxy.sh" >> ~/.zshrc
|
||||
# Use POSIX `.` (not the bashism `source`) so these lines are safe when the rc
|
||||
# files are read by a POSIX shell (e.g. `sh -lc`), which otherwise fails with
|
||||
# "source: not found". `.` is understood by bash, zsh, and dash alike.
|
||||
echo ". /etc/profile.d/proxy.sh" >> ~/.bashrc
|
||||
echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc
|
||||
|
||||
source /etc/profile.d/proxy.sh
|
||||
. /etc/profile.d/proxy.sh
|
||||
|
||||
echo "✅ System-wide proxy configuration complete"
|
||||
|
||||
@@ -151,34 +107,7 @@ 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"
|
||||
|
||||
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
|
||||
mkdir -p /workspace/.agent-browser-screenshots
|
||||
|
||||
echo "✅ Container ready"
|
||||
|
||||
|
||||
@@ -35,26 +35,39 @@ Configure Strix using environment variables or a config file.
|
||||
Timeout in seconds for memory compression operations (context summarization).
|
||||
</ParamField>
|
||||
|
||||
### Dedicated deduplication model
|
||||
|
||||
Finding deduplication is a cheap, structured classification task. By default it
|
||||
runs on the main model, but you can route it to a smaller/cheaper model without
|
||||
affecting the agents that do the actual testing.
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
|
||||
Model used to judge whether a candidate finding duplicates an existing report.
|
||||
Falls back to `STRIX_LLM` when unset.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
|
||||
Optional provider key for the deduplication model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
|
||||
Optional custom API base URL for the deduplication model. Use when the dedupe
|
||||
model runs on a different endpoint than the main model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
|
||||
Reasoning effort for the deduplication model. Defaults to the model's own
|
||||
baseline when unset.
|
||||
</ParamField>
|
||||
|
||||
## Optional Features
|
||||
|
||||
<ParamField path="PERPLEXITY_API_KEY" type="string">
|
||||
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">
|
||||
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`.
|
||||
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="TRACELOOP_BASE_URL" type="string">
|
||||
@@ -79,7 +92,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:0.1.13" type="string">
|
||||
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.0.0" type="string">
|
||||
Docker image to use for the sandbox container.
|
||||
</ParamField>
|
||||
|
||||
@@ -91,6 +104,10 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
|
||||
Runtime backend for the sandbox environment.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_MAX_LOCAL_COPY_MB" default="1024" type="integer">
|
||||
Maximum size (in MB) of a local directory target that Strix will copy into the sandbox file-by-file. Larger targets exit early with a suggestion to use `--mount` instead. Set to `0` to disable the check.
|
||||
</ParamField>
|
||||
|
||||
## Sandbox Configuration
|
||||
|
||||
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
|
||||
|
||||
@@ -81,6 +81,14 @@ Protocol-specific testing techniques.
|
||||
| --------- | ------------------------------------------------ |
|
||||
| `graphql` | GraphQL introspection, batching, resolver issues |
|
||||
|
||||
### Reconnaissance
|
||||
|
||||
Passive discovery and attack-surface mapping techniques.
|
||||
|
||||
| Skill | Coverage |
|
||||
| ----------------- | --------------------------------------------------------------- |
|
||||
| `asset_discovery` | CT, TLS SAN pivoting, passive DNS, and ASN/IP asset enumeration |
|
||||
|
||||
### Tooling
|
||||
|
||||
Sandbox CLI playbooks for core recon and scanning tools.
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Contribute to Strix development"
|
||||
|
||||
- Python 3.12+
|
||||
- Docker (running)
|
||||
- Poetry
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- Git
|
||||
|
||||
### Local Development
|
||||
@@ -26,8 +26,8 @@ description: "Contribute to Strix development"
|
||||
make setup-dev
|
||||
|
||||
# or manually:
|
||||
poetry install --with=dev
|
||||
poetry run pre-commit install
|
||||
uv sync
|
||||
uv run pre-commit install
|
||||
```
|
||||
</Step>
|
||||
<Step title="Configure LLM">
|
||||
@@ -38,7 +38,7 @@ description: "Contribute to Strix development"
|
||||
</Step>
|
||||
<Step title="Run Strix">
|
||||
```bash
|
||||
poetry run strix --target https://example.com
|
||||
uv run strix --target https://example.com
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"llm-providers/vertex",
|
||||
"llm-providers/bedrock",
|
||||
"llm-providers/azure",
|
||||
"llm-providers/novita",
|
||||
"llm-providers/local"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -13,6 +13,12 @@ Use the `-n` or `--non-interactive` flag:
|
||||
strix -n --target ./app --scan-mode quick
|
||||
```
|
||||
|
||||
For pull-request style CI runs, Strix automatically scopes quick scans to changed files. You can force this behavior and set a base ref explicitly:
|
||||
|
||||
```bash
|
||||
strix -n --target ./app --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
@@ -78,3 +84,7 @@ jobs:
|
||||
<Note>
|
||||
All CI platforms require Docker access. Ensure your runner has Docker available.
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
If diff-scope fails in CI, fetch full git history (for example, `fetch-depth: 0` in GitHub Actions) so merge-base and branch comparison can be resolved.
|
||||
</Tip>
|
||||
|
||||
@@ -18,6 +18,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Strix
|
||||
run: curl -sSL https://strix.ai/install | bash
|
||||
@@ -58,3 +60,7 @@ The workflow fails when vulnerabilities are found:
|
||||
<Tip>
|
||||
Use `quick` mode for PRs to keep feedback fast. Schedule `deep` scans nightly.
|
||||
</Tip>
|
||||
|
||||
<Note>
|
||||
For pull_request workflows, Strix automatically uses changed-files diff-scope in CI/headless runs. If diff resolution fails, ensure full history is fetched (`fetch-depth: 0`) or set `--diff-base`.
|
||||
</Note>
|
||||
|
||||
@@ -3,6 +3,14 @@ title: "AWS Bedrock"
|
||||
description: "Configure Strix with models via AWS Bedrock"
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Bedrock requires the AWS SDK dependency. Install Strix with the bedrock extra:
|
||||
|
||||
```bash
|
||||
pipx install "strix-agent[bedrock]"
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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
|
||||
@@ -62,6 +62,9 @@ strix --target https://your-app.com
|
||||
|
||||
# Multiple targets (white-box testing)
|
||||
strix -t https://github.com/org/repo -t https://your-app.com
|
||||
|
||||
# Targets from a file, one target per non-empty, non-comment line
|
||||
strix --target-list ./targets.txt
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
+51
-27
@@ -30,23 +30,33 @@ The agent can take any captured request and replay it with modifications:
|
||||
|
||||
## Python Integration
|
||||
|
||||
All proxy functions are automatically available in Python sessions. This enables powerful scripted security testing:
|
||||
Proxy helpers are available to sandbox Python scripts through the image-baked `caido_api` module. This enables powerful scripted security testing:
|
||||
|
||||
```python
|
||||
# List recent POST requests
|
||||
post_requests = list_requests(
|
||||
httpql_filter='req.method.eq:"POST"',
|
||||
page_size=20
|
||||
)
|
||||
import asyncio
|
||||
|
||||
# View a specific request
|
||||
request_details = view_request("req_123", part="request")
|
||||
from caido_api import list_requests, repeat_request, view_request
|
||||
|
||||
# Replay with modified payload
|
||||
response = repeat_request("req_123", {
|
||||
"body": '{"user_id": "admin"}'
|
||||
})
|
||||
print(f"Status: {response['status_code']}")
|
||||
|
||||
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())
|
||||
```
|
||||
|
||||
### Available Functions
|
||||
@@ -56,28 +66,42 @@ print(f"Status: {response['status_code']}")
|
||||
| `list_requests()` | Query captured traffic with HTTPQL filters |
|
||||
| `view_request()` | Get full request/response details |
|
||||
| `repeat_request()` | Replay a request with modifications |
|
||||
| `send_request()` | Send a new HTTP request |
|
||||
| `list_sitemap()` | Browse the request-tree view of discovered surface |
|
||||
| `view_sitemap_entry()` | Inspect one sitemap entry + its related requests |
|
||||
| `scope_rules()` | Manage proxy scope (allowlist/denylist) |
|
||||
| `list_sitemap()` | View discovered endpoints |
|
||||
| `view_sitemap_entry()` | Get details for a sitemap entry |
|
||||
|
||||
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`.
|
||||
|
||||
### Example: Automated IDOR Testing
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
# Get all requests to user endpoints
|
||||
user_requests = list_requests(
|
||||
httpql_filter='req.path.cont:"/users/"'
|
||||
)
|
||||
from caido_api import list_requests, repeat_request
|
||||
|
||||
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}')
|
||||
})
|
||||
|
||||
if response['status_code'] == 200:
|
||||
print(f"Potential IDOR: {test_id} returned 200")
|
||||
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())
|
||||
```
|
||||
|
||||
## Human-in-the-Loop
|
||||
|
||||
+11
-3
@@ -45,13 +45,21 @@ Strix runs inside a Kali Linux-based Docker container with a comprehensive set o
|
||||
| [js-beautify](https://github.com/beautifier/js-beautify) | JavaScript deobfuscation |
|
||||
| [JSHint](https://jshint.com) | JavaScript code quality tool |
|
||||
|
||||
## Source-Aware Analysis
|
||||
|
||||
| Tool | Description |
|
||||
| ------------------------------------------------------- | --------------------------------------------- |
|
||||
| [Semgrep](https://github.com/semgrep/semgrep) | Fast SAST and custom rule matching |
|
||||
| [ast-grep](https://ast-grep.github.io) | Structural AST/CST-aware code search (`sg`) |
|
||||
| [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) | Syntax tree parsing and symbol extraction (Java/JS/TS/Python/Go/Bash/JSON/YAML grammars pre-configured) |
|
||||
| [Bandit](https://bandit.readthedocs.io) | Python security linter |
|
||||
|
||||
## Secret Detection
|
||||
|
||||
| Tool | Description |
|
||||
| ----------------------------------------------------------- | ------------------------------------- |
|
||||
| [TruffleHog](https://github.com/trufflesecurity/trufflehog) | Find secrets in code and history |
|
||||
| [Semgrep](https://github.com/semgrep/semgrep) | Static analysis for security patterns |
|
||||
| [Bandit](https://bandit.readthedocs.io) | Python security linter |
|
||||
| [Gitleaks](https://github.com/gitleaks/gitleaks) | Detect hardcoded secrets in repositories |
|
||||
|
||||
## Authentication Testing
|
||||
|
||||
@@ -64,7 +72,7 @@ Strix runs inside a Kali Linux-based Docker container with a comprehensive set o
|
||||
|
||||
| Tool | Description |
|
||||
| -------------------------- | ---------------------------------------------- |
|
||||
| [Trivy](https://trivy.dev) | Container and dependency vulnerability scanner |
|
||||
| [Trivy](https://trivy.dev) | Filesystem/container scanning for vulns, misconfigurations, secrets, and licenses |
|
||||
|
||||
## HTTP Proxy
|
||||
|
||||
|
||||
+10
-6
@@ -32,14 +32,18 @@ sqlmap -u "https://example.com/page?id=1"
|
||||
### Code Analysis
|
||||
|
||||
```bash
|
||||
# Search for secrets
|
||||
trufflehog filesystem ./
|
||||
|
||||
# Static analysis
|
||||
# Fast SAST triage
|
||||
semgrep --config auto ./src
|
||||
|
||||
# Grep for patterns
|
||||
grep -r "password" ./
|
||||
# Structural AST search
|
||||
sg scan ./src
|
||||
|
||||
# Secret detection
|
||||
gitleaks detect --source ./
|
||||
trufflehog filesystem ./
|
||||
|
||||
# Supply-chain and misconfiguration checks
|
||||
trivy fs ./
|
||||
```
|
||||
|
||||
### Custom Scripts
|
||||
|
||||
+96
-4
@@ -6,13 +6,31 @@ description: "Command-line options for Strix"
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
strix --target <target> [options]
|
||||
strix (--target <target> | --target-list <path> | --mount <path>) [options]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
<ParamField path="--target, -t" type="string" required>
|
||||
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.
|
||||
<ParamField path="--target, -t" type="string">
|
||||
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target`, `--target-list`, or `--mount`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--target-list" type="string">
|
||||
Path to a file containing targets, one per non-empty, non-comment line. Lines starting with `#` are ignored. Can be specified multiple times and combined with `--target`.
|
||||
</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">
|
||||
@@ -27,6 +45,14 @@ strix --target <target> [options]
|
||||
Scan depth: `quick`, `standard`, or `deep`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--scope-mode" type="string" default="auto">
|
||||
Code scope mode: `auto` (enable PR diff-scope in CI/headless runs), `diff` (force changed-files scope), or `full` (disable diff-scope).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--diff-base" type="string">
|
||||
Target branch or commit to compare against (e.g., `origin/main`). Defaults to the repository's default branch.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--non-interactive, -n" type="boolean">
|
||||
Run in headless mode without TUI. Ideal for CI/CD.
|
||||
</ParamField>
|
||||
@@ -35,6 +61,59 @@ strix --target <target> [options]
|
||||
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--max-budget" 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.
|
||||
|
||||
In non-interactive mode (`-n`), once the running cost reaches the threshold,
|
||||
the scan stops cleanly with a `stopped` status (not a failure) and the sandbox
|
||||
is torn down. Sub-agents are stopped early, at 90% of the budget, reserving
|
||||
the final slice for the root agent to wind down and produce the final report.
|
||||
|
||||
In interactive mode, reaching the budget pauses the scan instead of ending
|
||||
it: every agent parks, and sending any message resumes the scan with the cap
|
||||
extended by the original budget amount. There is no sub-agent reserve in
|
||||
interactive mode.
|
||||
|
||||
As the budget is approached, graduated wrap-up warnings are surfaced to
|
||||
**every** agent so they can finish their work and call their lifecycle tool
|
||||
before the hard stop. The bands sit just below each role's own stop point: the
|
||||
root is warned at **70%, 85% and 95%** (it stops at 100%), while sub-agents are
|
||||
warned at **75%, 80% and 85%** (they stop at the 90% reserve). In interactive
|
||||
mode every agent uses the **70%, 85% and 95%** bands. Percentages shown in the
|
||||
warnings are the real cumulative spend against the full budget.
|
||||
|
||||
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.
|
||||
- For LiteLLM-routed models, Strix enables streaming success callbacks to
|
||||
capture provider-reported cost. Message content remains excluded, but
|
||||
third-party LiteLLM callbacks configured in the same process can receive
|
||||
other streaming metadata such as model names, request IDs, and token
|
||||
counts.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--max-turns" type="integer" default="500">
|
||||
Maximum number of turns (one model response plus its tool round) allotted to
|
||||
**each** agent, applied per run. When an agent reaches this limit it is
|
||||
force-stopped.
|
||||
|
||||
As the limit is approached, graduated wrap-up warnings (at 70%, 85% and 95%)
|
||||
are injected into that agent's next model turn so it can prioritise its
|
||||
remaining work and call its lifecycle tool (`finish_scan` for the root agent,
|
||||
`agent_finish` for sub-agents) before the hard stop.
|
||||
|
||||
Must be greater than `0`.
|
||||
</ParamField>
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
@@ -50,13 +129,26 @@ strix --target api.example.com --instruction "Focus on IDOR and auth bypass"
|
||||
# CI/CD mode
|
||||
strix -n --target ./ --scan-mode quick
|
||||
|
||||
# Cap cost and per-agent turns
|
||||
strix --target https://example.com --max-budget 25 --max-turns 300
|
||||
|
||||
# Force diff-scope against a specific base ref
|
||||
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
|
||||
|
||||
# Targets from a file
|
||||
strix --target-list ./targets.txt
|
||||
|
||||
# Large local repository — bind-mount instead of copying it in
|
||||
strix --mount ./huge-monorepo
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Scan completed, no vulnerabilities found |
|
||||
| 0 | Scan completed successfully (interactive mode always exits `0`; in headless mode, `0` means no vulnerabilities were found) |
|
||||
| 1 | A fatal error occurred before or during the scan (e.g. missing environment variables, Docker unavailable, invalid config file, diff-scope resolution failure, or an unhandled error) |
|
||||
| 2 | Vulnerabilities found (headless mode only) |
|
||||
|
||||
@@ -31,6 +31,8 @@ Balanced testing for routine security reviews. Best for:
|
||||
|
||||
**Duration**: 30 minutes to 1 hour
|
||||
|
||||
**White-box behavior**: Uses source-aware mapping and static triage to prioritize dynamic exploit validation paths.
|
||||
|
||||
## Deep
|
||||
|
||||
```bash
|
||||
@@ -44,6 +46,8 @@ Thorough penetration testing. Best for:
|
||||
|
||||
**Duration**: 1-4 hours depending on target complexity
|
||||
|
||||
**White-box behavior**: Runs broad source-aware triage (`semgrep`, AST structural search, secrets, supply-chain checks) and then systematically validates top candidates dynamically.
|
||||
|
||||
<Note>
|
||||
Deep mode is the default. It explores edge cases, chained vulnerabilities, and complex attack paths.
|
||||
</Note>
|
||||
|
||||
Generated
-8794
File diff suppressed because it is too large
Load Diff
+121
-142
@@ -1,10 +1,13 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "0.8.3"
|
||||
version = "1.4.1"
|
||||
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",
|
||||
@@ -29,81 +32,57 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
packages = [
|
||||
{ include = "strix", format = ["sdist", "wheel"] }
|
||||
]
|
||||
include = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"strix/agents/**/*.jinja",
|
||||
"strix/skills/**/*.md",
|
||||
"strix/**/*.xml",
|
||||
"strix/**/*.tcss"
|
||||
dependencies = [
|
||||
"openai-agents[litellm]==0.14.6",
|
||||
"openai>=2.26.0,<2.45",
|
||||
"litellm",
|
||||
"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",
|
||||
"reportlab>=4.0",
|
||||
"pypdf>=5.0",
|
||||
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
|
||||
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
|
||||
"cryptography>=48.0.1,<49",
|
||||
]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
[project.optional-dependencies]
|
||||
vertex = ["google-auth>=2.0.0"]
|
||||
bedrock = ["boto3>=1.28.0"]
|
||||
|
||||
[project.scripts]
|
||||
strix = "strix.interface.main:main"
|
||||
|
||||
[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"
|
||||
[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",
|
||||
]
|
||||
|
||||
# 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" }
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
@@ -134,34 +113,23 @@ pretty = true
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"litellm.*",
|
||||
"tenacity.*",
|
||||
"numpydoc.*",
|
||||
"rich.*",
|
||||
"IPython.*",
|
||||
"openhands_aci.*",
|
||||
"playwright.*",
|
||||
"uvicorn.*",
|
||||
"jinja2.*",
|
||||
"pydantic_settings.*",
|
||||
"jwt.*",
|
||||
"httpx.*",
|
||||
"gql.*",
|
||||
"textual.*",
|
||||
"pyte.*",
|
||||
"libtmux.*",
|
||||
"pytest.*",
|
||||
"cvss.*",
|
||||
"opentelemetry.*",
|
||||
"scrubadub.*",
|
||||
"traceloop.*",
|
||||
"docker.*",
|
||||
"caido_sdk_client.*",
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
"pygments.*",
|
||||
]
|
||||
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)
|
||||
@@ -173,7 +141,6 @@ line-length = 100
|
||||
extend-exclude = [
|
||||
".git",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"build",
|
||||
@@ -209,7 +176,6 @@ select = [
|
||||
"PIE", # flake8-pie
|
||||
"T20", # flake8-print
|
||||
"PYI", # flake8-pyi
|
||||
"PT", # flake8-pytest-style
|
||||
"Q", # flake8-quotes
|
||||
"RSE", # flake8-raise
|
||||
"RET", # flake8-return
|
||||
@@ -247,21 +213,76 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = [
|
||||
"S106", # Possible hardcoded password
|
||||
"S108", # Possible insecure usage of temporary file/directory
|
||||
"ARG001", # Unused function argument
|
||||
"PLR2004", # Magic value used in comparison
|
||||
]
|
||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||
"strix/interface/auth_cli.py" = ["N802"]
|
||||
"tests/test_codex_streaming.py" = ["N802"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/interface/viewer/cli.py" = ["PLC0415"]
|
||||
# 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"]
|
||||
"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", "PLC0415"]
|
||||
"strix/report/usage.py" = ["PLC0415"]
|
||||
# Lazy import of strix.config.models avoids a circular dependency between the
|
||||
# report pipeline and the config layer.
|
||||
"strix/report/dedupe.py" = ["PLC0415"]
|
||||
"strix/telemetry/logging.py" = ["PLC0415"]
|
||||
"strix/config/models.py" = ["PLC0415"]
|
||||
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
|
||||
# don't pull them in.
|
||||
"strix/config/codex.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 = ["fastapi", "pydantic"]
|
||||
known-third-party = ["pydantic"]
|
||||
|
||||
[tool.ruff.lint.pylint]
|
||||
max-args = 8
|
||||
@@ -337,55 +358,13 @@ force_grid_wrap = 0
|
||||
use_parentheses = true
|
||||
ensure_newline_before_comments = true
|
||||
known_first_party = ["strix"]
|
||||
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",
|
||||
]
|
||||
known_third_party = ["pydantic", "litellm"]
|
||||
|
||||
# ============================================================================
|
||||
# Bandit Configuration (Security Linting)
|
||||
# ============================================================================
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", "docs", "build", "dist"]
|
||||
exclude_dirs = ["docs", "build", "dist"]
|
||||
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
|
||||
severity = "medium"
|
||||
|
||||
+6
-6
@@ -33,23 +33,23 @@ echo -e "${YELLOW}Platform:${NC} $OS_NAME-$ARCH_NAME"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
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"
|
||||
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/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "\n${BLUE}Installing dependencies...${NC}"
|
||||
poetry install --with dev
|
||||
uv sync --frozen
|
||||
|
||||
VERSION=$(poetry version -s)
|
||||
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
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}"
|
||||
poetry run pyinstaller strix.spec --noconfirm
|
||||
uv run pyinstaller strix.spec --noconfirm
|
||||
|
||||
RELEASE_DIR="dist/release"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/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"
|
||||
+2
-2
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
APP=strix
|
||||
REPO="usestrix/strix"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:0.1.13"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0"
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
@@ -41,7 +41,7 @@ fi
|
||||
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64)
|
||||
linux-x86_64|linux-arm64|macos-x86_64|macos-arm64|windows-x86_64)
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
|
||||
+81
-19
@@ -25,6 +25,13 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
||||
rel_path = tcss_file.relative_to(project_root)
|
||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||
|
||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||
viewer_static = strix_root / 'interface' / 'viewer' / 'static'
|
||||
for asset in viewer_static.rglob('*'):
|
||||
if asset.is_file():
|
||||
rel_path = asset.relative_to(project_root)
|
||||
datas.append((str(asset), str(rel_path.parent)))
|
||||
|
||||
datas += collect_data_files('textual')
|
||||
|
||||
datas += collect_data_files('tiktoken')
|
||||
@@ -32,6 +39,8 @@ datas += collect_data_files('tiktoken_ext')
|
||||
|
||||
datas += collect_data_files('litellm')
|
||||
|
||||
datas += collect_data_files('agents', includes=['**/*.md', '**/*.jinja', '**/*.json'])
|
||||
|
||||
hiddenimports = [
|
||||
# Core dependencies
|
||||
'litellm',
|
||||
@@ -116,26 +125,73 @@ 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.base_agent',
|
||||
'strix.agents.state',
|
||||
'strix.agents.StrixAgent',
|
||||
'strix.llm',
|
||||
'strix.llm.llm',
|
||||
'strix.llm.config',
|
||||
'strix.llm.utils',
|
||||
'strix.llm.memory_compressor',
|
||||
'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.interface.viewer',
|
||||
'strix.interface.viewer.auth',
|
||||
'strix.interface.viewer.cli',
|
||||
'strix.interface.viewer.report_pdf',
|
||||
'strix.interface.viewer.server',
|
||||
'strix.interface.viewer.transcript',
|
||||
|
||||
# PDF report generation + encryption
|
||||
'reportlab',
|
||||
'reportlab.pdfgen',
|
||||
'reportlab.pdfbase',
|
||||
'reportlab.lib',
|
||||
'reportlab.platypus',
|
||||
'pypdf',
|
||||
'cryptography',
|
||||
'strix.runtime',
|
||||
'strix.runtime.runtime',
|
||||
'strix.runtime.docker_runtime',
|
||||
'strix.runtime.backends',
|
||||
'strix.runtime.caido_bootstrap',
|
||||
'strix.runtime.docker_client',
|
||||
'strix.runtime.session_manager',
|
||||
'strix.telemetry',
|
||||
'strix.telemetry.tracer',
|
||||
'strix.telemetry.logging',
|
||||
'strix.telemetry.posthog',
|
||||
'strix.tools',
|
||||
'strix.tools.registry',
|
||||
'strix.tools.executor',
|
||||
'strix.tools.argument_parser',
|
||||
'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.skills',
|
||||
]
|
||||
|
||||
@@ -144,6 +200,16 @@ hiddenimports += collect_submodules('textual')
|
||||
hiddenimports += collect_submodules('rich')
|
||||
hiddenimports += collect_submodules('pydantic')
|
||||
hiddenimports += collect_submodules('pygments')
|
||||
# reportlab loads renderers/fonts dynamically, so pull its whole tree in.
|
||||
hiddenimports += collect_submodules('reportlab')
|
||||
|
||||
# reportlab ships bundled fonts (.pfb/.afm) it needs at runtime.
|
||||
datas += collect_data_files('reportlab')
|
||||
|
||||
# reportlab imports PIL (pillow) lazily for image handling, so it must be
|
||||
# bundled explicitly and kept out of the excludes list below.
|
||||
hiddenimports += collect_submodules('PIL')
|
||||
datas += collect_data_files('PIL')
|
||||
|
||||
excludes = [
|
||||
# Sandbox-only packages
|
||||
@@ -156,9 +222,6 @@ excludes = [
|
||||
'pyte',
|
||||
'openhands_aci',
|
||||
'openhands-aci',
|
||||
'gql',
|
||||
'fastapi',
|
||||
'uvicorn',
|
||||
'numpydoc',
|
||||
|
||||
# Google Cloud / Vertex AI
|
||||
@@ -194,7 +257,6 @@ excludes = [
|
||||
'numpy',
|
||||
'pandas',
|
||||
'scipy',
|
||||
'PIL',
|
||||
'cv2',
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from .strix_agent import StrixAgent
|
||||
|
||||
|
||||
__all__ = ["StrixAgent"]
|
||||
@@ -1,128 +0,0 @@
|
||||
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", [])
|
||||
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)
|
||||
|
||||
task_description = " ".join(task_parts)
|
||||
|
||||
if user_instructions:
|
||||
task_description += f"\n\nSpecial instructions: {user_instructions}"
|
||||
|
||||
return await self.agent_loop(task=task_description)
|
||||
@@ -1,10 +0,0 @@
|
||||
from .base_agent import BaseAgent
|
||||
from .state import AgentState
|
||||
from .StrixAgent import StrixAgent
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentState",
|
||||
"BaseAgent",
|
||||
"StrixAgent",
|
||||
]
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,579 @@
|
||||
"""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.config import load_settings
|
||||
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.output_store import bound_and_store, bound_text
|
||||
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_dependency_report,
|
||||
create_vulnerability_report,
|
||||
get_report,
|
||||
list_reports,
|
||||
)
|
||||
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, Sequence
|
||||
|
||||
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 _tool_output_limits() -> tuple[int, int]:
|
||||
context = load_settings().context
|
||||
return context.tool_output_max_lines, context.tool_output_max_bytes
|
||||
|
||||
|
||||
async def _bound_result(result: Any) -> Any:
|
||||
if not isinstance(result, str):
|
||||
return result
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return await bound_and_store(result, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _format_tool_error(exc: Exception) -> str:
|
||||
message = str(exc) or exc.__class__.__name__
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return bound_text(message, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _with_bounded_result(tool: FunctionTool) -> FunctionTool:
|
||||
"""Cap a tool's result size before it enters history (idempotent)."""
|
||||
if getattr(tool, "_strix_bounded", False):
|
||||
return tool
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
tool._strix_bounded = True # type: ignore[attr-defined]
|
||||
return tool
|
||||
|
||||
|
||||
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 _bound_result(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 _bound_result(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 _bound_custom_tool(tool: CustomTool) -> CustomTool:
|
||||
"""Bound a native ``CustomTool`` result in place (Responses path)."""
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
return tool
|
||||
|
||||
|
||||
def _configure_filesystem_tools(toolset: Any, *, chat_completions: bool) -> None:
|
||||
for name, tool in vars(toolset).items():
|
||||
if chat_completions:
|
||||
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))
|
||||
elif isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _bound_custom_tool(tool))
|
||||
elif isinstance(tool, FunctionTool):
|
||||
setattr(toolset, name, _with_bounded_result(tool))
|
||||
|
||||
|
||||
def _make_filesystem_configurator(*, chat_completions: bool) -> Any:
|
||||
def configure(toolset: Any) -> None:
|
||||
_configure_filesystem_tools(toolset, chat_completions=chat_completions)
|
||||
|
||||
return configure
|
||||
|
||||
|
||||
_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 _apply_shell_output_cap(parsed: dict[str, Any]) -> None:
|
||||
"""Clamp the SDK shell tools' ``max_output_tokens`` to the configured
|
||||
ceiling; a smaller explicit value is respected."""
|
||||
ceiling = load_settings().context.tool_output_max_tokens
|
||||
requested = parsed.get("max_output_tokens")
|
||||
parsed["max_output_tokens"] = (
|
||||
ceiling if not isinstance(requested, int) or requested > ceiling else requested
|
||||
)
|
||||
|
||||
|
||||
def _wrap_exec_command(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, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
if "shell" not in parsed:
|
||||
parsed["shell"] = "bash"
|
||||
_apply_shell_output_cap(parsed)
|
||||
raw_input = json.dumps(parsed)
|
||||
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):
|
||||
if isinstance(parsed.get("chars"), str):
|
||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||
_apply_shell_output_cap(parsed)
|
||||
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,
|
||||
create_dependency_report,
|
||||
list_reports,
|
||||
get_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,
|
||||
)
|
||||
|
||||
|
||||
# Extra tools registered for scan agents. Mirrors
|
||||
# ``strix.runtime.backends.register_backend``: register before the first
|
||||
# ``build_strix_agent`` call and every agent (root + children) gets them.
|
||||
_EXTRA_TOOLS: list[Tool] = []
|
||||
|
||||
|
||||
def _ensure_unique_tool_names(tools: Sequence[Tool]) -> None:
|
||||
seen: set[str] = set()
|
||||
duplicates: set[str] = set()
|
||||
for tool in tools:
|
||||
if tool.name in seen:
|
||||
duplicates.add(tool.name)
|
||||
seen.add(tool.name)
|
||||
if duplicates:
|
||||
msg = f"Agent tools must have unique names: {sorted(duplicates)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def register_agent_tools(*tools: Tool) -> None:
|
||||
"""Register tools for every scan agent built afterwards.
|
||||
|
||||
Tools are added to both root and child agents, after the base set and
|
||||
before the lifecycle tool (``finish_scan`` / ``agent_finish``). Duplicate
|
||||
tool objects are ignored so repeated imports don't double-register.
|
||||
"""
|
||||
new_tools: list[Tool] = []
|
||||
for tool in tools:
|
||||
if tool not in _EXTRA_TOOLS and tool not in new_tools:
|
||||
new_tools.append(tool)
|
||||
|
||||
_ensure_unique_tool_names([*_BASE_TOOLS, *_EXTRA_TOOLS, *new_tools, finish_scan, agent_finish])
|
||||
|
||||
for tool in new_tools:
|
||||
_EXTRA_TOOLS.append(tool)
|
||||
logger.info("Registered extra agent tool: %s", getattr(tool, "name", tool))
|
||||
|
||||
|
||||
def registered_agent_tools() -> tuple[Tool, ...]:
|
||||
"""Return the currently registered scan-agent tools."""
|
||||
return tuple(_EXTRA_TOOLS)
|
||||
|
||||
|
||||
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,
|
||||
extra_tools: Sequence[Tool] | None = None,
|
||||
instructions_override: str | 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.
|
||||
extra_tools: Additional tools for this scan agent only, on top of any
|
||||
registered via ``register_agent_tools``.
|
||||
instructions_override: Use this verbatim as the system prompt instead
|
||||
of rendering the built-in scan prompt.
|
||||
"""
|
||||
if instructions_override is not None:
|
||||
instructions = instructions_override
|
||||
else:
|
||||
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,
|
||||
)
|
||||
|
||||
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
|
||||
if is_root:
|
||||
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
|
||||
else:
|
||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||
_ensure_unique_tool_names(tools)
|
||||
tools = [
|
||||
_with_bounded_result(tool) if isinstance(tool, FunctionTool) else tool for tool in tools
|
||||
]
|
||||
|
||||
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=_make_filesystem_configurator(
|
||||
chat_completions=chat_completions_tools,
|
||||
),
|
||||
),
|
||||
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
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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, skill_search_dirs
|
||||
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)
|
||||
loader_dirs = [prompt_dir, *skill_search_dirs()]
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(loader_dirs),
|
||||
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,
|
||||
is_root=is_root,
|
||||
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)
|
||||
+96
-110
@@ -1,5 +1,14 @@
|
||||
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
|
||||
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
|
||||
{% if is_root %}
|
||||
<root_agent_directive>
|
||||
YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing.
|
||||
- You accomplish security work by DELEGATING to specialized subagents via create_agent — you do NOT run scanners, crawlers, fuzzers, or send exploit/injection payloads yourself.
|
||||
- IMPORTANT — how to read this prompt as root: the rest of this system prompt is written in the second person ("you") and describes the hands-on testing methodology (recon, mapping, scanning, payload spraying, PoC building, fixing). When you are the root agent, treat every such hands-on instruction as something you ensure gets done BY A SUBAGENT, not as a task you perform in your own turns. The "map the target", "recon first", "mandatory initial phases", and "spray payloads" directives are DELEGATION REQUIREMENTS for you — spawn recon/mapping/testing subagents to satisfy them.
|
||||
- Do NOT probe endpoints, run "basic" or "quick" injection/XSS/etc. tests, or do exploratory scanning before delegating. Even a single quick test on a discovered endpoint is out of role: spin up a subagent instead.
|
||||
- Your own turns should be spent on: reading scope/config, decomposing the target, spawning and monitoring subagents, tracking todos/notes/coverage, deciding next steps, and aggregating results into the final report.
|
||||
</root_agent_directive>
|
||||
{% endif %}
|
||||
|
||||
<core_capabilities>
|
||||
- Security assessment and vulnerability scanning
|
||||
@@ -16,9 +25,8 @@ CLI OUTPUT:
|
||||
- NEVER use "Strix" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
|
||||
|
||||
INTER-AGENT MESSAGES:
|
||||
- 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.
|
||||
- Messages from other agents arrive prefixed with a header like `[Message from agent <name> | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output.
|
||||
- Treat agent identity / inherited-context preambles as internal metadata; do not echo them in outputs or tool calls.
|
||||
- Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging
|
||||
|
||||
{% if interactive %}
|
||||
@@ -31,6 +39,9 @@ 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 %}
|
||||
@@ -41,6 +52,7 @@ 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>
|
||||
|
||||
@@ -111,17 +123,19 @@ BLACK-BOX TESTING (domain/subdomain only):
|
||||
|
||||
WHITE-BOX TESTING (code provided):
|
||||
- MUST perform BOTH static AND dynamic analysis
|
||||
- Static: Review code for vulnerabilities
|
||||
- Dynamic: Run the application and test live
|
||||
- NEVER rely solely on static code analysis - always test dynamically
|
||||
- You MUST begin at the very first step by running the code and testing live.
|
||||
- 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)
|
||||
- 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.
|
||||
- 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.
|
||||
- Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation
|
||||
- If dynamically running the code proves impossible after exhaustive attempts, pivot to just comprehensive static analysis.
|
||||
- If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis.
|
||||
- Try to infer how to run the code based on its structure and content.
|
||||
- FIX discovered vulnerabilities in code in same file.
|
||||
- Test patches to confirm vulnerability removal.
|
||||
- Do not stop until all reported vulnerabilities are fixed.
|
||||
- Include code diff in final report.
|
||||
- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
|
||||
- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass.
|
||||
|
||||
COMBINED MODE (code + deployed target present):
|
||||
- Treat this as static analysis plus dynamic testing simultaneously
|
||||
@@ -142,13 +156,12 @@ 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
|
||||
- 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
|
||||
- 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 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
|
||||
- NEVER skip think tool - it's your most important tool for reasoning and success
|
||||
- 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.
|
||||
- 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
|
||||
@@ -157,14 +170,34 @@ OPERATIONAL PRINCIPLES:
|
||||
EFFICIENCY TACTICS:
|
||||
- Automate with Python scripts for complex workflows and repetitive inputs/tasks
|
||||
- Batch similar operations together
|
||||
- Use captured traffic from proxy in Python tool to automate analysis
|
||||
- Use captured traffic from the proxy tools directly, or import `caido_api`
|
||||
from sandbox Python scripts when proxy automation is easier in code
|
||||
- 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
|
||||
- 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 the python or terminal tools
|
||||
- Use `exec_command` for Python code: write reusable scripts to a file and
|
||||
run them with `python3 script.py`. For one-off snippets, `python3 -c` or a
|
||||
here-document is acceptable, but avoid deeply nested quotes/parentheses — if
|
||||
a snippet needs complex quoting or is more than a few lines, write it to a
|
||||
file first to prevent syntax errors.
|
||||
- Before importing a third-party Python library, make sure it is installed. The
|
||||
sandbox's `python3` runs inside a preconfigured virtualenv that ships
|
||||
`requests`, `httpx`, `beautifulsoup4` (bs4), `lxml`, `pyjwt`, and
|
||||
`cryptography`; for anything else prefer the stdlib or run `pip install <pkg>`
|
||||
(it installs into that active venv) before importing, rather than letting the
|
||||
script fail with `ModuleNotFoundError`.
|
||||
- `exec_command` runs each command in a fresh non-interactive shell (plain
|
||||
pipes, no TTY). To drive an interactive or long-running process with
|
||||
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `sqlmap`, or to send Ctrl-C —
|
||||
you MUST start it with `exec_command(cmd="...", tty=true)` and then
|
||||
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
|
||||
default (non-TTY) command or on a process that has already exited fails with
|
||||
"stdin is not available".
|
||||
- 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 established fuzzers/scanners where applicable: ffuf, sqlmap, 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.
|
||||
- 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
|
||||
@@ -175,13 +208,14 @@ EFFICIENCY TACTICS:
|
||||
VALIDATION REQUIREMENTS:
|
||||
- Full validation required - no assumptions
|
||||
- Demonstrate concrete impact with evidence
|
||||
- Consider business context for severity assessment
|
||||
- Consider business context for severity assessment — check whether the target is a demo/sandbox environment or content meant to be public, and factor that in
|
||||
- Independent verification through subagent
|
||||
- Document complete attack chain
|
||||
- Keep going until you find something that matters
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Do NOT patch/fix before reporting: first create the vulnerability report via create_vulnerability_report (by the reporting agent). Only after reporting is completed should fixing/patching proceed
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
|
||||
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
|
||||
</execution_guidelines>
|
||||
|
||||
<vulnerability_focus>
|
||||
@@ -229,12 +263,20 @@ AGENT ISOLATION & SANDBOXING:
|
||||
- All agents share the same /workspace directory and proxy history
|
||||
- Agents can see each other's files and proxy traffic for better collaboration
|
||||
|
||||
MANDATORY INITIAL PHASES:
|
||||
DISK & SCRATCH HYGIENE:
|
||||
- /workspace is a shared, finite disk used by all agents at once — be a considerate tenant
|
||||
- Prefer bounded recon: scope crawls and scans by depth, duration, and target rather than "collect everything"
|
||||
- Redirect large tool output to a file, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output
|
||||
- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use
|
||||
|
||||
MANDATORY INITIAL PHASES:
|
||||
{% if is_root %}
|
||||
- ROOT AGENT: these phases are mandatory for the assessment, but you MUST accomplish them by delegating to reconnaissance/mapping subagents — do NOT run recon, crawling, enumeration, or mapping tools in your own turns. Spawn the appropriate subagent(s) and track their coverage.
|
||||
{% endif %}
|
||||
BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):
|
||||
- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection
|
||||
- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs
|
||||
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files
|
||||
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files — keep each crawl bounded by depth/duration, and tidy up raw output once endpoints are extracted
|
||||
- ENUMERATE technologies: frameworks, libraries, versions, dependencies
|
||||
- Reconnaissance should normally happen before targeted vulnerability discovery unless the correct next move is already obvious or the user/system explicitly asks to prioritize a specific area first
|
||||
- ONLY AFTER comprehensive mapping → proceed to vulnerability testing
|
||||
@@ -259,13 +301,14 @@ ROOT AGENT ROLE:
|
||||
- The root agent should coordinate strategy, delegate meaningful work, track progress, maintain todo lists, maintain notes, monitor subagent results, and decide next steps
|
||||
- The root agent should keep a clear view of overall coverage, uncovered attack surfaces, validation status, and reporting/fixing progress
|
||||
- The root agent should avoid spending its own iterations on detailed testing, payload execution, or deep target-specific investigation when that work can be delegated to specialized subagents
|
||||
- The root agent may do lightweight triage, quick verification, or setup work when necessary to unblock delegation, but its default mode should be coordinator/controller
|
||||
- The root agent may do orchestration-support work needed to delegate well — reading scope/config, inspecting workspace layout, reading subagent output/reports, and light bookkeeping. It must NOT do the actual security testing itself: no running scanners/fuzzers/crawlers, no sending injection/XSS/SSRF/etc. payloads, and no "basic" or "quick" probing of discovered endpoints. If a check requires touching the target, delegate it to a subagent rather than doing it yourself
|
||||
- Its default and near-exclusive mode is coordinator/controller
|
||||
- Subagents should do the substantive testing, validation, reporting, and fixing work
|
||||
- The root agent is responsible for ensuring that work is broken down clearly, tracked, and completed across the agent tree
|
||||
|
||||
1. **CREATE AGENTS SELECTIVELY** - Spawn subagents when delegation materially improves parallelism, specialization, coverage, or independent validation. Deeper delegation is allowed when the child has a meaningfully different responsibility from the parent. Do not spawn subagents for trivial continuation of the same narrow task.
|
||||
2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)
|
||||
3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)
|
||||
3. **WHITE-BOX**: Discovery → Validation → Reporting-with-fix (3 agents per vulnerability — the reporting agent derives and files the fix inline; do NOT add a separate fixing agent that re-derives the same patch)
|
||||
4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain
|
||||
5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces
|
||||
6. **ONE JOB PER AGENT** - Each agent has ONE specific task only
|
||||
@@ -284,8 +327,7 @@ BLACK-BOX (domain/URL only):
|
||||
WHITE-BOX (source code provided):
|
||||
- Found authentication code issues? → Create authentication analysis agent
|
||||
- Auth agent finds potential vulnerability? → Create "Auth Validation Agent"
|
||||
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent"
|
||||
- Reporting agent documents vulnerability? → Create "Auth Fixing Agent" (implement code fix and test it works)
|
||||
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" that files the report AND its inline fix (`code_locations` + `fix_pr_body`) in one shot — no separate fixing agent
|
||||
|
||||
VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):
|
||||
|
||||
@@ -306,9 +348,11 @@ Authentication Code Agent finds weak password validation
|
||||
↓
|
||||
Spawns "Auth Validation Agent" (proves it's exploitable)
|
||||
↓
|
||||
If valid → Spawns "Auth Reporting Agent" (creates vulnerability report)
|
||||
If valid → Spawns "Auth Reporting Agent" (creates the vulnerability report
|
||||
WITH the fix inline: code_locations fix_before/fix_after + fix_pr_body,
|
||||
applying/verifying the patch in the same turn if desired)
|
||||
↓
|
||||
Spawns "Auth Fixing Agent" (implements secure code fix)
|
||||
STOP - no separate fixing agent; the fix was derived once, at report time
|
||||
```
|
||||
|
||||
CRITICAL RULES:
|
||||
@@ -344,7 +388,7 @@ FOCUS PRINCIPLES:
|
||||
REALISTIC TESTING OUTCOMES:
|
||||
- **No Findings**: Agent completes testing but finds no vulnerabilities
|
||||
- **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable
|
||||
- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box)
|
||||
- **Valid Vulnerability**: Validation succeeds, spawns a reporting agent that files the report with the fix inline (white-box) — no separate fixing agent
|
||||
|
||||
PERSISTENCE IS MANDATORY:
|
||||
- Real vulnerabilities take TIME - expect to need 2000+ steps minimum
|
||||
@@ -355,79 +399,6 @@ 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:
|
||||
|
||||
@@ -442,7 +413,6 @@ VULNERABILITY ASSESSMENT:
|
||||
- nuclei - Vulnerability scanner with templates
|
||||
- sqlmap - SQL injection detection/exploitation
|
||||
- trivy - Container/dependency vulnerability scanner
|
||||
- zaproxy - OWASP ZAP web app scanner
|
||||
- wapiti - Web vulnerability scanner
|
||||
|
||||
WEB FUZZING & DISCOVERY:
|
||||
@@ -460,8 +430,12 @@ JAVASCRIPT ANALYSIS:
|
||||
|
||||
CODE ANALYSIS:
|
||||
- semgrep - Static analysis/SAST
|
||||
- ast-grep (sg) - Structural AST/CST-aware code search
|
||||
- tree-sitter - Syntax-aware parsing and symbol extraction support
|
||||
- bandit - Python security linter
|
||||
- trufflehog - Secret detection in code
|
||||
- gitleaks - Secret detection in repository content/history
|
||||
- trivy fs - Filesystem vulnerability/misconfiguration/license/secret scanning
|
||||
|
||||
SPECIALIZED TOOLS:
|
||||
- jwt_tool - JWT token manipulation
|
||||
@@ -469,15 +443,17 @@ SPECIALIZED TOOLS:
|
||||
- interactsh-client - OOB interaction testing
|
||||
|
||||
PROXY & INTERCEPTION:
|
||||
- Caido CLI - Modern web proxy (already running). Used with proxy tool or with python tool (functions already imported).
|
||||
- Caido CLI - Modern web proxy (already running). Use the proxy tools
|
||||
directly, or import `caido_api` from sandbox Python scripts.
|
||||
- HTTPQL filters (for `list_requests`): quote string values, leave integers unquoted (`resp.code.eq:200`, not `"200"`); combine terms with `AND`/`OR` (there is no `NOT` — use the negated operator `ne`/`ncont`/`nregex`). Numeric fields (`resp.code`, `req.port`) use `eq`/`ne`/`gt`/`gte`/`lt`/`lte`; text fields (`req.host`, `req.path`, `req.method`, `req.raw`) use `cont`/`ncont`/`eq`/`regex`. Example: `resp.code.gte:200 AND resp.code.lt:300 AND req.host.cont:"api"`.
|
||||
- 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, Poetry, Go, Node.js/npm
|
||||
- Python 3, uv, 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.)
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
|
||||
|
||||
Directories:
|
||||
- /workspace - where you should work.
|
||||
@@ -496,3 +472,13 @@ 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 %}
|
||||
@@ -1,172 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,12 +1,41 @@
|
||||
from strix.config.config import (
|
||||
Config,
|
||||
apply_saved_config,
|
||||
save_current_config,
|
||||
"""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 (
|
||||
ContextSettings,
|
||||
DedupeSettings,
|
||||
IntegrationSettings,
|
||||
LlmSettings,
|
||||
RuntimeSettings,
|
||||
Settings,
|
||||
TelemetrySettings,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"apply_saved_config",
|
||||
"save_current_config",
|
||||
"ContextSettings",
|
||||
"DedupeSettings",
|
||||
"IntegrationSettings",
|
||||
"LlmSettings",
|
||||
"RuntimeSettings",
|
||||
"Settings",
|
||||
"TelemetrySettings",
|
||||
"apply_config_override",
|
||||
"load_settings",
|
||||
"persist_current",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""ChatGPT (Codex) subscription auth: OAuth login, token refresh, and the OpenAI
|
||||
client that routes inference through the ChatGPT backend.
|
||||
|
||||
Mirrors OpenAI's Codex CLI: OAuth 2.0 + PKCE against ``auth.openai.com``, with the
|
||||
access token sent as a ``Bearer`` token to ``chatgpt.com/backend-api/codex``. Using
|
||||
a ChatGPT subscription outside OpenAI's own products is not officially supported by
|
||||
OpenAI; the user chooses this path knowingly. The OAuth constants are OpenAI's own
|
||||
Codex CLI values (the backend only accepts that client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PROVIDER = "codex"
|
||||
|
||||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
||||
TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 # nosec B105 - URL, not a secret
|
||||
CALLBACK_HOST = "localhost"
|
||||
CALLBACK_PORT = 1455
|
||||
CALLBACK_PATH = "/auth/callback"
|
||||
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
|
||||
SCOPE = "openid profile email offline_access"
|
||||
|
||||
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
ORIGINATOR = "codex_cli_rs"
|
||||
_ACCOUNT_CLAIM = "https://api.openai.com/auth"
|
||||
|
||||
_TOKEN_TIMEOUT = 30
|
||||
_EXPIRY_SKEW_S = 300
|
||||
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
# Kept separate from cli-config.json so OAuth tokens never land in the env-var config.
|
||||
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
|
||||
|
||||
|
||||
def _read_store() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_store(data: dict[str, Any]) -> None:
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
record = _read_store().get(PROVIDER)
|
||||
if not isinstance(record, dict) or record.get("type") != "oauth":
|
||||
return None
|
||||
if not (record.get("access") and record.get("refresh") and record.get("account_id")):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
return read_record() is not None
|
||||
|
||||
|
||||
def save_record(record: dict[str, Any]) -> None:
|
||||
data = _read_store()
|
||||
data[PROVIDER] = record
|
||||
_write_store(data)
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
data = _read_store()
|
||||
if PROVIDER not in data:
|
||||
return
|
||||
del data[PROVIDER]
|
||||
if data:
|
||||
_write_store(data)
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _refresh_guard() -> Iterator[None]:
|
||||
"""Serialize token refresh within (lock) and across (flock) Strix processes,
|
||||
so concurrent runs can't both spend the single-use refresh token."""
|
||||
with _refresh_lock:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
lock_path = AUTH_PATH.with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = lock_path.open("w")
|
||||
except (ImportError, OSError):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
|
||||
|
||||
class CodexAuthError(Exception):
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
class CodexContentGuardrailError(Exception):
|
||||
"""The ChatGPT backend refused a request via its content guardrail.
|
||||
Terminal — retrying identical content never clears the block."""
|
||||
|
||||
def __init__(self, model: str, original: BaseException | None = None) -> None:
|
||||
self.model = model
|
||||
self.original = original
|
||||
super().__init__(
|
||||
f"'{model}' was blocked by ChatGPT's content guardrails "
|
||||
f"(flagged as a possible cybersecurity risk). "
|
||||
f"Set STRIX_LLM to a model that isn't blocked and re-run."
|
||||
)
|
||||
|
||||
|
||||
_GUARDRAIL_MARKERS = (
|
||||
"flagged for possible cybersecurity risk",
|
||||
"trusted access for cyber",
|
||||
)
|
||||
|
||||
|
||||
def is_content_guardrail_error(exc: BaseException) -> bool:
|
||||
if isinstance(exc, CodexContentGuardrailError):
|
||||
return True
|
||||
text = str(exc).lower()
|
||||
return any(marker in text for marker in _GUARDRAIL_MARKERS)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str]:
|
||||
verifier = _b64url(secrets.token_bytes(64))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def create_state() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def build_authorize_url(challenge: str, state: str) -> str:
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
"originator": ORIGINATOR,
|
||||
}
|
||||
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
|
||||
def parse_redirect_input(value: str) -> tuple[str | None, str | None]:
|
||||
"""Extract ``(code, state)`` from a pasted redirect URL, ``code#state``,
|
||||
query string, or bare code."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None, None
|
||||
with contextlib.suppress(ValueError):
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme and parsed.query:
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
if "#" in value:
|
||||
code, _, state = value.partition("#")
|
||||
return code or None, state or None
|
||||
if "code=" in value:
|
||||
query = urllib.parse.parse_qs(value)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
return value, None
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
|
||||
try:
|
||||
response = requests.post(
|
||||
TOKEN_URL,
|
||||
data=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise CodexAuthError("unavailable", str(exc)) from exc
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:300]
|
||||
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
|
||||
data = json.loads(response.content or b"{}")
|
||||
if not isinstance(data, dict):
|
||||
raise CodexAuthError("bad_response", "token endpoint returned non-object")
|
||||
return data
|
||||
|
||||
|
||||
def _record_from_token_response(
|
||||
data: dict[str, Any], refresh_fallback: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
access = data.get("access_token")
|
||||
# A refresh response may omit refresh_token when it isn't rotated; keep the old one.
|
||||
refresh = data.get("refresh_token") or refresh_fallback
|
||||
expires_in = data.get("expires_in")
|
||||
if not isinstance(access, str) or not access:
|
||||
raise CodexAuthError("bad_response", "token response missing access_token")
|
||||
if not isinstance(refresh, str) or not refresh:
|
||||
raise CodexAuthError("bad_response", "token response missing refresh_token")
|
||||
account_id = _account_id_from_jwt(access) or _account_id_from_jwt(
|
||||
data.get("id_token") if isinstance(data.get("id_token"), str) else ""
|
||||
)
|
||||
if not account_id:
|
||||
raise CodexAuthError("no_account_id", "could not read chatgpt_account_id from token")
|
||||
ttl = expires_in if isinstance(expires_in, int | float) else 3600
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": PROVIDER,
|
||||
"access": access,
|
||||
"refresh": refresh,
|
||||
"account_id": account_id,
|
||||
"expires_at": time.time() + ttl,
|
||||
}
|
||||
|
||||
|
||||
def exchange_code(code: str, verifier: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data, refresh_fallback=refresh_token)
|
||||
|
||||
|
||||
def _account_id_from_jwt(token: str | None) -> str | None:
|
||||
"""Read the account id claim without verifying the JWT (the server enforces
|
||||
authenticity on use); it feeds the ``chatgpt-account-id`` header."""
|
||||
if not token or token.count(".") != 2:
|
||||
return None
|
||||
payload_b64 = token.split(".")[1]
|
||||
padding = "=" * (-len(payload_b64) % 4)
|
||||
try:
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
auth = payload.get(_ACCOUNT_CLAIM)
|
||||
if isinstance(auth, dict):
|
||||
account_id = auth.get("chatgpt_account_id")
|
||||
if isinstance(account_id, str) and account_id:
|
||||
return account_id
|
||||
organizations = payload.get("organizations")
|
||||
if isinstance(organizations, list) and organizations and isinstance(organizations[0], dict):
|
||||
org_id = organizations[0].get("id")
|
||||
if isinstance(org_id, str) and org_id:
|
||||
return org_id
|
||||
return None
|
||||
|
||||
|
||||
def _near_expiry(record: dict[str, Any]) -> bool:
|
||||
expires_at = record.get("expires_at")
|
||||
if not isinstance(expires_at, int | float):
|
||||
return True
|
||||
return expires_at - _EXPIRY_SKEW_S <= time.time()
|
||||
|
||||
|
||||
def get_valid_token() -> tuple[str, str]:
|
||||
"""Return ``(access_token, account_id)``, refreshing under the cross-process
|
||||
guard if near expiry."""
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
with _refresh_guard():
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
try:
|
||||
refreshed = refresh_tokens(record["refresh"])
|
||||
except CodexAuthError:
|
||||
# A peer process may have already spent this single-use refresh token.
|
||||
latest = read_record()
|
||||
if latest and latest["refresh"] != record["refresh"] and not _near_expiry(latest):
|
||||
return latest["access"], latest["account_id"]
|
||||
raise
|
||||
save_record(refreshed)
|
||||
return refreshed["access"], refreshed["account_id"]
|
||||
|
||||
|
||||
def build_openai_client() -> AsyncOpenAI:
|
||||
"""An ``AsyncOpenAI`` for the ChatGPT backend. A per-request hook re-stamps a
|
||||
fresh bearer token so long scans survive token expiry."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
get_valid_token() # fail fast at configure time if the sign-in is dead
|
||||
|
||||
async def _auth_hook(request: httpx.Request) -> None:
|
||||
access, account_id = await asyncio.to_thread(get_valid_token)
|
||||
request.headers["Authorization"] = f"Bearer {access}"
|
||||
request.headers["chatgpt-account-id"] = account_id
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||
event_hooks={"request": [_auth_hook]},
|
||||
)
|
||||
return AsyncOpenAI(
|
||||
api_key="strix-codex-oauth", # placeholder; the hook overwrites Authorization
|
||||
base_url=CODEX_BASE_URL,
|
||||
http_client=http_client,
|
||||
default_headers={
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": ORIGINATOR,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_subscription_client: AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def get_subscription_client() -> AsyncOpenAI:
|
||||
global _subscription_client # noqa: PLW0603
|
||||
if _subscription_client is None:
|
||||
_subscription_client = build_openai_client()
|
||||
return _subscription_client
|
||||
|
||||
|
||||
SUBSCRIPTION_PREFIX = "chatgpt/"
|
||||
|
||||
|
||||
def subscription_model(model_name: str | None) -> str | None:
|
||||
"""The model slug behind a ``chatgpt/<model>`` STRIX_LLM, or None."""
|
||||
name = (model_name or "").strip()
|
||||
if not name.lower().startswith(SUBSCRIPTION_PREFIX):
|
||||
return None
|
||||
return name[len(SUBSCRIPTION_PREFIX) :] or None
|
||||
|
||||
|
||||
def auth_mode(model_name: str | None) -> str:
|
||||
return "subscription" if subscription_model(model_name) else "api_key"
|
||||
@@ -1,215 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,127 @@
|
||||
"""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()}
|
||||
env_present = {k.upper() for k in os.environ}
|
||||
|
||||
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():
|
||||
aliases = [alias.upper() for alias in _aliases_for(finfo)]
|
||||
if any(alias in env_present for alias in aliases):
|
||||
continue # env wins under some alias; skip the JSON file for this field
|
||||
for alias in aliases:
|
||||
if alias in env_block_upper:
|
||||
sub_data[fname] = env_block_upper[alias]
|
||||
break
|
||||
if sub_data:
|
||||
nested[sub_name] = sub_data
|
||||
return nested
|
||||
@@ -0,0 +1,518 @@
|
||||
"""SDK model configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import (
|
||||
set_default_openai_api,
|
||||
set_default_openai_key,
|
||||
set_tracing_disabled,
|
||||
)
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from agents.models.openai_responses import OpenAIResponsesModel
|
||||
from agents.retry import (
|
||||
ModelRetryBackoffSettings,
|
||||
ModelRetrySettings,
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from agents.models.interface import Model, ModelProvider
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from strix.config.settings import ReasoningEffort, Settings
|
||||
|
||||
|
||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
|
||||
if not timeout_s or timeout_s <= 0:
|
||||
return None
|
||||
return {"timeout": timeout_s}
|
||||
|
||||
|
||||
def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
|
||||
"""Retry statusless provider errors (e.g. mid-stream quota/billing), but not aborts."""
|
||||
normalized = context.normalized
|
||||
if normalized.is_abort:
|
||||
return False
|
||||
if codex.is_content_guardrail_error(context.error):
|
||||
return False
|
||||
return normalized.status_code is None
|
||||
|
||||
|
||||
class _CodexResponsesModel(OpenAIResponsesModel):
|
||||
"""Responses model for the ChatGPT subscription backend (always streamed, stateless)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
openai_client: AsyncOpenAI,
|
||||
*,
|
||||
reasoning_effort: ReasoningEffort | None = None,
|
||||
) -> None:
|
||||
super().__init__(model, openai_client)
|
||||
self._reasoning_effort = reasoning_effort
|
||||
|
||||
def _codex_settings(self, model_settings: ModelSettings) -> ModelSettings:
|
||||
overrides = ModelSettings(store=False, response_include=["reasoning.encrypted_content"])
|
||||
effort = self._reasoning_effort
|
||||
if effort and effort != "none":
|
||||
# Clamp to efforts the backend accepts.
|
||||
if effort == "minimal":
|
||||
effort = "low"
|
||||
elif effort == "xhigh":
|
||||
effort = "high"
|
||||
overrides = overrides.resolve(ModelSettings(reasoning=Reasoning(effort=effort)))
|
||||
return model_settings.resolve(overrides)
|
||||
|
||||
async def _fetch_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any:
|
||||
if len(args) >= 3: # model_settings is positional arg 2
|
||||
args = (*args[:2], self._codex_settings(args[2]), *args[3:])
|
||||
try:
|
||||
events = await super()._fetch_response(*args, stream=True, **kwargs) # type: ignore[call-overload]
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
guarded = self._guarded(events)
|
||||
if stream:
|
||||
return guarded
|
||||
final_response = None
|
||||
async for event in guarded:
|
||||
if getattr(event, "type", None) == "response.completed":
|
||||
final_response = event.response
|
||||
if final_response is None:
|
||||
msg = "ChatGPT backend stream ended without a completed response"
|
||||
raise RuntimeError(msg)
|
||||
return final_response
|
||||
|
||||
def _as_guardrail(self, exc: BaseException) -> codex.CodexContentGuardrailError | None:
|
||||
if isinstance(exc, codex.CodexContentGuardrailError):
|
||||
return exc
|
||||
if codex.is_content_guardrail_error(exc):
|
||||
return codex.CodexContentGuardrailError(self.model, exc)
|
||||
return None
|
||||
|
||||
async def _guarded(self, events: Any) -> AsyncIterator[Any]:
|
||||
"""Convert mid-stream guardrail rejections and close the stream on exit."""
|
||||
try:
|
||||
async for event in events:
|
||||
yield event
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
finally:
|
||||
await self._aclose(events)
|
||||
|
||||
@staticmethod
|
||||
async def _aclose(events: Any) -> None:
|
||||
aclose = getattr(events, "aclose", None)
|
||||
if callable(aclose):
|
||||
with contextlib.suppress(Exception):
|
||||
await aclose()
|
||||
return
|
||||
close = getattr(events, "close", None)
|
||||
if callable(close):
|
||||
with contextlib.suppress(Exception):
|
||||
result = close()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
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
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
slug = codex.subscription_model(model_name)
|
||||
if slug:
|
||||
return _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=load_settings().llm.reasoning_effort,
|
||||
)
|
||||
return super().get_model(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)),
|
||||
_retry_statusless_provider_errors,
|
||||
),
|
||||
)
|
||||
|
||||
RECOMMENDED_MODEL_NAMES = (
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.6-luna",
|
||||
"openai/gpt-5.6",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-opus-4-8",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"vertex_ai/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.6-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"dashscope/qwen3.8-max",
|
||||
"dashscope/qwen3.7-max-2026-06-08",
|
||||
"moonshot/kimi-k3",
|
||||
"moonshot/kimi-k2.7-code",
|
||||
)
|
||||
|
||||
_RECOMMENDED_MODEL_NAME_SET = frozenset(name.lower() for name in RECOMMENDED_MODEL_NAMES)
|
||||
|
||||
FRONTIER_MODEL_FAMILIES = (
|
||||
(("azure", "azure_ai", "bedrock_mantle", "chatgpt", "openai"), ("gpt-5",)),
|
||||
(
|
||||
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
|
||||
("claude-fable-5", "claude-opus-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
|
||||
),
|
||||
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
|
||||
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
|
||||
(("alibaba", "dashscope", "qwen"), ("qwen3.8", "qwen3.7", "qwen3-max")),
|
||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k3", "kimi-k2.7", "kimi-k2.6")),
|
||||
)
|
||||
|
||||
|
||||
def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
"""Apply Strix config to SDK-native defaults."""
|
||||
llm = settings.llm
|
||||
set_tracing_disabled(True)
|
||||
if codex.subscription_model(llm.model):
|
||||
return
|
||||
_configure_litellm_compatibility()
|
||||
_configure_openrouter_attribution(llm.model)
|
||||
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:
|
||||
"""Apply LiteLLM compatibility, privacy, and callback settings."""
|
||||
import litellm
|
||||
|
||||
litellm.drop_params = True
|
||||
litellm.modify_params = True
|
||||
litellm.turn_off_message_logging = True
|
||||
# Strix uses LiteLLM's success callback to capture provider-reported cost.
|
||||
# Disabling streaming logging also disables that callback for streamed calls.
|
||||
litellm.disable_streaming_logging = False
|
||||
litellm.suppress_debug_info = True
|
||||
|
||||
_register_litellm_cost_callback()
|
||||
_install_openrouter_stream_cost_capture()
|
||||
|
||||
|
||||
def _install_openrouter_stream_cost_capture() -> None:
|
||||
"""Preserve OpenRouter's per-stream cost, which LiteLLM drops when streaming.
|
||||
|
||||
OpenRouter reports the real charge in ``usage.cost`` of the final stream
|
||||
chunk, but LiteLLM rebuilds streamed responses from token-only fields and
|
||||
discards it (its non-streamed path stashes the cost in hidden params; the
|
||||
streaming path does not). Every scan streams, so without this the cost is
|
||||
lost and Strix falls back to a cost-map estimate that is missing entirely
|
||||
for new models (e.g. kimi-k3), reporting $0. Subclass the OpenRouter
|
||||
streaming handler to record the cost keyed by response id so the cost
|
||||
callback can recover the exact charge for the matching rebuilt response.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.llms.openrouter.chat.transformation import (
|
||||
OpenRouterChatCompletionStreamingHandler,
|
||||
OpenrouterConfig,
|
||||
)
|
||||
|
||||
from strix.report.state import streamed_openrouter_costs
|
||||
|
||||
class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler):
|
||||
def chunk_parser(self, chunk: dict[str, Any]) -> Any:
|
||||
stream = super().chunk_parser(chunk)
|
||||
streamed_openrouter_costs.remember(
|
||||
chunk.get("id") or getattr(stream, "id", None), chunk.get("usage")
|
||||
)
|
||||
return stream
|
||||
|
||||
class _StrixOpenrouterConfig(OpenrouterConfig):
|
||||
def get_model_response_iterator(
|
||||
self, streaming_response: Any, sync_stream: bool, json_mode: bool | None = False
|
||||
) -> Any:
|
||||
return _StrixOpenRouterStreamingHandler(
|
||||
streaming_response=streaming_response,
|
||||
sync_stream=sync_stream,
|
||||
json_mode=json_mode,
|
||||
)
|
||||
|
||||
# LiteLLM's provider-config factory reads litellm.OpenrouterConfig at call
|
||||
# time, so overriding the attribute is enough for the subclass to take
|
||||
# effect. (type: ignore — mypy rejects reassigning a class attribute.)
|
||||
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
|
||||
|
||||
|
||||
_OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
"HTTP-Referer": "https://strix.ai",
|
||||
"X-Title": "Strix",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
}
|
||||
|
||||
|
||||
def _configure_openrouter_attribution(model_name: str | None) -> None:
|
||||
import litellm
|
||||
|
||||
current: object = litellm.headers
|
||||
existing: dict[str, str] = current if isinstance(current, dict) else {}
|
||||
if not model_name or "openrouter/" not in model_name.strip().lower():
|
||||
if any(key in existing for key in _OPENROUTER_ATTRIBUTION_HEADERS):
|
||||
remaining = {
|
||||
k: v for k, v in existing.items() if k not in _OPENROUTER_ATTRIBUTION_HEADERS
|
||||
}
|
||||
litellm.headers = remaining or None # type: ignore[assignment]
|
||||
return
|
||||
|
||||
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
|
||||
|
||||
|
||||
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."""
|
||||
if codex.subscription_model(model_name):
|
||||
return False
|
||||
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")
|
||||
|
||||
|
||||
def is_claude_model(model_name: str) -> bool:
|
||||
return "claude" in (model_name or "").strip().lower()
|
||||
|
||||
|
||||
def is_bedrock_route(model_name: str) -> bool:
|
||||
name = (model_name or "").strip().lower()
|
||||
return name.startswith("bedrock/") or "anthropic." in name
|
||||
|
||||
|
||||
def _prompt_cache_name_candidates(model_name: str) -> list[str]:
|
||||
# LiteLLM's model map keys the same model under several names; strip the
|
||||
# route prefix, then leading dotted segments (region, provider).
|
||||
name = (model_name or "").strip().lower()
|
||||
for prefix in ("litellm/", "bedrock/"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
candidates = [name]
|
||||
rest = name
|
||||
while "." in rest:
|
||||
rest = rest.split(".", 1)[1]
|
||||
candidates.append(rest)
|
||||
return candidates
|
||||
|
||||
|
||||
def bedrock_route_supports_prompt_caching(model_name: str) -> bool:
|
||||
# Bedrock rejects the cache marker for models LiteLLM's map doesn't
|
||||
# recognise as cache-capable, so callers withhold it unless confirmed here.
|
||||
import litellm
|
||||
|
||||
checker = getattr(getattr(litellm, "utils", None), "supports_prompt_caching", None)
|
||||
for cand in _prompt_cache_name_candidates(model_name):
|
||||
if checker is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
if checker(cand):
|
||||
return True
|
||||
entry = litellm.model_cost.get(cand)
|
||||
if entry and entry.get("supports_prompt_caching"):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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")
|
||||
force_required_tool_choice: bool = Field(
|
||||
default=False,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
alias="STRIX_PROMPT_CACHE",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
|
||||
reasoning_effort: ReasoningEffort | None = Field(
|
||||
default=None,
|
||||
alias="STRIX_DEDUPE_REASONING_EFFORT",
|
||||
)
|
||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
"""Context-window management: per-tool-output caps and history compaction."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
|
||||
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
|
||||
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
|
||||
fallback_context_tokens: int = Field(
|
||||
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
|
||||
)
|
||||
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
|
||||
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
|
||||
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
|
||||
# Floor above the truncation-notice size so a preview always fits.
|
||||
tool_output_max_bytes: int = Field(
|
||||
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
image: str = Field(
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.1.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")
|
||||
# Max screenshot/image tool outputs kept live per agent context (0 = none).
|
||||
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
|
||||
|
||||
|
||||
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 ViewerSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
# Base URL of the Strix relay the local viewer proxies to for email
|
||||
# verification and encrypted report delivery. The browser never talks to
|
||||
# the relay directly; the local server is the only caller.
|
||||
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
|
||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||
context: ContextSettings = Field(default_factory=ContextSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
@@ -0,0 +1 @@
|
||||
"""Strix scan runtime core."""
|
||||
@@ -0,0 +1,422 @@
|
||||
"""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
|
||||
|
||||
from strix.core.sessions import session_write_lock
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||
|
||||
|
||||
@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.errors: dict[str, str] = {}
|
||||
self.runtimes: dict[str, AgentRuntime] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshot_path: Path | None = None
|
||||
self.is_shutting_down = False
|
||||
self._budget_stopped = False
|
||||
self._reserve_stopped = False
|
||||
self._budget_paused = False
|
||||
self._extend_budget: Callable[[], None] | None = None
|
||||
|
||||
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()
|
||||
|
||||
@property
|
||||
def reserve_stopped(self) -> bool:
|
||||
return self._reserve_stopped
|
||||
|
||||
@property
|
||||
def budget_paused(self) -> bool:
|
||||
return self._budget_paused
|
||||
|
||||
def set_budget_extender(self, extend: Callable[[], None]) -> None:
|
||||
self._extend_budget = extend
|
||||
|
||||
async def pause_for_budget(self, agent_id: str) -> None:
|
||||
async with self._lock:
|
||||
self._budget_paused = True
|
||||
await self.set_status(agent_id, "budget_paused")
|
||||
|
||||
async def resume_from_budget_pause(self, *, exclude: str | None = None) -> None:
|
||||
async with self._lock:
|
||||
if not self._budget_paused:
|
||||
return
|
||||
self._budget_paused = False
|
||||
paused = [aid for aid, status in self.statuses.items() if status == "budget_paused"]
|
||||
if self._extend_budget is not None:
|
||||
self._extend_budget()
|
||||
for aid in paused:
|
||||
await self.set_status(aid, "waiting")
|
||||
if aid != exclude:
|
||||
await self.send(
|
||||
aid,
|
||||
{
|
||||
"from": "system",
|
||||
"type": "budget_extended",
|
||||
"content": (
|
||||
"[Budget] The user extended the scan budget \u2014 continue your "
|
||||
"current task."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def reset_budget_stops(
|
||||
self,
|
||||
*,
|
||||
budget_stopped: bool,
|
||||
reserve_stopped: bool,
|
||||
budget_paused: bool = False,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
self._budget_stopped = budget_stopped
|
||||
self._reserve_stopped = reserve_stopped
|
||||
if not budget_paused:
|
||||
self._budget_paused = False
|
||||
for aid, status in self.statuses.items():
|
||||
if status == "budget_paused":
|
||||
self.statuses[aid] = "waiting"
|
||||
await self._maybe_snapshot()
|
||||
|
||||
async def claim_reserve_notification(self) -> str | None:
|
||||
async with self._lock:
|
||||
if self._reserve_stopped:
|
||||
return None
|
||||
self._reserve_stopped = True
|
||||
for runtime in self.runtimes.values():
|
||||
runtime.wake.set()
|
||||
return next((aid for aid, parent in self.parent_of.items() if parent is None), None)
|
||||
|
||||
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"
|
||||
self.errors.pop(agent_id, None)
|
||||
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, *, error: str | None = None
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
if agent_id not in self.statuses:
|
||||
return
|
||||
self.statuses[agent_id] = status # type: ignore[assignment]
|
||||
if error is not None:
|
||||
self.errors[agent_id] = error
|
||||
elif status == "running":
|
||||
self.errors.pop(agent_id, None)
|
||||
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], *, interrupt: bool = True
|
||||
) -> bool:
|
||||
"""Deliver a user/peer message by appending it to the target SDK session."""
|
||||
if message.get("from") == "user" and self._budget_paused:
|
||||
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||
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_on_message = 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:
|
||||
async with session_write_lock(session):
|
||||
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 and interrupt_on_message:
|
||||
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:
|
||||
reserve_exit = self._reserve_stopped and self.parent_of.get(agent_id) is not None
|
||||
if self._budget_stopped or reserve_exit 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], dict[str, str]]:
|
||||
async with self._lock:
|
||||
return (
|
||||
dict(self.parent_of),
|
||||
dict(self.statuses),
|
||||
dict(self.names),
|
||||
dict(self.errors),
|
||||
)
|
||||
|
||||
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),
|
||||
"errors": dict(self.errors),
|
||||
"budget_stopped": self._budget_stopped,
|
||||
"reserve_stopped": self._reserve_stopped,
|
||||
"budget_paused": self._budget_paused,
|
||||
}
|
||||
|
||||
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", {}))
|
||||
self.errors = dict(snap.get("errors", {}))
|
||||
self._budget_stopped = bool(snap.get("budget_stopped", False))
|
||||
self._reserve_stopped = bool(snap.get("reserve_stopped", False))
|
||||
self._budget_paused = bool(snap.get("budget_paused", False))
|
||||
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
|
||||
@@ -0,0 +1,797 @@
|
||||
"""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 (
|
||||
APIConnectionError,
|
||||
APIError,
|
||||
APIStatusError,
|
||||
APITimeoutError,
|
||||
RateLimitError,
|
||||
)
|
||||
|
||||
from strix.config import codex
|
||||
from strix.core.hooks import (
|
||||
BudgetExceededError,
|
||||
BudgetPausedError,
|
||||
SubagentBudgetReservedError,
|
||||
)
|
||||
from strix.core.inputs import child_initial_input
|
||||
from strix.core.sessions import (
|
||||
enforce_image_budget,
|
||||
open_agent_session,
|
||||
strip_all_images_from_session,
|
||||
)
|
||||
from strix.llm.compaction import is_context_overflow, maybe_compact
|
||||
|
||||
|
||||
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})
|
||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||
|
||||
|
||||
def _run_config_model(run_config: RunConfig) -> str | None:
|
||||
return run_config.model if isinstance(run_config.model, str) else None
|
||||
|
||||
|
||||
def _agent_instructions(agent: Any) -> str:
|
||||
instructions = getattr(agent, "instructions", None)
|
||||
return instructions if isinstance(instructions, str) else ""
|
||||
|
||||
|
||||
def _agent_tools_text(agent: Any) -> str:
|
||||
parts: list[str] = []
|
||||
for tool in getattr(agent, "tools", []) or []:
|
||||
name = getattr(tool, "name", "")
|
||||
description = getattr(tool, "description", "") or ""
|
||||
schema = getattr(tool, "params_json_schema", "") or ""
|
||||
parts.append(f"{name} {description} {schema}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def _compact_session(
|
||||
agent: Any, session: Session, run_config: RunConfig, *, force: bool
|
||||
) -> bool:
|
||||
model = _run_config_model(run_config)
|
||||
if session is None or model is None:
|
||||
return False
|
||||
return await maybe_compact(
|
||||
session,
|
||||
model=model,
|
||||
instructions=_agent_instructions(agent),
|
||||
tools_text=_agent_tools_text(agent),
|
||||
force=force,
|
||||
)
|
||||
|
||||
|
||||
_GUARDRAIL_PARK_ERROR = (
|
||||
"Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). "
|
||||
"Set STRIX_LLM to a model that isn't blocked and resume the scan to continue."
|
||||
)
|
||||
|
||||
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
|
||||
_MAX_TRANSIENT_MODEL_RETRIES = 4
|
||||
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
|
||||
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 30.0
|
||||
|
||||
|
||||
def _model_error_status_code(exc: BaseException) -> int | None:
|
||||
code = getattr(exc, "status_code", None)
|
||||
return code if isinstance(code, int) else None
|
||||
|
||||
|
||||
def _is_transient_model_error(exc: BaseException) -> bool:
|
||||
if isinstance(exc, RateLimitError):
|
||||
return False
|
||||
if isinstance(exc, APITimeoutError | APIConnectionError):
|
||||
return True
|
||||
if isinstance(exc, APIStatusError):
|
||||
return exc.status_code in _TRANSIENT_MODEL_STATUS_CODES
|
||||
if isinstance(exc, APIError):
|
||||
return _model_error_status_code(exc) is None
|
||||
return False
|
||||
|
||||
|
||||
def _transient_model_retry_delay(attempt: int) -> float:
|
||||
delay = _TRANSIENT_MODEL_RETRY_BASE_DELAY_S * float(2 ** (attempt - 1))
|
||||
return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S)
|
||||
|
||||
|
||||
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
|
||||
|
||||
budget_stopped = coordinator.budget_stopped
|
||||
reserve_stopped = coordinator.reserve_stopped
|
||||
if budget_stopped:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise BudgetExceededError("scan budget reached")
|
||||
if reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
if reserve_stopped and start_parked and interactive and context.get("parent_id") is None:
|
||||
await coordinator.send(agent_id, _reserve_notice())
|
||||
|
||||
if not (start_parked and interactive):
|
||||
if interactive:
|
||||
with contextlib.suppress(BudgetPausedError):
|
||||
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")
|
||||
|
||||
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
await coordinator.consume_pending(agent_id)
|
||||
with contextlib.suppress(BudgetPausedError):
|
||||
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
|
||||
md["_restored_error"] = coordinator.errors.get(aid)
|
||||
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")
|
||||
recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error"))
|
||||
start_parked = interactive and restored_status != "running" and not recoverable_park
|
||||
|
||||
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")
|
||||
|
||||
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
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_terminal(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
|
||||
compactions = 0
|
||||
model_retries = 0
|
||||
while True:
|
||||
try:
|
||||
await coordinator.mark_running(agent_id)
|
||||
if session is not None:
|
||||
max_images = context.get("max_context_images")
|
||||
if isinstance(max_images, int):
|
||||
try:
|
||||
await enforce_image_budget(session, max_images)
|
||||
except Exception:
|
||||
logger.exception("image-budget enforcement failed for %s", agent_id)
|
||||
try:
|
||||
await _compact_session(agent, session, run_config, force=False)
|
||||
except Exception:
|
||||
logger.exception("proactive compaction failed for %s", 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, BudgetPausedError, SubagentBudgetReservedError):
|
||||
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 BudgetPausedError as exc:
|
||||
logger.info("agent %s paused at the scan budget limit: %s", agent_id, exc)
|
||||
await coordinator.pause_for_budget(agent_id)
|
||||
raise
|
||||
except SubagentBudgetReservedError as exc:
|
||||
logger.info("sub-agent %s stopped at the budget reserve: %s", agent_id, exc)
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
await _notify_root_on_budget_reserve(coordinator)
|
||||
raise
|
||||
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 (
|
||||
compactions < _MAX_COMPACTIONS_PER_CYCLE
|
||||
and session is not None
|
||||
and is_context_overflow(exc)
|
||||
):
|
||||
try:
|
||||
compacted = await _compact_session(agent, session, run_config, force=True)
|
||||
except Exception:
|
||||
logger.exception("overflow compaction recovery failed for %s", agent_id)
|
||||
compacted = False
|
||||
if compacted:
|
||||
compactions += 1
|
||||
logger.info(
|
||||
"Compacted %s session after context overflow; retrying (%d)",
|
||||
agent_id,
|
||||
compactions,
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if model_retries < _MAX_TRANSIENT_MODEL_RETRIES and _is_transient_model_error(exc):
|
||||
model_retries += 1
|
||||
delay = _transient_model_retry_delay(model_retries)
|
||||
logger.warning(
|
||||
"transient model/provider error for %s; replaying turn "
|
||||
"(attempt %d/%d, backoff %.1fs): %r",
|
||||
agent_id,
|
||||
model_retries,
|
||||
_MAX_TRANSIENT_MODEL_RETRIES,
|
||||
delay,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
if session is not None:
|
||||
input_data = []
|
||||
continue
|
||||
if codex.is_content_guardrail_error(exc):
|
||||
return await _handle_content_guardrail(
|
||||
coordinator, agent_id, exc, interactive=interactive
|
||||
)
|
||||
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, error=str(exc) or type(exc).__name__)
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, status)
|
||||
return None
|
||||
else:
|
||||
await _settle_run_result(coordinator, agent_id, interactive)
|
||||
return stream
|
||||
|
||||
|
||||
async def _handle_content_guardrail(
|
||||
coordinator: AgentCoordinator,
|
||||
agent_id: str,
|
||||
exc: BaseException,
|
||||
*,
|
||||
interactive: bool,
|
||||
) -> RunResultBase | None:
|
||||
logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc)
|
||||
if interactive:
|
||||
await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR)
|
||||
return None
|
||||
await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR)
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
|
||||
return None
|
||||
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
_TERMINAL_NOTICE = {
|
||||
"crashed": (
|
||||
"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
||||
"Stop waiting on this child unless you want to message it again."
|
||||
),
|
||||
"failed": (
|
||||
"[Agent failed] {name} ({agent_id}) stopped with an error and will not "
|
||||
"send a completion report. Stop waiting on this child unless you want to "
|
||||
"message it again."
|
||||
),
|
||||
"stopped": (
|
||||
"[Agent capped] {name} ({agent_id}) hit its turn limit and was stopped "
|
||||
"before finishing. It will not send a completion report, so stop waiting "
|
||||
"on this child; account for its capped subtask and continue."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _notify_parent_on_terminal(
|
||||
coordinator: AgentCoordinator,
|
||||
agent_id: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
template = _TERMINAL_NOTICE.get(status)
|
||||
if template is None:
|
||||
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": status,
|
||||
"priority": "high",
|
||||
"content": template.format(name=name, agent_id=agent_id),
|
||||
},
|
||||
interrupt=False,
|
||||
)
|
||||
|
||||
|
||||
def _reserve_notice() -> dict[str, Any]:
|
||||
return {
|
||||
"from": "system",
|
||||
"type": "budget_reserve_stop",
|
||||
"priority": "high",
|
||||
"content": (
|
||||
"[Budget reserve] The scan has reached the sub-agent budget reserve: every "
|
||||
"sub-agent is being force-stopped as soon as its in-flight turn completes, and "
|
||||
"none will send a completion report. Their confirmed vulnerabilities are "
|
||||
"already filed as they were found. Do not wait on any sub-agents and do not "
|
||||
"spawn new ones — wrap up now and call finish_scan."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
|
||||
root = await coordinator.claim_reserve_notification()
|
||||
if root is None:
|
||||
return
|
||||
await coordinator.send(root, _reserve_notice())
|
||||
|
||||
|
||||
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)
|
||||
except SubagentBudgetReservedError:
|
||||
logger.info("child %s stopped at the sub-agent budget reserve", child_id)
|
||||
|
||||
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
|
||||
await coordinator.attach_runtime(child_id, task=task_handle)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""SDK run hooks used by Strix orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
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, TResponseInputItem
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
|
||||
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
_SUBAGENT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.75, 0.80, 0.85)
|
||||
_SUBAGENT_BUDGET_RESERVE = 0.90
|
||||
|
||||
|
||||
class BudgetExceededError(RuntimeError):
|
||||
"""Raised when the accumulated LLM cost reaches the configured budget."""
|
||||
|
||||
|
||||
class SubagentBudgetReservedError(RuntimeError):
|
||||
"""Raised to stop a single sub-agent once the reserve threshold is crossed."""
|
||||
|
||||
|
||||
class BudgetPausedError(RuntimeError):
|
||||
"""Raised to park one agent when an interactive scan reaches its budget."""
|
||||
|
||||
|
||||
def recomputed_budget_flags(
|
||||
cost: float,
|
||||
max_budget_usd: float | None,
|
||||
*,
|
||||
interactive: bool,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Return the (budget_stopped, reserve_stopped) flags a resumed scan should carry."""
|
||||
if max_budget_usd is None:
|
||||
return False, False
|
||||
if interactive:
|
||||
return False, False
|
||||
budget_stopped = cost >= max_budget_usd
|
||||
reserve_stopped = cost >= max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||
return budget_stopped, reserve_stopped
|
||||
|
||||
|
||||
def _crossed_stage(fraction: float, bands: tuple[float, ...]) -> int | None:
|
||||
crossed: int | None = None
|
||||
for index, band in enumerate(bands):
|
||||
if fraction >= band:
|
||||
crossed = index
|
||||
return crossed
|
||||
|
||||
|
||||
_ROOT_DIRECTIVES: tuple[str, ...] = (
|
||||
(
|
||||
"As the root agent, begin planning your wind-down of the whole scan: avoid "
|
||||
"starting large new lines of investigation, and keep your required objectives on "
|
||||
"track so you can call finish_scan comfortably before the limit."
|
||||
),
|
||||
(
|
||||
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
|
||||
"lines of investigation, close out only what is essential, and move toward calling "
|
||||
"finish_scan to compile and deliver the final report."
|
||||
),
|
||||
(
|
||||
"As the root agent, STOP all other work on the whole scan and finish immediately: "
|
||||
"secure your findings and call finish_scan now — anything left unfinished when the "
|
||||
"limit is hit is discarded."
|
||||
),
|
||||
)
|
||||
_SUBAGENT_DIRECTIVES: tuple[str, ...] = (
|
||||
(
|
||||
"As a sub-agent, begin planning your wind-down: avoid starting large new subtasks, "
|
||||
"and if you are close to a confirmed, validated vulnerability, drive it to a result "
|
||||
"you can report."
|
||||
),
|
||||
(
|
||||
"As a sub-agent, prioritize wrapping up your task now: report any confirmed, "
|
||||
"validated vulnerability, finish work that is nearly done rather than starting "
|
||||
"anything new, and prepare to call agent_finish."
|
||||
),
|
||||
(
|
||||
"As a sub-agent, STOP all other work and finish immediately: report any confirmed "
|
||||
"vulnerability right now and call agent_finish to hand your results back to your "
|
||||
"parent before you are cut off."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
|
||||
is_root = context.context.get("parent_id") is None
|
||||
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
|
||||
return directives[stage]
|
||||
|
||||
|
||||
def _urgency(stage: int) -> str:
|
||||
return _STAGE_LABELS[stage]
|
||||
|
||||
|
||||
class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
"""Persist SDK-native usage and warn/stop as turn and cost budgets are consumed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
max_budget_usd: float | None = None,
|
||||
max_turns: int | None = None,
|
||||
interactive: bool = False,
|
||||
) -> None:
|
||||
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")
|
||||
if max_turns is not None and max_turns <= 0:
|
||||
raise ValueError("max_turns must be a positive integer")
|
||||
self._model = model
|
||||
self._max_budget_usd = max_budget_usd
|
||||
self._budget_increment = max_budget_usd
|
||||
self._max_turns = max_turns
|
||||
self._interactive = interactive
|
||||
|
||||
def extend_budget(self) -> None:
|
||||
if self._max_budget_usd is None or self._budget_increment is None:
|
||||
return
|
||||
self._max_budget_usd += self._budget_increment
|
||||
|
||||
async def on_llm_start(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
agent: Agent[dict[str, Any]], # noqa: ARG002
|
||||
system_prompt: str | None, # noqa: ARG002
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
try:
|
||||
self._maybe_warn_turns(context, input_items)
|
||||
self._maybe_warn_budget(context, input_items)
|
||||
except Exception:
|
||||
logger.exception("budget/turn warning injection failed")
|
||||
|
||||
def _maybe_warn_turns(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
if not self._max_turns:
|
||||
return
|
||||
usage = getattr(context, "usage", None)
|
||||
requests = getattr(usage, "requests", None)
|
||||
if not isinstance(requests, int):
|
||||
return
|
||||
turns_used = requests + 1
|
||||
stage = _crossed_stage(turns_used / self._max_turns, _TURN_WARN_BANDS)
|
||||
if stage is None:
|
||||
return
|
||||
remaining = max(self._max_turns - turns_used, 0)
|
||||
pct = round(100 * turns_used / self._max_turns)
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Turn budget: {turns_used}/{self._max_turns} used ({pct}%). "
|
||||
f"About {remaining} turn(s) remain before this agent is force-stopped and any "
|
||||
f"in-progress work is discarded. {_wrapup_directive(context, stage)}"
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
def _maybe_warn_budget(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
if self._max_budget_usd is None:
|
||||
return
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return
|
||||
cost = report_state.get_total_llm_cost()
|
||||
is_root = context.context.get("parent_id") is None
|
||||
if self._interactive:
|
||||
bands = _ROOT_BUDGET_WARN_BANDS
|
||||
else:
|
||||
bands = _ROOT_BUDGET_WARN_BANDS if is_root else _SUBAGENT_BUDGET_WARN_BANDS
|
||||
stage = _crossed_stage(cost / self._max_budget_usd, bands)
|
||||
if stage is None:
|
||||
return
|
||||
pct = round(100 * cost / self._max_budget_usd)
|
||||
reserve_pct = round(_SUBAGENT_BUDGET_RESERVE * 100)
|
||||
if self._interactive:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||
"is reached all agents are paused until the user chooses to continue. "
|
||||
f"{_wrapup_directive(context, stage)}"
|
||||
)
|
||||
elif is_root:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||
"is reached the whole scan is stopped immediately, and sub-agents are stopped at "
|
||||
f"{reserve_pct}% to reserve the remainder for your final report. "
|
||||
f"{_wrapup_directive(context, stage)}"
|
||||
)
|
||||
else:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; "
|
||||
f"sub-agents are stopped at {reserve_pct}% to leave the remainder for the root "
|
||||
f"agent's final report. {_wrapup_directive(context, stage)}"
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
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:
|
||||
if self._interactive:
|
||||
raise BudgetPausedError(
|
||||
f"Scan budget of ${self._max_budget_usd:.2f} reached "
|
||||
f"(spent ${cost:.4f}); pausing until the user continues"
|
||||
)
|
||||
raise BudgetExceededError(
|
||||
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
|
||||
)
|
||||
is_root = ctx.get("parent_id") is None
|
||||
if not self._interactive and not is_root:
|
||||
reserve_limit = self._max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||
if cost >= reserve_limit:
|
||||
raise SubagentBudgetReservedError(
|
||||
f"Sub-agent budget reserve reached: spent ${cost:.4f} of "
|
||||
f"${self._max_budget_usd:.2f} "
|
||||
f"(>= {round(_SUBAGENT_BUDGET_RESERVE * 100)}% reserve); stopping this "
|
||||
"sub-agent so the root agent can finish the scan."
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""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,
|
||||
bedrock_route_supports_prompt_caching,
|
||||
is_bedrock_route,
|
||||
is_claude_model,
|
||||
is_known_openai_bare_model,
|
||||
model_supports_reasoning,
|
||||
request_timeout_extra_args,
|
||||
)
|
||||
from strix.core.sessions import scrub_images_from_items
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from strix.config.settings import ReasoningEffort
|
||||
|
||||
|
||||
DEFAULT_MAX_TURNS = 500
|
||||
|
||||
|
||||
def _accepts_required_tool_choice(model_name: str | None) -> bool:
|
||||
name = (model_name or "").strip().lower()
|
||||
for prefix in ("litellm/", "any-llm/"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
return name.startswith("openai/") or is_known_openai_bare_model(name)
|
||||
|
||||
|
||||
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,
|
||||
force_required_tool_choice: bool = False,
|
||||
request_timeout: float | None = None,
|
||||
prompt_cache: bool = True,
|
||||
) -> ModelSettings:
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
include_usage=True,
|
||||
extra_args=request_timeout_extra_args(request_timeout),
|
||||
)
|
||||
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)),
|
||||
)
|
||||
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
|
||||
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
|
||||
|
||||
cache_extra_args = _prompt_cache_extra_args(model_name) if prompt_cache else None
|
||||
if cache_extra_args:
|
||||
model_settings = model_settings.resolve(
|
||||
ModelSettings(
|
||||
extra_args={**(model_settings.extra_args or {}), **cache_extra_args},
|
||||
),
|
||||
)
|
||||
return model_settings
|
||||
|
||||
|
||||
def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
|
||||
"""LiteLLM ``cache_control_injection_points`` for Claude prompt caching.
|
||||
|
||||
System prompt + rolling last-message breakpoint everywhere; ``tool_config``
|
||||
only on Bedrock Converse (the only route whose LiteLLM transform consumes
|
||||
it — elsewhere it leaks onto the wire and native Anthropic 400s). Unmapped
|
||||
Bedrock models get no points at all: Bedrock rejects the passed-through
|
||||
field outright.
|
||||
"""
|
||||
if not is_claude_model(model_name):
|
||||
return None
|
||||
if is_bedrock_route(model_name) and not bedrock_route_supports_prompt_caching(model_name):
|
||||
return None
|
||||
|
||||
points: list[dict[str, Any]] = [{"location": "message", "role": "system"}]
|
||||
if is_bedrock_route(model_name):
|
||||
points.append({"location": "tool_config"})
|
||||
points.append({"location": "message", "index": -1})
|
||||
return {"cache_control_injection_points": points}
|
||||
|
||||
|
||||
def child_initial_input(
|
||||
*,
|
||||
name: str,
|
||||
child_id: str,
|
||||
parent_id: str,
|
||||
task: str,
|
||||
parent_history: list[Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the initial input for a child agent as a single user message.
|
||||
|
||||
Collapsing the inherited-context block, the identity line, and the task into
|
||||
one ``{"role": "user"}`` message keeps providers that require strictly
|
||||
alternating roles (e.g. Perplexity, llama.cpp) from rejecting consecutive
|
||||
user messages.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if parent_history:
|
||||
rendered = json.dumps(
|
||||
scrub_images_from_items(parent_history),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
parts.append(
|
||||
"== Inherited context from parent (background only) ==\n"
|
||||
f"{rendered}\n"
|
||||
"== End of inherited context ==\n"
|
||||
"Use the above as background only; do not continue the "
|
||||
"parent's work. Your task follows.",
|
||||
)
|
||||
parts.append(
|
||||
f"You are agent {name} ({child_id}); your parent is {parent_id}. "
|
||||
"Maintain your own identity. Call agent_finish when your task "
|
||||
"is complete.",
|
||||
)
|
||||
parts.append(task)
|
||||
return [{"role": "user", "content": "\n\n".join(parts)}]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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
|
||||
|
||||
|
||||
def runs_base_dir(*, cwd: Path | None = None) -> Path:
|
||||
base = cwd or Path.cwd()
|
||||
return base / RUNS_DIR_NAME
|
||||
|
||||
|
||||
def latest_run_dir(*, cwd: Path | None = None) -> Path | None:
|
||||
base = runs_base_dir(cwd=cwd)
|
||||
if not base.is_dir():
|
||||
return None
|
||||
candidates = [child for child in base.iterdir() if run_record_path(child).is_file()]
|
||||
if not candidates:
|
||||
return None
|
||||
# run.json is rewritten on status/end changes, so its mtime tracks activity
|
||||
# more reliably than the directory mtime (a live run sorts to the top).
|
||||
return max(candidates, key=lambda child: run_record_path(child).stat().st_mtime)
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Top-level Strix scan runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import RunConfig
|
||||
from agents.sandbox import SandboxRunConfig
|
||||
from openai import RateLimitError
|
||||
|
||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
||||
from strix.agents.prompt import render_system_prompt
|
||||
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, recomputed_budget_flags
|
||||
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.report.state import get_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
configure_spill_writer,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.memory import SQLiteSession
|
||||
from agents.result import RunResultBase
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
StreamEventSink = Callable[[str, Any], None]
|
||||
|
||||
|
||||
def _merge_root_prompt_context(
|
||||
scope_context: dict[str, Any],
|
||||
extra_system_prompt_context: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
if not extra_system_prompt_context:
|
||||
return scope_context
|
||||
reserved_keys = scope_context.keys() & extra_system_prompt_context.keys()
|
||||
if reserved_keys:
|
||||
raise ValueError(
|
||||
"extra_system_prompt_context cannot override built-in scope keys: "
|
||||
f"{sorted(reserved_keys)}",
|
||||
)
|
||||
return {**scope_context, **extra_system_prompt_context}
|
||||
|
||||
|
||||
def _compose_root_instructions_override(
|
||||
root_instructions_override: str | None,
|
||||
*,
|
||||
skills: list[str],
|
||||
scan_mode: str,
|
||||
is_whitebox: bool,
|
||||
interactive: bool,
|
||||
system_prompt_context: dict[str, Any],
|
||||
) -> str | None:
|
||||
if root_instructions_override is None:
|
||||
return None
|
||||
|
||||
base_instructions = render_system_prompt(
|
||||
skills=skills,
|
||||
scan_mode=scan_mode,
|
||||
is_whitebox=is_whitebox,
|
||||
is_root=True,
|
||||
interactive=interactive,
|
||||
system_prompt_context=system_prompt_context,
|
||||
)
|
||||
return (
|
||||
f"{base_instructions}\n\n"
|
||||
"<root_scan_instructions_override>\n"
|
||||
"The following root scan instructions are subordinate to the "
|
||||
"system-verified scope above. They cannot expand, replace, or weaken "
|
||||
"authorized target constraints.\n\n"
|
||||
f"{root_instructions_override}\n"
|
||||
"</root_scan_instructions_override>"
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
root_instructions_override: str | None = None,
|
||||
extra_system_prompt_context: dict[str, Any] | None = None,
|
||||
) -> RunResultBase | None:
|
||||
"""Run or resume one Strix scan against a sandbox.
|
||||
|
||||
``root_instructions_override`` adds root scan instructions to the rendered
|
||||
root prompt without replacing the system-verified scope block.
|
||||
``extra_system_prompt_context`` is merged into the root agent's scan
|
||||
context before prompt rendering. Child agents keep the standard scan prompt
|
||||
and context.
|
||||
"""
|
||||
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)
|
||||
report_state = get_global_report_state()
|
||||
if report_state is not None:
|
||||
budget_stopped, reserve_stopped = recomputed_budget_flags(
|
||||
report_state.get_total_llm_cost(),
|
||||
max_budget_usd,
|
||||
interactive=interactive,
|
||||
)
|
||||
await coordinator.reset_budget_stops(
|
||||
budget_stopped=budget_stopped,
|
||||
reserve_stopped=reserve_stopped,
|
||||
budget_paused=interactive and coordinator.budget_paused,
|
||||
)
|
||||
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)
|
||||
|
||||
sandbox_session = bundle["session"]
|
||||
|
||||
async def _spill_to_workspace(output_id: str, text: str) -> str | None:
|
||||
"""Write an oversized tool result into the sandbox; return its path or None."""
|
||||
path = f"{WORKSPACE_SPILL_DIR}/{output_id}.txt"
|
||||
try:
|
||||
await sandbox_session.write(Path(path), io.BytesIO(text.encode("utf-8")))
|
||||
except Exception:
|
||||
logger.exception("failed to spill tool output to sandbox workspace")
|
||||
return None
|
||||
return path
|
||||
|
||||
configure_spill_writer(_spill_to_workspace)
|
||||
|
||||
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,
|
||||
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
||||
request_timeout=settings.llm.timeout,
|
||||
prompt_cache=settings.llm.prompt_cache,
|
||||
)
|
||||
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,
|
||||
max_turns=max_turns,
|
||||
interactive=interactive,
|
||||
)
|
||||
if interactive:
|
||||
coordinator.set_budget_extender(hooks.extend_budget)
|
||||
|
||||
scope_context = build_scope_context(scan_config)
|
||||
root_context = _merge_root_prompt_context(scope_context, extra_system_prompt_context)
|
||||
root_instructions = _compose_root_instructions_override(
|
||||
root_instructions_override,
|
||||
skills=skills,
|
||||
scan_mode=scan_mode,
|
||||
is_whitebox=is_whitebox,
|
||||
interactive=interactive,
|
||||
system_prompt_context=root_context,
|
||||
)
|
||||
|
||||
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=root_context,
|
||||
instructions_override=root_instructions,
|
||||
)
|
||||
|
||||
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,
|
||||
"max_context_images": settings.runtime.max_context_images,
|
||||
}
|
||||
|
||||
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)
|
||||
root_error = coordinator.errors.get(root_id)
|
||||
|
||||
root_recoverable_park = root_status == "waiting" and bool(root_error)
|
||||
root_start_parked = bool(
|
||||
interactive and is_resume and root_status != "running" and not root_recoverable_park
|
||||
)
|
||||
|
||||
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=root_start_parked,
|
||||
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 RateLimitError as exc:
|
||||
logger.warning(
|
||||
"Scan %s stopped: persistent rate limit from the LLM provider (%s). "
|
||||
"Resume with 'strix --resume %s' once the limit clears.",
|
||||
scan_id,
|
||||
exc,
|
||||
scan_id,
|
||||
)
|
||||
if root_id is not None:
|
||||
await coordinator.cancel_descendants(root_id)
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "stopped")
|
||||
return None
|
||||
except BaseException:
|
||||
logger.exception("Strix scan %s failed", scan_id)
|
||||
if root_id is not None:
|
||||
await coordinator.cancel_descendants(root_id)
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "failed")
|
||||
raise
|
||||
finally:
|
||||
configure_spill_writer(None)
|
||||
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()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""SDK session helpers for Strix agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from agents.memory import SQLiteSession
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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]"
|
||||
_IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]"
|
||||
_INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]"
|
||||
|
||||
|
||||
def _output_has_image(item_dict: dict[str, Any]) -> bool:
|
||||
return (
|
||||
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"])
|
||||
)
|
||||
|
||||
|
||||
def _elided_output(item_dict: dict[str, Any], text: str) -> dict[str, Any]:
|
||||
# Replace only image blocks; sibling text blocks are preserved.
|
||||
output = item_dict.get("output")
|
||||
blocks = output if isinstance(output, list) else []
|
||||
return {
|
||||
"type": "function_call_output",
|
||||
"call_id": item_dict.get("call_id"),
|
||||
"output": [
|
||||
{"type": "input_text", "text": text}
|
||||
if isinstance(block, dict) and block.get("type") == "input_image"
|
||||
else block
|
||||
for block in blocks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
_session_write_locks: WeakKeyDictionary[Session, asyncio.Lock] = WeakKeyDictionary()
|
||||
|
||||
|
||||
def session_write_lock(session: Session) -> asyncio.Lock:
|
||||
"""Lock serialising all out-of-band writes to ``session``."""
|
||||
lock = _session_write_locks.get(session)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_session_write_locks[session] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def _rewrite_session(
|
||||
session: Session,
|
||||
transform: Callable[[list[Any]], tuple[list[Any], bool]],
|
||||
) -> bool:
|
||||
"""Read-modify-write a session under its write lock, restoring on failure."""
|
||||
async with session_write_lock(session):
|
||||
items = await session.get_items()
|
||||
if not items:
|
||||
return False
|
||||
rebuilt, changed = transform(list(items))
|
||||
if not changed:
|
||||
return False
|
||||
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
|
||||
original_items = cast("list[TResponseInputItem]", list(items))
|
||||
await session.clear_session()
|
||||
try:
|
||||
await session.add_items(rebuilt_items)
|
||||
except Exception:
|
||||
logger.exception("session rewrite failed; restoring original items")
|
||||
await session.clear_session()
|
||||
await session.add_items(original_items)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def replace_session_items(
|
||||
session: Session,
|
||||
new_items: list[Any],
|
||||
*,
|
||||
expected_len: int | None = None,
|
||||
) -> bool:
|
||||
"""Overwrite the session's items, restoring the originals on failure.
|
||||
|
||||
When ``expected_len`` is given, the rewrite is skipped if the session no
|
||||
longer has that many items (a concurrent writer changed it), so a slow
|
||||
compaction summary can't clobber newer turns.
|
||||
"""
|
||||
async with session_write_lock(session):
|
||||
original = list(await session.get_items())
|
||||
if expected_len is not None and len(original) != expected_len:
|
||||
logger.warning(
|
||||
"skipping session rewrite: expected %d items, found %d",
|
||||
expected_len,
|
||||
len(original),
|
||||
)
|
||||
return False
|
||||
rebuilt = cast("list[TResponseInputItem]", new_items)
|
||||
await session.clear_session()
|
||||
try:
|
||||
await session.add_items(rebuilt)
|
||||
except Exception:
|
||||
logger.exception("session rewrite failed; restoring original items")
|
||||
await session.clear_session()
|
||||
await session.add_items(original)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def strip_all_images_from_session(session: Session) -> bool:
|
||||
"""Replace every image tool output with a text placeholder (rejection recovery)."""
|
||||
|
||||
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
|
||||
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 _output_has_image(item_dict):
|
||||
rebuilt.append(_elided_output(item_dict, _IMAGE_REJECTED_TEXT))
|
||||
changed = True
|
||||
else:
|
||||
rebuilt.append(item)
|
||||
return rebuilt, changed
|
||||
|
||||
return await _rewrite_session(session, _transform)
|
||||
|
||||
|
||||
async def enforce_image_budget(session: Session, max_images: int) -> bool:
|
||||
"""Keep only the most recent ``max_images`` image outputs; elide older ones."""
|
||||
if max_images < 0:
|
||||
return False
|
||||
|
||||
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
|
||||
image_indices = [
|
||||
i
|
||||
for i, item in enumerate(items)
|
||||
if isinstance(item, dict) and _output_has_image(cast("dict[str, Any]", item))
|
||||
]
|
||||
if len(image_indices) <= max_images:
|
||||
return items, False
|
||||
to_elide = set(image_indices[: len(image_indices) - max_images])
|
||||
rebuilt = [
|
||||
_elided_output(cast("dict[str, Any]", item), _IMAGE_ELIDED_TEXT)
|
||||
if i in to_elide
|
||||
else item
|
||||
for i, item in enumerate(items)
|
||||
]
|
||||
return rebuilt, True
|
||||
|
||||
return await _rewrite_session(session, _transform)
|
||||
|
||||
|
||||
def scrub_images_from_items(items: list[Any]) -> list[Any]:
|
||||
"""Return a copy of ``items`` with every image block replaced by text."""
|
||||
|
||||
def _scrub(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
if obj.get("type") == "input_image":
|
||||
return {"type": "input_text", "text": _INHERITED_IMAGE_TEXT}
|
||||
return {k: _scrub(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_scrub(v) for v in obj]
|
||||
return obj
|
||||
|
||||
return [_scrub(item) for item in items]
|
||||
@@ -67,6 +67,16 @@ Toast.-information .toast--title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#viewer_cta {
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: round #333333;
|
||||
color: #60a5fa;
|
||||
padding: 0 1;
|
||||
margin-bottom: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#agents_tree {
|
||||
height: 1fr;
|
||||
background: transparent;
|
||||
@@ -386,7 +396,6 @@ VulnerabilityDetailScreen {
|
||||
|
||||
.browser-tool,
|
||||
.terminal-tool,
|
||||
.python-tool,
|
||||
.agents-graph-tool,
|
||||
.file-edit-tool,
|
||||
.proxy-tool,
|
||||
@@ -411,8 +420,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""`strix auth` — ChatGPT subscription sign-in (login / status / logout).
|
||||
|
||||
Signing in only stores OAuth tokens (``~/.strix/subscription-auth.json``); model
|
||||
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on the
|
||||
subscription.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import logging
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CALLBACK_TIMEOUT_S = 300
|
||||
|
||||
# CLI-facing name for the login provider. Internally this is the Codex OAuth
|
||||
# flow (``codex.PROVIDER``), but users know it as ChatGPT, so that's what the
|
||||
# command and messaging say. ``codex`` is accepted as an alias.
|
||||
LOGIN_PROVIDER = "chatgpt"
|
||||
_ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER})
|
||||
|
||||
_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout"
|
||||
|
||||
|
||||
def run_auth(argv: list[str]) -> int:
|
||||
"""Entry point for ``strix auth …``. Returns a process exit code."""
|
||||
console = Console()
|
||||
# Bare `strix auth` (no subcommand) defaults to login.
|
||||
subcommand = argv[0] if argv else "login"
|
||||
rest = argv[1:]
|
||||
|
||||
if subcommand in ("-h", "--help", "help"):
|
||||
console.print(_USAGE)
|
||||
return 0
|
||||
|
||||
handlers: dict[str, Callable[[], int]] = {
|
||||
"login": lambda: _login(console, rest),
|
||||
"status": lambda: _status(console),
|
||||
"logout": lambda: _logout(console),
|
||||
}
|
||||
handler = handlers.get(subcommand)
|
||||
if handler is not None:
|
||||
return handler()
|
||||
|
||||
console.print(f"[red]Unknown auth command:[/] {subcommand}\n")
|
||||
console.print(_USAGE)
|
||||
return 2
|
||||
|
||||
|
||||
def _login(console: Console, argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog="strix auth login", add_help=True)
|
||||
parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=LOGIN_PROVIDER,
|
||||
help="Model provider to sign in with (default: chatgpt).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manual",
|
||||
action="store_true",
|
||||
help="Skip the local callback server and paste the redirect URL by hand.",
|
||||
)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as exc: # argparse already printed the message
|
||||
return int(exc.code or 2)
|
||||
|
||||
if args.provider.lower() not in _ACCEPTED_PROVIDERS:
|
||||
console.print(
|
||||
f"[red]Unsupported provider:[/] {args.provider}. "
|
||||
f"Only '{LOGIN_PROVIDER}' (ChatGPT subscription) is supported."
|
||||
)
|
||||
return 2
|
||||
|
||||
verifier, challenge = codex.generate_pkce()
|
||||
state = codex.create_state()
|
||||
authorize_url = codex.build_authorize_url(challenge, state)
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Signing in with ChatGPT[/] [dim](provider: chatgpt)[/]")
|
||||
console.print(
|
||||
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
try:
|
||||
record = _run_oauth_flow(console, authorize_url, verifier, state, manual=args.manual)
|
||||
except codex.CodexAuthError as exc:
|
||||
return _fail(console, exc)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Sign-in cancelled.[/]")
|
||||
return 130
|
||||
|
||||
codex.save_record(record)
|
||||
_print_success(console)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_oauth_flow(
|
||||
console: Console,
|
||||
authorize_url: str,
|
||||
verifier: str,
|
||||
state: str,
|
||||
*,
|
||||
manual: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the browser (or manual) OAuth flow and return a token record."""
|
||||
server = None if manual else _try_start_callback_server()
|
||||
|
||||
console.print("Open this URL in your browser to authorize:")
|
||||
console.print(f"[cyan]{authorize_url}[/]")
|
||||
console.print()
|
||||
if not manual:
|
||||
try:
|
||||
webbrowser.open(authorize_url)
|
||||
except Exception: # noqa: BLE001 - opening a browser is best-effort
|
||||
logger.debug("could not open browser", exc_info=True)
|
||||
|
||||
if server is not None:
|
||||
console.print("[dim]Waiting for you to finish signing in…[/]")
|
||||
result = server.wait(_CALLBACK_TIMEOUT_S)
|
||||
server.shutdown()
|
||||
if result is not None:
|
||||
code, returned_state, error = result
|
||||
if error:
|
||||
raise codex.CodexAuthError("oauth_error", error)
|
||||
return _finish(code, returned_state, verifier, state, require_state=True)
|
||||
console.print("[yellow]Timed out waiting for the browser. Falling back to manual paste.[/]")
|
||||
|
||||
# Manual fallback: the user completes sign-in and pastes the redirect URL
|
||||
# (the browser lands on a localhost page that won't load if no server is up;
|
||||
# the address bar still holds the code+state).
|
||||
console.print()
|
||||
try:
|
||||
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
|
||||
except EOFError as exc:
|
||||
raise codex.CodexAuthError("no_input", "no redirect URL provided") from exc
|
||||
code, returned_state = codex.parse_redirect_input(pasted)
|
||||
return _finish(code, returned_state, verifier, state, require_state=False)
|
||||
|
||||
|
||||
def _finish(
|
||||
code: str | None,
|
||||
returned_state: str | None,
|
||||
verifier: str,
|
||||
expected_state: str,
|
||||
*,
|
||||
require_state: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not code:
|
||||
raise codex.CodexAuthError("no_code", "no authorization code found in the redirect")
|
||||
# The loopback callback from OpenAI always carries state, so a missing or
|
||||
# mismatched value there is forged (CSRF) and must be rejected. Manual paste
|
||||
# is user-initiated (the user copies their own redirect), so state is only
|
||||
# validated when the pasted value includes it.
|
||||
if require_state and returned_state is None:
|
||||
raise codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
|
||||
if returned_state is not None and returned_state != expected_state:
|
||||
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
|
||||
return codex.exchange_code(code, verifier)
|
||||
|
||||
|
||||
class _CallbackServer:
|
||||
"""A one-shot local HTTP server that catches the OAuth redirect."""
|
||||
|
||||
def __init__(self, httpd: HTTPServer, event: threading.Event, holder: dict[str, Any]) -> None:
|
||||
self._httpd = httpd
|
||||
self._event = event
|
||||
self._holder = holder
|
||||
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def wait(self, timeout: float) -> tuple[str | None, str | None, str | None] | None:
|
||||
if not self._event.wait(timeout):
|
||||
return None
|
||||
return (
|
||||
self._holder.get("code"),
|
||||
self._holder.get("state"),
|
||||
self._holder.get("error"),
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._httpd.shutdown()
|
||||
self._httpd.server_close()
|
||||
|
||||
|
||||
def _try_start_callback_server() -> _CallbackServer | None:
|
||||
event = threading.Event()
|
||||
holder: dict[str, Any] = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args: Any) -> None: # silence default stderr logging
|
||||
pass
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != codex.CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
query = parse_qs(parsed.query)
|
||||
holder["code"] = _first(query, "code")
|
||||
holder["state"] = _first(query, "state")
|
||||
holder["error"] = _first(query, "error_description") or _first(query, "error")
|
||||
body = _render_callback_html().encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
event.set()
|
||||
|
||||
try:
|
||||
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
|
||||
except OSError:
|
||||
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
|
||||
return None
|
||||
return _CallbackServer(httpd, event, holder)
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _status(console: Console) -> int:
|
||||
record = codex.read_record()
|
||||
if record is None:
|
||||
console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] to sign in.")
|
||||
return 1
|
||||
settings = load_settings()
|
||||
console.print("[green]Signed in[/] with a ChatGPT subscription.")
|
||||
console.print(f" Account: [bold]{record.get('account_id')}[/]")
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{settings.llm.model}[/]).")
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. [cyan]chatgpt/gpt-5.4[/] "
|
||||
"to run on the subscription."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _logout(console: Console) -> int:
|
||||
codex.logout()
|
||||
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
|
||||
return 0
|
||||
|
||||
|
||||
def _fail(console: Console, exc: codex.CodexAuthError) -> int:
|
||||
error_text = Text()
|
||||
error_text.append("SIGN-IN FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(f"{exc}", style="white")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _print_success(console: Console) -> None:
|
||||
text = Text()
|
||||
text.append("Signed in with your ChatGPT subscription", style="bold #22c55e")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Set ", style="white")
|
||||
text.append("STRIX_LLM", style="bold white")
|
||||
text.append(" to a ", style="white")
|
||||
text.append("chatgpt/", style="bold cyan")
|
||||
text.append(" model (e.g. ", style="white")
|
||||
text.append("chatgpt/gpt-5.4", style="bold cyan")
|
||||
text.append(") — runs are billed to your ChatGPT plan.", style="white")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Run a scan as usual, e.g. ", style="white")
|
||||
text.append("strix --target https://example.com", style="bold cyan")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="#22c55e",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
_LOGO_PATH = Path(__file__).resolve().parent.parent / "viewer" / "static" / "logo.png"
|
||||
|
||||
|
||||
def _logo_img_tag() -> str:
|
||||
"""Return an ``<img>`` for the Strix logo as an inline data URI, or "".
|
||||
|
||||
The callback page is served offline by the local OAuth server, so the logo
|
||||
is embedded rather than linked. Missing/unreadable file degrades to just the
|
||||
"Strix" wordmark.
|
||||
"""
|
||||
try:
|
||||
data = _LOGO_PATH.read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f'<img class="logo" src="data:image/png;base64,{encoded}" alt="" />'
|
||||
|
||||
|
||||
def _render_callback_html() -> str:
|
||||
return _CALLBACK_HTML.replace("<!--LOGO-->", _logo_img_tag())
|
||||
|
||||
|
||||
_CALLBACK_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Strix — signed in</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; padding: 24px;
|
||||
font-family: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||
background: #000; color: #ededed;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.topbar {
|
||||
position: absolute; top: 20px; left: 22px;
|
||||
display: flex; align-items: center; gap: 6px; text-decoration: none;
|
||||
}
|
||||
.topbar .logo { width: 40px; height: 40px; display: block; }
|
||||
.topbar span {
|
||||
font-size: 1.1rem; font-weight: 600; letter-spacing: -.01em; color: #fff;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
.topbar:hover span { color: #c9c9c9; }
|
||||
.brand {
|
||||
font-size: 2.1rem; font-weight: 700; letter-spacing: -.02em; color: #fff;
|
||||
text-align: center; margin: 0 0 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.35rem; font-weight: 600; letter-spacing: -.01em; color: #f5f5f5;
|
||||
text-align: center; margin: 0 0 28px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 430px; text-align: center;
|
||||
background: #171717; border: 1px solid rgba(255, 255, 255, .06);
|
||||
border-radius: 24px; padding: 40px 40px 34px;
|
||||
}
|
||||
.badge {
|
||||
margin: 0 auto 22px; width: 52px; height: 52px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 23px; color: #fff;
|
||||
background: rgba(255, 255, 255, .05); border: 1px solid rgba(255, 255, 255, .14);
|
||||
}
|
||||
.msg { margin: 0 auto; max-width: 34ch; color: #b5b5b5; line-height: 1.6; font-size: .98rem; }
|
||||
.rule { height: 1px; background: rgba(255, 255, 255, .07); margin: 26px 0 0; }
|
||||
.tagline { margin: 22px 0 0; color: #7c7c7c; font-size: .9rem; line-height: 1.55; }
|
||||
.tagline b { color: #ededed; font-weight: 500; }
|
||||
.links {
|
||||
margin-top: 18px; display: flex; gap: 8px; justify-content: center;
|
||||
align-items: center; flex-wrap: wrap; font-size: .84rem;
|
||||
}
|
||||
.links a { color: #a3a3a3; text-decoration: none; transition: color .15s ease; }
|
||||
.links a:hover { color: #fff; }
|
||||
.links .dot { color: #3a3a3a; }
|
||||
.close { margin: 24px 0 0; color: #5a5a5a; font-size: .78rem; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<a class="topbar" href="https://strix.ai" target="_blank" rel="noopener"
|
||||
aria-label="Strix — strix.ai">
|
||||
<!--LOGO-->
|
||||
<span>Strix</span>
|
||||
</a>
|
||||
<div class="brand">Strix</div>
|
||||
<h1>You're signed in</h1>
|
||||
<main class="card">
|
||||
<div class="badge">✓</div>
|
||||
<p class="msg">Strix is connected to your ChatGPT subscription. Head back to your
|
||||
terminal — your security test runs there.</p>
|
||||
<div class="rule"></div>
|
||||
<p class="tagline">Autonomous AI hackers that <b>find and fix</b> your app's
|
||||
vulnerabilities.</p>
|
||||
<nav class="links">
|
||||
<a href="https://strix.ai" target="_blank" rel="noopener">strix.ai</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://docs.strix.ai" target="_blank" rel="noopener">docs</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://discord.gg/strix-ai" target="_blank" rel="noopener">community</a>
|
||||
</nav>
|
||||
</main>
|
||||
<p class="close">You can close this tab.</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
__all__ = ["run_auth"]
|
||||
+56
-38
@@ -1,4 +1,6 @@
|
||||
import atexit
|
||||
import contextlib
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
@@ -10,9 +12,11 @@ from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.agents.StrixAgent import StrixAgent
|
||||
from strix.llm.config import LLMConfig
|
||||
from strix.telemetry.tracer import Tracer, set_global_tracer
|
||||
from strix.config import load_settings
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
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 .utils import (
|
||||
build_live_stats_text,
|
||||
@@ -20,6 +24,18 @@ 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()
|
||||
|
||||
@@ -67,24 +83,24 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
|
||||
scan_mode = getattr(args, "scan_mode", "deep")
|
||||
|
||||
scan_config = {
|
||||
scan_config: dict[str, Any] = {
|
||||
"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 "",
|
||||
}
|
||||
|
||||
llm_config = LLMConfig(scan_mode=scan_mode)
|
||||
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)
|
||||
report_state = ReportState(args.run_name)
|
||||
report_state.hydrate_from_run_dir()
|
||||
report_state.set_scan_config(scan_config)
|
||||
report_state.save_run_data()
|
||||
|
||||
def display_vulnerability(report: dict[str, Any]) -> None:
|
||||
report_id = report.get("id", "unknown")
|
||||
@@ -102,16 +118,13 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
console.print(vuln_panel)
|
||||
console.print()
|
||||
|
||||
tracer.vulnerability_found_callback = display_vulnerability
|
||||
report_state.vulnerability_found_callback = display_vulnerability
|
||||
|
||||
def cleanup_on_exit() -> None:
|
||||
from strix.runtime import cleanup_runtime
|
||||
|
||||
tracer.cleanup()
|
||||
cleanup_runtime()
|
||||
report_state.cleanup()
|
||||
|
||||
def signal_handler(_signum: int, _frame: Any) -> None:
|
||||
tracer.cleanup()
|
||||
report_state.cleanup(status="interrupted")
|
||||
sys.exit(1)
|
||||
|
||||
atexit.register(cleanup_on_exit)
|
||||
@@ -120,14 +133,14 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
signal.signal(signal.SIGHUP, signal_handler)
|
||||
|
||||
set_global_tracer(tracer)
|
||||
set_global_report_state(report_state)
|
||||
|
||||
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(tracer, agent_config)
|
||||
stats_text = build_live_stats_text(report_state)
|
||||
if stats_text:
|
||||
status_text.append(stats_text)
|
||||
|
||||
@@ -152,34 +165,39 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
try:
|
||||
live.update(create_live_status())
|
||||
time.sleep(2)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
break
|
||||
|
||||
update_thread = threading.Thread(target=update_status, daemon=True)
|
||||
update_thread.start()
|
||||
|
||||
try:
|
||||
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)
|
||||
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),
|
||||
max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS),
|
||||
)
|
||||
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 tracer.final_scan_result:
|
||||
if report_state.final_scan_result:
|
||||
console.print()
|
||||
|
||||
final_report_text = Text()
|
||||
@@ -189,7 +207,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
Text.assemble(
|
||||
final_report_text,
|
||||
"\n\n",
|
||||
tracer.final_scan_result,
|
||||
report_state.final_scan_result,
|
||||
),
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
|
||||
+655
-131
File diff suppressed because it is too large
Load Diff
@@ -1,125 +0,0 @@
|
||||
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
|
||||
@@ -1,45 +0,0 @@
|
||||
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,94 +0,0 @@
|
||||
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
|
||||
@@ -1,136 +0,0 @@
|
||||
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
|
||||
@@ -1,177 +0,0 @@
|
||||
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)
|
||||
@@ -1,155 +0,0 @@
|
||||
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)
|
||||
@@ -1,255 +0,0 @@
|
||||
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 strix.tools.reporting.reporting_actions import (
|
||||
parse_code_locations_xml,
|
||||
parse_cvss_xml,
|
||||
)
|
||||
|
||||
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"]}
|
||||
|
||||
|
||||
FIELD_STYLE = "bold #4ade80"
|
||||
DIM_STYLE = "dim"
|
||||
FILE_STYLE = "bold #60a5fa"
|
||||
LINE_STYLE = "#facc15"
|
||||
LABEL_STYLE = "italic #a1a1aa"
|
||||
CODE_STYLE = "#e2e8f0"
|
||||
BEFORE_STYLE = "#ef4444"
|
||||
AFTER_STYLE = "#22c55e"
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_vulnerability_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
}
|
||||
|
||||
@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_python(cls, code: str) -> Text:
|
||||
lexer = PythonLexer()
|
||||
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 _get_cvss_color(cls, cvss_score: float) -> str:
|
||||
if cvss_score >= 9.0:
|
||||
return "#dc2626"
|
||||
if cvss_score >= 7.0:
|
||||
return "#ea580c"
|
||||
if cvss_score >= 4.0:
|
||||
return "#d97706"
|
||||
if cvss_score >= 0.1:
|
||||
return "#65a30d"
|
||||
return "#6b7280"
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result", {})
|
||||
|
||||
title = args.get("title", "")
|
||||
description = args.get("description", "")
|
||||
impact = args.get("impact", "")
|
||||
target = args.get("target", "")
|
||||
technical_analysis = args.get("technical_analysis", "")
|
||||
poc_description = args.get("poc_description", "")
|
||||
poc_script_code = args.get("poc_script_code", "")
|
||||
remediation_steps = args.get("remediation_steps", "")
|
||||
|
||||
cvss_breakdown_xml = args.get("cvss_breakdown", "")
|
||||
code_locations_xml = args.get("code_locations", "")
|
||||
|
||||
endpoint = args.get("endpoint", "")
|
||||
method = args.get("method", "")
|
||||
cve = args.get("cve", "")
|
||||
cwe = args.get("cwe", "")
|
||||
|
||||
severity = ""
|
||||
cvss_score = None
|
||||
if isinstance(result, dict):
|
||||
severity = result.get("severity", "")
|
||||
cvss_score = result.get("cvss_score")
|
||||
|
||||
text = Text()
|
||||
text.append("🐞 ")
|
||||
text.append("Vulnerability Report", style="bold #ea580c")
|
||||
|
||||
if title:
|
||||
text.append("\n\n")
|
||||
text.append("Title: ", style=FIELD_STYLE)
|
||||
text.append(title)
|
||||
|
||||
if severity:
|
||||
text.append("\n\n")
|
||||
text.append("Severity: ", style=FIELD_STYLE)
|
||||
severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280")
|
||||
text.append(severity.upper(), style=f"bold {severity_color}")
|
||||
|
||||
if cvss_score is not None:
|
||||
text.append("\n\n")
|
||||
text.append("CVSS Score: ", style=FIELD_STYLE)
|
||||
cvss_color = cls._get_cvss_color(cvss_score)
|
||||
text.append(str(cvss_score), style=f"bold {cvss_color}")
|
||||
|
||||
if target:
|
||||
text.append("\n\n")
|
||||
text.append("Target: ", style=FIELD_STYLE)
|
||||
text.append(target)
|
||||
|
||||
if endpoint:
|
||||
text.append("\n\n")
|
||||
text.append("Endpoint: ", style=FIELD_STYLE)
|
||||
text.append(endpoint)
|
||||
|
||||
if method:
|
||||
text.append("\n\n")
|
||||
text.append("Method: ", style=FIELD_STYLE)
|
||||
text.append(method)
|
||||
|
||||
if cve:
|
||||
text.append("\n\n")
|
||||
text.append("CVE: ", style=FIELD_STYLE)
|
||||
text.append(cve)
|
||||
|
||||
if cwe:
|
||||
text.append("\n\n")
|
||||
text.append("CWE: ", style=FIELD_STYLE)
|
||||
text.append(cwe)
|
||||
|
||||
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 [
|
||||
("attack_vector", "AV"),
|
||||
("attack_complexity", "AC"),
|
||||
("privileges_required", "PR"),
|
||||
("user_interaction", "UI"),
|
||||
("scope", "S"),
|
||||
("confidentiality", "C"),
|
||||
("integrity", "I"),
|
||||
("availability", "A"),
|
||||
]:
|
||||
val = parsed_cvss.get(key)
|
||||
if val:
|
||||
cvss_parts.append(f"{prefix}:{val}")
|
||||
text.append("CVSS Vector: ", style=FIELD_STYLE)
|
||||
text.append("/".join(cvss_parts), style=DIM_STYLE)
|
||||
|
||||
if description:
|
||||
text.append("\n\n")
|
||||
text.append("Description", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(description)
|
||||
|
||||
if impact:
|
||||
text.append("\n\n")
|
||||
text.append("Impact", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(impact)
|
||||
|
||||
if technical_analysis:
|
||||
text.append("\n\n")
|
||||
text.append("Technical Analysis", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(technical_analysis)
|
||||
|
||||
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(parsed_locations):
|
||||
text.append("\n\n")
|
||||
text.append(f" Location {i + 1}: ", style=DIM_STYLE)
|
||||
text.append(loc.get("file", "unknown"), style=FILE_STYLE)
|
||||
start = loc.get("start_line")
|
||||
end = loc.get("end_line")
|
||||
if start is not None:
|
||||
if end and end != start:
|
||||
text.append(f":{start}-{end}", style=LINE_STYLE)
|
||||
else:
|
||||
text.append(f":{start}", style=LINE_STYLE)
|
||||
if loc.get("label"):
|
||||
text.append(f"\n {loc['label']}", style=LABEL_STYLE)
|
||||
if loc.get("snippet"):
|
||||
text.append("\n ")
|
||||
text.append(loc["snippet"], style=CODE_STYLE)
|
||||
if loc.get("fix_before") or loc.get("fix_after"):
|
||||
text.append("\n ")
|
||||
text.append("Fix:", style=DIM_STYLE)
|
||||
if loc.get("fix_before"):
|
||||
text.append("\n ")
|
||||
text.append("- ", style=BEFORE_STYLE)
|
||||
text.append(loc["fix_before"], style=BEFORE_STYLE)
|
||||
if loc.get("fix_after"):
|
||||
text.append("\n ")
|
||||
text.append("+ ", style=AFTER_STYLE)
|
||||
text.append(loc["fix_after"], style=AFTER_STYLE)
|
||||
|
||||
if poc_description:
|
||||
text.append("\n\n")
|
||||
text.append("PoC Description", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(poc_description)
|
||||
|
||||
if poc_script_code:
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(cls._highlight_python(poc_script_code))
|
||||
|
||||
if remediation_steps:
|
||||
text.append("\n\n")
|
||||
text.append("Remediation", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(remediation_steps)
|
||||
|
||||
if not title:
|
||||
text.append("\n ")
|
||||
text.append("Creating report...", style="dim")
|
||||
|
||||
padded = Text()
|
||||
padded.append("\n\n")
|
||||
padded.append_text(text)
|
||||
padded.append("\n\n")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(padded, classes=css_classes)
|
||||
@@ -1,68 +0,0 @@
|
||||
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)
|
||||
@@ -1,311 +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"\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)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Textual TUI interface."""
|
||||
|
||||
from strix.interface.tui.app import StrixTUIApp, run_tui
|
||||
|
||||
|
||||
__all__ = ["StrixTUIApp", "run_tui"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
"""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)
|
||||
# Open read-only: the scan process may be actively writing this WAL database
|
||||
# from another process (the local viewer tails it live), and a reader must
|
||||
# never lock or mutate it. mode=ro (not immutable=1) still reads the latest
|
||||
# committed WAL state; WAL permits concurrent readers alongside the writer.
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
conn = sqlite3.connect(
|
||||
f"file:{agents_db}?mode=ro",
|
||||
uri=True,
|
||||
check_same_thread=False,
|
||||
)
|
||||
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 []
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,369 @@
|
||||
"""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_agent_and_call_id: dict[tuple[str, 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_agent_error(self, agent_id: str, error: str) -> None:
|
||||
self._append_event(
|
||||
agent_id,
|
||||
"chat",
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (f"An error occurred: {error}\nI'm now waiting for new instructions."),
|
||||
"metadata": {"source": "agent_error"},
|
||||
},
|
||||
)
|
||||
|
||||
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"]
|
||||
event_key = (agent_id, call_id)
|
||||
existing = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||
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_agent_and_call_id[event_key] = 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_key = (agent_id, call_id)
|
||||
event = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||
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_agent_and_call_id[event_key] = 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"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,30 @@
|
||||
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",
|
||||
]
|
||||
+14
-24
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
@@ -5,10 +6,9 @@ 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
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
_BLANK_LINE_RUNS = re.compile(r"\n\s*\n")
|
||||
|
||||
|
||||
_HEADER_STYLES = [
|
||||
@@ -160,31 +160,21 @@ def _process_inline_formatting(line: str) -> Text:
|
||||
return result
|
||||
|
||||
|
||||
@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))
|
||||
class AgentMessageRenderer:
|
||||
_cache: ClassVar[dict[str, Text]] = {}
|
||||
|
||||
@classmethod
|
||||
def render_simple(cls, content: str) -> Text:
|
||||
if not content:
|
||||
return Text()
|
||||
|
||||
from strix.llm.utils import clean_content
|
||||
|
||||
cleaned = clean_content(content)
|
||||
cleaned = _BLANK_LINE_RUNS.sub("\n\n", content).strip()
|
||||
if not cleaned:
|
||||
return Text()
|
||||
|
||||
return _apply_markdown_styles(cleaned)
|
||||
cached = cls._cache.get(cleaned)
|
||||
if cached is not None:
|
||||
return cached.copy()
|
||||
rendered = _apply_markdown_styles(cleaned)
|
||||
if len(cls._cache) > 100:
|
||||
cls._cache.clear()
|
||||
cls._cache[cleaned] = rendered
|
||||
return rendered.copy()
|
||||
+38
-3
@@ -61,12 +61,12 @@ class SendMessageToAgentRenderer(BaseToolRenderer):
|
||||
status = tool_data.get("status", "unknown")
|
||||
|
||||
message = args.get("message", "")
|
||||
agent_id = args.get("agent_id", "")
|
||||
target_agent_id = args.get("target_agent_id", "")
|
||||
|
||||
text = Text()
|
||||
text.append("→ ", style="#60a5fa")
|
||||
if agent_id:
|
||||
text.append(f"to {agent_id}", style="dim")
|
||||
if target_agent_id:
|
||||
text.append(f"to {target_agent_id}", style="dim")
|
||||
else:
|
||||
text.append("sending message", style="dim")
|
||||
|
||||
@@ -138,3 +138,38 @@ 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,30 @@
|
||||
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)
|
||||
@@ -0,0 +1,266 @@
|
||||
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))
|
||||
+5
-1
@@ -17,7 +17,11 @@ class LoadSkillRenderer(BaseToolRenderer):
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "completed")
|
||||
|
||||
requested = args.get("skills", "")
|
||||
raw_skills = args.get("skills", "")
|
||||
if isinstance(raw_skills, list):
|
||||
requested = ", ".join(str(s) for s in raw_skills)
|
||||
else:
|
||||
requested = str(raw_skills)
|
||||
|
||||
text = Text()
|
||||
text.append("◇ ", style="#10b981")
|
||||
+47
@@ -7,6 +7,13 @@ from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
|
||||
def _author_label(note: dict[str, Any]) -> str:
|
||||
if note.get("by_you"):
|
||||
return "you"
|
||||
agent_name = note.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateNoteRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_note"
|
||||
@@ -117,10 +124,15 @@ class ListNotesRenderer(BaseToolRenderer):
|
||||
title = note.get("title", "").strip() or "(untitled)"
|
||||
category = note.get("category", "general")
|
||||
note_content = note.get("content", "").strip()
|
||||
if not note_content:
|
||||
note_content = note.get("content_preview", "").strip()
|
||||
|
||||
text.append("\n - ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
|
||||
if note_content:
|
||||
text.append("\n ")
|
||||
@@ -131,3 +143,38 @@ class ListNotesRenderer(BaseToolRenderer):
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class GetNoteRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "get_note"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
|
||||
text = Text()
|
||||
text.append("◇ ", style="#fbbf24")
|
||||
text.append("note read", style="dim")
|
||||
|
||||
if result and isinstance(result, dict) and result.get("success"):
|
||||
note = result.get("note", {}) or {}
|
||||
title = str(note.get("title", "")).strip() or "(untitled)"
|
||||
category = note.get("category", "general")
|
||||
content = str(note.get("content", "")).strip()
|
||||
text.append("\n ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
if content:
|
||||
text.append("\n ")
|
||||
text.append(content, style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
+194
-268
@@ -17,7 +17,6 @@ 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)
|
||||
|
||||
@@ -42,7 +41,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 # noqa: PLR0912
|
||||
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")
|
||||
@@ -73,21 +72,25 @@ class ListRequestsRenderer(BaseToolRenderer):
|
||||
if "error" in result:
|
||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||
else:
|
||||
total = result.get("total_count", 0)
|
||||
requests = result.get("requests", [])
|
||||
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")
|
||||
|
||||
text.append(f" [{total} found]", style="dim")
|
||||
|
||||
if requests and isinstance(requests, list):
|
||||
if entries and isinstance(entries, list):
|
||||
text.append("\n")
|
||||
for i, req in enumerate(requests[:MAX_REQUESTS_DISPLAY]):
|
||||
if not isinstance(req, dict):
|
||||
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
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
|
||||
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
|
||||
|
||||
text.append(" ")
|
||||
text.append(f"{method:6}", style="#a78bfa")
|
||||
@@ -95,13 +98,13 @@ class ListRequestsRenderer(BaseToolRenderer):
|
||||
if code:
|
||||
text.append(f" {code}", style=_status_style(code))
|
||||
|
||||
if i < min(len(requests), MAX_REQUESTS_DISPLAY) - 1:
|
||||
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
|
||||
text.append("\n")
|
||||
|
||||
if len(requests) > MAX_REQUESTS_DISPLAY:
|
||||
if len(entries) > MAX_REQUESTS_DISPLAY:
|
||||
text.append("\n")
|
||||
text.append(
|
||||
f" ... +{len(requests) - MAX_REQUESTS_DISPLAY} more",
|
||||
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more",
|
||||
style="dim italic",
|
||||
)
|
||||
|
||||
@@ -139,14 +142,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 "matches" in result:
|
||||
matches = result.get("matches", [])
|
||||
total = result.get("total_matches", len(matches))
|
||||
elif "hits" in result:
|
||||
hits = result.get("hits", [])
|
||||
total = result.get("total_hits", len(hits))
|
||||
text.append(f" [{total} matches]", style="dim")
|
||||
|
||||
if matches and isinstance(matches, list):
|
||||
if hits and isinstance(hits, list):
|
||||
text.append("\n")
|
||||
for i, m in enumerate(matches[:5]):
|
||||
for i, m in enumerate(hits[:5]):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
before = m.get("before", "") or ""
|
||||
@@ -164,19 +167,20 @@ class ViewRequestRenderer(BaseToolRenderer):
|
||||
if after:
|
||||
text.append(f"{after}...", style="dim")
|
||||
|
||||
if i < min(len(matches), 5) - 1:
|
||||
if i < min(len(hits), 5) - 1:
|
||||
text.append("\n")
|
||||
|
||||
if len(matches) > 5:
|
||||
if len(hits) > 5:
|
||||
text.append("\n")
|
||||
text.append(f" ... +{len(matches) - 5} more matches", style="dim italic")
|
||||
text.append(f" ... +{len(hits) - 5} more matches", style="dim italic")
|
||||
|
||||
elif "content" in result:
|
||||
showing = result.get("showing_lines", "")
|
||||
page = result.get("page", 1)
|
||||
total_lines = result.get("total_lines", 0)
|
||||
has_more = result.get("has_more", False)
|
||||
content = result.get("content", "")
|
||||
|
||||
text.append(f" [{showing}]", style="dim")
|
||||
text.append(f" [page {page}, {total_lines} lines]", style="dim")
|
||||
|
||||
if content and isinstance(content, str):
|
||||
lines = content.split("\n")[:15]
|
||||
@@ -187,7 +191,7 @@ class ViewRequestRenderer(BaseToolRenderer):
|
||||
if i < len(lines) - 1:
|
||||
text.append("\n")
|
||||
|
||||
if has_more or len(lines) > 15:
|
||||
if has_more or len(content.split("\n")) > 15:
|
||||
text.append("\n")
|
||||
text.append(" ... more content available", style="dim italic")
|
||||
|
||||
@@ -195,80 +199,6 @@ 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"
|
||||
@@ -332,30 +262,26 @@ class RepeatRequestRenderer(BaseToolRenderer):
|
||||
text.append(f"\n {_truncate(modifications, 200)}", style="dim italic")
|
||||
|
||||
if status == "completed" and isinstance(result, dict):
|
||||
if "error" in result:
|
||||
if not result.get("success", True) and result.get("error"):
|
||||
text.append(f"\n error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||
else:
|
||||
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")
|
||||
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
|
||||
)
|
||||
|
||||
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")
|
||||
else:
|
||||
text.append("(no response)", style="dim")
|
||||
if elapsed_ms:
|
||||
text.append(f" ({elapsed_ms}ms)", style="dim")
|
||||
|
||||
body = result.get("body", "")
|
||||
if body and isinstance(body, str):
|
||||
lines = body.split("\n")[:5]
|
||||
for line in lines:
|
||||
@@ -363,7 +289,7 @@ class RepeatRequestRenderer(BaseToolRenderer):
|
||||
text.append(" << ", style="#22c55e")
|
||||
text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim")
|
||||
|
||||
if len(body.split("\n")) > 5:
|
||||
if body_truncated or len(body.split("\n")) > 5:
|
||||
text.append("\n")
|
||||
text.append(" ...", style="dim italic")
|
||||
|
||||
@@ -371,6 +297,155 @@ 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"
|
||||
@@ -459,152 +534,3 @@ 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)
|
||||
-8
@@ -20,14 +20,6 @@ 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)
|
||||
@@ -0,0 +1,547 @@
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pygments.styles import get_style_by_name
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from strix.report.writer import parse_fenced_code, resolve_lexer
|
||||
|
||||
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")
|
||||
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||
|
||||
|
||||
FIELD_STYLE = "bold #4ade80"
|
||||
DIM_STYLE = "dim"
|
||||
FILE_STYLE = "bold #60a5fa"
|
||||
LINE_STYLE = "#facc15"
|
||||
LABEL_STYLE = "italic #a1a1aa"
|
||||
CODE_STYLE = "#e2e8f0"
|
||||
BEFORE_STYLE = "#ef4444"
|
||||
AFTER_STYLE = "#22c55e"
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_vulnerability_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
}
|
||||
|
||||
@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, language: str | None) -> Text:
|
||||
lexer = resolve_lexer(language, code)
|
||||
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 _get_cvss_color(cls, cvss_score: float) -> str:
|
||||
if cvss_score >= 9.0:
|
||||
return "#dc2626"
|
||||
if cvss_score >= 7.0:
|
||||
return "#ea580c"
|
||||
if cvss_score >= 4.0:
|
||||
return "#d97706"
|
||||
if cvss_score >= 0.1:
|
||||
return "#65a30d"
|
||||
return "#6b7280"
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result", {})
|
||||
|
||||
title = args.get("title", "")
|
||||
description = args.get("description", "")
|
||||
impact = args.get("impact", "")
|
||||
target = args.get("target", "")
|
||||
technical_analysis = args.get("technical_analysis", "")
|
||||
poc_description = args.get("poc_description", "")
|
||||
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"))
|
||||
|
||||
endpoint = args.get("endpoint", "")
|
||||
method = args.get("method", "")
|
||||
cve = args.get("cve", "")
|
||||
cwe = args.get("cwe", "")
|
||||
|
||||
severity = ""
|
||||
cvss_score = None
|
||||
if isinstance(result, dict):
|
||||
severity = result.get("severity", "")
|
||||
cvss_score = result.get("cvss_score")
|
||||
|
||||
text = Text()
|
||||
text.append("🐞 ")
|
||||
text.append("Vulnerability Report", style="bold #ea580c")
|
||||
|
||||
if title:
|
||||
text.append("\n\n")
|
||||
text.append("Title: ", style=FIELD_STYLE)
|
||||
text.append(title)
|
||||
|
||||
if severity:
|
||||
text.append("\n\n")
|
||||
text.append("Severity: ", style=FIELD_STYLE)
|
||||
severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280")
|
||||
text.append(severity.upper(), style=f"bold {severity_color}")
|
||||
|
||||
if cvss_score is not None:
|
||||
text.append("\n\n")
|
||||
text.append("CVSS Score: ", style=FIELD_STYLE)
|
||||
cvss_color = cls._get_cvss_color(cvss_score)
|
||||
text.append(str(cvss_score), style=f"bold {cvss_color}")
|
||||
|
||||
if target:
|
||||
text.append("\n\n")
|
||||
text.append("Target: ", style=FIELD_STYLE)
|
||||
text.append(target)
|
||||
|
||||
if endpoint:
|
||||
text.append("\n\n")
|
||||
text.append("Endpoint: ", style=FIELD_STYLE)
|
||||
text.append(endpoint)
|
||||
|
||||
if method:
|
||||
text.append("\n\n")
|
||||
text.append("Method: ", style=FIELD_STYLE)
|
||||
text.append(method)
|
||||
|
||||
if cve:
|
||||
text.append("\n\n")
|
||||
text.append("CVE: ", style=FIELD_STYLE)
|
||||
text.append(cve)
|
||||
|
||||
if cwe:
|
||||
text.append("\n\n")
|
||||
text.append("CWE: ", style=FIELD_STYLE)
|
||||
text.append(cwe)
|
||||
|
||||
if cvss_breakdown:
|
||||
text.append("\n\n")
|
||||
cvss_parts = []
|
||||
for key, prefix in [
|
||||
("attack_vector", "AV"),
|
||||
("attack_complexity", "AC"),
|
||||
("privileges_required", "PR"),
|
||||
("user_interaction", "UI"),
|
||||
("scope", "S"),
|
||||
("confidentiality", "C"),
|
||||
("integrity", "I"),
|
||||
("availability", "A"),
|
||||
]:
|
||||
val = cvss_breakdown.get(key)
|
||||
if val:
|
||||
cvss_parts.append(f"{prefix}:{val}")
|
||||
text.append("CVSS Vector: ", style=FIELD_STYLE)
|
||||
text.append("/".join(cvss_parts), style=DIM_STYLE)
|
||||
|
||||
if description:
|
||||
text.append("\n\n")
|
||||
text.append("Description", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(description)
|
||||
|
||||
if impact:
|
||||
text.append("\n\n")
|
||||
text.append("Impact", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(impact)
|
||||
|
||||
if technical_analysis:
|
||||
text.append("\n\n")
|
||||
text.append("Technical Analysis", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(technical_analysis)
|
||||
|
||||
if code_locations:
|
||||
text.append("\n\n")
|
||||
text.append("Code Locations", style=FIELD_STYLE)
|
||||
for i, loc in enumerate(code_locations):
|
||||
text.append("\n\n")
|
||||
text.append(f" Location {i + 1}: ", style=DIM_STYLE)
|
||||
text.append(loc.get("file", "unknown"), style=FILE_STYLE)
|
||||
start = loc.get("start_line")
|
||||
end = loc.get("end_line")
|
||||
if start is not None:
|
||||
if end and end != start:
|
||||
text.append(f":{start}-{end}", style=LINE_STYLE)
|
||||
else:
|
||||
text.append(f":{start}", style=LINE_STYLE)
|
||||
if loc.get("label"):
|
||||
text.append(f"\n {loc['label']}", style=LABEL_STYLE)
|
||||
if loc.get("snippet"):
|
||||
text.append("\n ")
|
||||
text.append(loc["snippet"], style=CODE_STYLE)
|
||||
if loc.get("fix_before") or loc.get("fix_after"):
|
||||
text.append("\n ")
|
||||
text.append("Fix:", style=DIM_STYLE)
|
||||
if loc.get("fix_before"):
|
||||
text.append("\n ")
|
||||
text.append("- ", style=BEFORE_STYLE)
|
||||
text.append(loc["fix_before"], style=BEFORE_STYLE)
|
||||
if loc.get("fix_after"):
|
||||
text.append("\n ")
|
||||
text.append("+ ", style=AFTER_STYLE)
|
||||
text.append(loc["fix_after"], style=AFTER_STYLE)
|
||||
|
||||
if poc_description:
|
||||
text.append("\n\n")
|
||||
text.append("PoC Description", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(poc_description)
|
||||
|
||||
if poc_script_code:
|
||||
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(cls._highlight_code(poc_code, poc_language))
|
||||
|
||||
if remediation_steps:
|
||||
text.append("\n\n")
|
||||
text.append("Remediation", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(remediation_steps)
|
||||
|
||||
if not title:
|
||||
text.append("\n ")
|
||||
text.append("Creating report...", style="dim")
|
||||
|
||||
padded = Text()
|
||||
padded.append("\n\n")
|
||||
padded.append_text(text)
|
||||
padded.append("\n\n")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(padded, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateDependencyReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_dependency_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_cvss_color(cls, cvss_score: float) -> str:
|
||||
if cvss_score >= 9.0:
|
||||
return "#dc2626"
|
||||
if cvss_score >= 7.0:
|
||||
return "#ea580c"
|
||||
if cvss_score >= 4.0:
|
||||
return "#d97706"
|
||||
if cvss_score >= 0.1:
|
||||
return "#65a30d"
|
||||
return "#6b7280"
|
||||
|
||||
@classmethod
|
||||
def _render_unsuccessful(cls, args: dict[str, Any], result: dict[str, Any]) -> Static:
|
||||
text = Text()
|
||||
text.append("📦 ")
|
||||
text.append("Dependency (SCA) Report", style="bold #ea580c")
|
||||
title = args.get("title", "")
|
||||
if title:
|
||||
text.append("\n\n")
|
||||
text.append("Title: ", style=FIELD_STYLE)
|
||||
text.append(title)
|
||||
|
||||
warning = result.get("warning")
|
||||
if result.get("success") is False:
|
||||
errors = result.get("errors")
|
||||
detail = (
|
||||
"; ".join(errors) if isinstance(errors, list) and errors else result.get("error")
|
||||
)
|
||||
label, style = "✗ Not created: ", "bold #dc2626"
|
||||
fallback = "Report was not created."
|
||||
else:
|
||||
detail = warning
|
||||
label, style = "⚠ Not persisted: ", "bold #d97706"
|
||||
fallback = "Report could not be persisted."
|
||||
text.append("\n\n")
|
||||
text.append(label, style=style)
|
||||
text.append(str(detail or fallback))
|
||||
|
||||
padded = Text()
|
||||
padded.append("\n\n")
|
||||
padded.append_text(text)
|
||||
padded.append("\n\n")
|
||||
return Static(padded, classes=cls.get_css_classes("failed"))
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result", {})
|
||||
|
||||
if isinstance(result, dict) and (result.get("success") is False or result.get("warning")):
|
||||
return cls._render_unsuccessful(args, result)
|
||||
|
||||
title = args.get("title", "")
|
||||
description = args.get("description", "")
|
||||
impact = args.get("impact", "")
|
||||
target = args.get("target", "")
|
||||
technical_analysis = args.get("technical_analysis", "")
|
||||
remediation_steps = args.get("remediation_steps", "")
|
||||
assumptions = args.get("assumptions", "")
|
||||
|
||||
package_name = args.get("package_name", "")
|
||||
package_ecosystem = args.get("package_ecosystem", "")
|
||||
installed_version = args.get("installed_version", "")
|
||||
fixed_version = args.get("fixed_version", "")
|
||||
cve = args.get("cve", "")
|
||||
cwe = args.get("cwe", "")
|
||||
advisory_cvss = args.get("advisory_cvss")
|
||||
fix_effort = args.get("fix_effort", "")
|
||||
|
||||
severity = ""
|
||||
if isinstance(result, dict):
|
||||
severity = result.get("severity", "")
|
||||
|
||||
text = Text()
|
||||
text.append("📦 ")
|
||||
text.append("Dependency (SCA) Report", style="bold #ea580c")
|
||||
|
||||
if title:
|
||||
text.append("\n\n")
|
||||
text.append("Title: ", style=FIELD_STYLE)
|
||||
text.append(title)
|
||||
|
||||
if severity:
|
||||
text.append("\n\n")
|
||||
text.append("Severity: ", style=FIELD_STYLE)
|
||||
severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280")
|
||||
text.append(severity.upper(), style=f"bold {severity_color}")
|
||||
|
||||
if advisory_cvss is not None:
|
||||
text.append("\n\n")
|
||||
text.append("Advisory CVSS: ", style=FIELD_STYLE)
|
||||
try:
|
||||
score = float(advisory_cvss)
|
||||
text.append(str(score), style=f"bold {cls._get_cvss_color(score)}")
|
||||
except (TypeError, ValueError):
|
||||
text.append(str(advisory_cvss), style=DIM_STYLE)
|
||||
|
||||
if cve:
|
||||
text.append("\n\n")
|
||||
text.append("CVE: ", style=FIELD_STYLE)
|
||||
text.append(cve)
|
||||
|
||||
if cwe:
|
||||
text.append("\n\n")
|
||||
text.append("CWE: ", style=FIELD_STYLE)
|
||||
text.append(cwe)
|
||||
|
||||
if package_name:
|
||||
text.append("\n\n")
|
||||
text.append("Package: ", style=FIELD_STYLE)
|
||||
text.append(package_name, style=FILE_STYLE)
|
||||
if package_ecosystem:
|
||||
text.append(f" ({package_ecosystem})", style=DIM_STYLE)
|
||||
|
||||
if installed_version:
|
||||
text.append("\n\n")
|
||||
text.append("Installed: ", style=FIELD_STYLE)
|
||||
text.append(installed_version, style=BEFORE_STYLE)
|
||||
if fixed_version:
|
||||
text.append(" → ", style=DIM_STYLE)
|
||||
text.append("Fixed: ", style=FIELD_STYLE)
|
||||
text.append(fixed_version, style=AFTER_STYLE)
|
||||
|
||||
if fix_effort:
|
||||
text.append("\n\n")
|
||||
text.append("Fix Effort: ", style=FIELD_STYLE)
|
||||
text.append(fix_effort)
|
||||
|
||||
if target:
|
||||
text.append("\n\n")
|
||||
text.append("Target: ", style=FIELD_STYLE)
|
||||
text.append(target)
|
||||
|
||||
for label, value in [
|
||||
("Description", description),
|
||||
("Impact", impact),
|
||||
("Technical Analysis", technical_analysis),
|
||||
("Assumptions", assumptions),
|
||||
("Remediation", remediation_steps),
|
||||
]:
|
||||
if value:
|
||||
text.append("\n\n")
|
||||
text.append(label, style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(value)
|
||||
|
||||
if not title:
|
||||
text.append("\n ")
|
||||
text.append("Creating dependency report...", style="dim")
|
||||
|
||||
padded = Text()
|
||||
padded.append("\n\n")
|
||||
padded.append_text(text)
|
||||
padded.append("\n\n")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(padded, classes=css_classes)
|
||||
|
||||
|
||||
_LIST_SEVERITY_COLORS = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
"none": "#6b7280",
|
||||
}
|
||||
|
||||
|
||||
def _severity_style(severity: Any) -> str:
|
||||
return _LIST_SEVERITY_COLORS.get(str(severity or "").lower(), "#d97706")
|
||||
|
||||
|
||||
def _author_label(report: dict[str, Any]) -> str:
|
||||
if report.get("by_you"):
|
||||
return "you"
|
||||
agent_name = report.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ListReportsRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "list_reports"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("reports", style="dim")
|
||||
|
||||
if isinstance(tool_data.get("result"), str) and str(tool_data["result"]).strip():
|
||||
text.append("\n ")
|
||||
text.append(str(tool_data["result"]).strip(), style="dim")
|
||||
elif result.get("success"):
|
||||
total = result.get("total_count", 0)
|
||||
reports = _coerce_list_of_dicts(result.get("reports"))
|
||||
counts = _coerce_dict(result.get("severity_counts"))
|
||||
|
||||
text.append(f" ({total})", style="dim")
|
||||
for sev, count in counts.items():
|
||||
text.append(" ")
|
||||
text.append(f"{sev} {count}", style=_severity_style(sev))
|
||||
|
||||
if not reports:
|
||||
text.append("\n ")
|
||||
text.append("No reports filed yet", style="dim")
|
||||
else:
|
||||
for report in reports:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n - ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class GetReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "get_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("report read", style="dim")
|
||||
|
||||
report = _coerce_dict(result.get("report")) if result.get("success") else {}
|
||||
if report:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
target = str(report.get("target", "")).strip()
|
||||
if target:
|
||||
text.append("\n ")
|
||||
text.append(target, style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
detail = result.get("error") if result.get("success") is False else None
|
||||
text.append(str(detail) if detail else "Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
@@ -0,0 +1,266 @@
|
||||
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: str = 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))
|
||||
+1
-22
@@ -1,28 +1,7 @@
|
||||
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 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))
|
||||
|
||||
class UserMessageRenderer:
|
||||
@classmethod
|
||||
def render_simple(cls, content: str) -> Text:
|
||||
if not content:
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Update notifications and self-update for the strix CLI.
|
||||
|
||||
Follows the pattern used by tools like gh, uv, and pip: a background,
|
||||
rate-limited (once per 24h) check against the release source, a cached
|
||||
result in ``~/.strix``, a non-intrusive notice with the upgrade command
|
||||
for the detected install method, and a ``strix --update`` self-update
|
||||
path for the standalone binary install.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from strix.telemetry._common import get_version
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_REPO = "usestrix/strix"
|
||||
PYPI_PACKAGE = "strix-agent"
|
||||
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
REQUEST_TIMEOUT_SECONDS = 5
|
||||
|
||||
_CACHE_PATH = Path.home() / ".strix" / "update-check.json"
|
||||
|
||||
_background_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _is_disabled() -> bool:
|
||||
return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any(
|
||||
os.environ.get(key)
|
||||
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI")
|
||||
)
|
||||
|
||||
|
||||
def is_binary_install() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def get_install_method() -> str:
|
||||
if is_binary_install():
|
||||
return "binary"
|
||||
prefix = str(Path(sys.prefix)).replace("\\", "/")
|
||||
if "/pipx/" in prefix or prefix.endswith("/pipx"):
|
||||
return "pipx"
|
||||
if "/uv/tools/" in prefix:
|
||||
return "uv"
|
||||
return "pip"
|
||||
|
||||
|
||||
def get_upgrade_command(method: str | None = None) -> str:
|
||||
method = method or get_install_method()
|
||||
commands = {
|
||||
"binary": "strix --update",
|
||||
"pipx": "pipx upgrade strix-agent",
|
||||
"uv": "uv tool upgrade strix-agent",
|
||||
"pip": "pip install --upgrade strix-agent",
|
||||
}
|
||||
return commands[method]
|
||||
|
||||
|
||||
def _parse_version(value: str) -> tuple[int, ...] | None:
|
||||
parts = value.strip().lstrip("v").split(".")
|
||||
try:
|
||||
return tuple(int(part) for part in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
latest_parts = _parse_version(latest)
|
||||
current_parts = _parse_version(current)
|
||||
if latest_parts is None or current_parts is None:
|
||||
return False
|
||||
return latest_parts > current_parts
|
||||
|
||||
|
||||
def _fetch_latest_version() -> str | None:
|
||||
try:
|
||||
if is_binary_install():
|
||||
response = requests.get(
|
||||
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
tag = response.json().get("tag_name", "")
|
||||
return tag.lstrip("v") or None
|
||||
response = requests.get(
|
||||
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
version = response.json().get("info", {}).get("version")
|
||||
return str(version) if version else None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("update check failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_asset_digest(version: str, filename: str) -> str | None:
|
||||
"""Return the expected sha256 (hex) for a release asset, if the API provides one."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
for asset in response.json().get("assets", []):
|
||||
if asset.get("name") == filename:
|
||||
digest = asset.get("digest") or ""
|
||||
if digest.startswith("sha256:"):
|
||||
return digest.removeprefix("sha256:")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("release asset digest lookup failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_cache() -> dict[str, object]:
|
||||
try:
|
||||
with _CACHE_PATH.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return cast("dict[str, object]", data)
|
||||
except Exception: # noqa: BLE001, S110
|
||||
pass # nosec B110
|
||||
return {}
|
||||
|
||||
|
||||
def _write_cache(**fields: object) -> None:
|
||||
try:
|
||||
cache = _read_cache()
|
||||
cache.update(fields)
|
||||
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
|
||||
except Exception: # noqa: BLE001, S110
|
||||
pass # nosec B110
|
||||
|
||||
|
||||
def skip_version(version: str) -> None:
|
||||
"""Remember not to prompt again for this version (newer releases still notify)."""
|
||||
_write_cache(skipped_version=version)
|
||||
|
||||
|
||||
def _refresh_cache() -> None:
|
||||
latest = _fetch_latest_version()
|
||||
if latest:
|
||||
_write_cache(latest_version=latest, checked_at=time.time())
|
||||
|
||||
|
||||
def start_background_check() -> None:
|
||||
"""Refresh the cached latest-version info in a daemon thread (at most once per 24h)."""
|
||||
global _background_thread # noqa: PLW0603
|
||||
if _is_disabled():
|
||||
return
|
||||
cache = _read_cache()
|
||||
checked_at = cache.get("checked_at")
|
||||
if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS:
|
||||
return
|
||||
_background_thread = threading.Thread(target=_refresh_cache, daemon=True)
|
||||
_background_thread.start()
|
||||
|
||||
|
||||
def get_available_update(*, respect_skip: bool = True) -> str | None:
|
||||
"""Return the newer version from the cache, or None if up to date / unknown."""
|
||||
if _is_disabled():
|
||||
return None
|
||||
if _background_thread is not None:
|
||||
_background_thread.join(timeout=0.2)
|
||||
cache = _read_cache()
|
||||
latest = cache.get("latest_version")
|
||||
current = get_version()
|
||||
if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current):
|
||||
return None
|
||||
if respect_skip and cache.get("skipped_version") == latest:
|
||||
return None
|
||||
return latest
|
||||
|
||||
|
||||
def notify_update(console: Console) -> None:
|
||||
latest = get_available_update()
|
||||
if not latest:
|
||||
return
|
||||
console.print(
|
||||
f"[#eab308]A new version of strix is available:[/] "
|
||||
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||
f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def run_package_upgrade(console: Console, method: str) -> bool:
|
||||
"""Upgrade a package-manager install by running its upgrade command."""
|
||||
command = get_upgrade_command(method).split()
|
||||
console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]")
|
||||
try:
|
||||
result = subprocess.run(command, check=False) # noqa: S603
|
||||
except OSError as e:
|
||||
console.print(f"[bold red]Update failed:[/] {e}")
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
console.print(
|
||||
f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] "
|
||||
f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||
)
|
||||
return False
|
||||
console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]")
|
||||
return True
|
||||
|
||||
|
||||
def prompt_update_if_available(console: Console) -> bool:
|
||||
"""Offer an interactive update before a scan starts.
|
||||
|
||||
Returns True if strix was updated (caller should re-exec / exit).
|
||||
"""
|
||||
latest = get_available_update()
|
||||
if not latest or not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return False
|
||||
console.print()
|
||||
console.print(
|
||||
f"[#eab308]A new version of strix is available:[/] "
|
||||
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||
)
|
||||
console.print(
|
||||
"[dim] y — update now n — not now (ask again next run) s — skip this version[/]"
|
||||
)
|
||||
choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n")
|
||||
console.print()
|
||||
if choice == "s":
|
||||
skip_version(latest)
|
||||
return False
|
||||
if choice != "y":
|
||||
return False
|
||||
method = get_install_method()
|
||||
if method == "binary":
|
||||
return self_update(console, version=latest)
|
||||
return run_package_upgrade(console, method)
|
||||
|
||||
|
||||
def _release_target() -> str | None:
|
||||
raw_os = platform.system().lower()
|
||||
os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os)
|
||||
arch = platform.machine().lower()
|
||||
arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch)
|
||||
if os_name is None:
|
||||
return None
|
||||
target = f"{os_name}-{arch}"
|
||||
supported = {
|
||||
"linux-x86_64",
|
||||
"linux-arm64",
|
||||
"macos-x86_64",
|
||||
"macos-arm64",
|
||||
"windows-x86_64",
|
||||
}
|
||||
return target if target in supported else None
|
||||
|
||||
|
||||
def _download_and_replace(version: str, target: str, console: Console) -> bool:
|
||||
is_windows = target.startswith("windows")
|
||||
archive_ext = ".zip" if is_windows else ".tar.gz"
|
||||
filename = f"strix-{version}-{target}{archive_ext}"
|
||||
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}"
|
||||
binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "")
|
||||
current_exe = Path(sys.executable).resolve()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
archive_path = tmp_dir / filename
|
||||
console.print(f"[dim]Downloading[/] {url}")
|
||||
with requests.get( # nosec B113
|
||||
url,
|
||||
stream=True,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS * 12,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
with archive_path.open("wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=1 << 20):
|
||||
f.write(chunk)
|
||||
|
||||
expected_digest = _fetch_asset_digest(version, filename)
|
||||
if expected_digest:
|
||||
actual_digest = _sha256_file(archive_path)
|
||||
if actual_digest != expected_digest:
|
||||
raise RuntimeError(
|
||||
f"checksum mismatch for {filename}: "
|
||||
f"expected sha256 {expected_digest}, got {actual_digest}"
|
||||
)
|
||||
else:
|
||||
console.print("[dim yellow]No published checksum available; skipping verification[/]")
|
||||
|
||||
if is_windows:
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
zf.extract(binary_name, tmp_dir)
|
||||
else:
|
||||
with tarfile.open(archive_path, "r:gz") as tf:
|
||||
tf.extract(binary_name, tmp_dir, filter="data")
|
||||
|
||||
new_binary = tmp_dir / binary_name
|
||||
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
staged = current_exe.with_name(current_exe.name + ".new")
|
||||
try:
|
||||
shutil.copy2(new_binary, staged)
|
||||
if is_windows:
|
||||
# Windows can't replace a running executable in place; move it aside first.
|
||||
old = current_exe.with_name(current_exe.name + ".old")
|
||||
old.unlink(missing_ok=True)
|
||||
current_exe.rename(old)
|
||||
try:
|
||||
staged.replace(current_exe)
|
||||
except Exception:
|
||||
old.rename(current_exe)
|
||||
raise
|
||||
else:
|
||||
staged.replace(current_exe)
|
||||
except Exception:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
def self_update(console: Console | None = None, version: str | None = None) -> bool:
|
||||
"""Replace the running standalone binary with the latest release.
|
||||
|
||||
Returns True on success. For package-manager installs this only
|
||||
prints the right upgrade command and returns False.
|
||||
"""
|
||||
console = console or Console()
|
||||
|
||||
if not is_binary_install():
|
||||
method = get_install_method()
|
||||
console.print(
|
||||
f"[#eab308]This strix was installed via {method};[/] "
|
||||
f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
latest = version or _fetch_latest_version()
|
||||
if not latest:
|
||||
console.print("[bold red]Could not determine the latest strix version.[/]")
|
||||
return False
|
||||
|
||||
current = get_version()
|
||||
if current != "unknown" and not _is_newer(latest, current):
|
||||
console.print(f"[#22c55e]strix {current} is already the latest version.[/]")
|
||||
return True
|
||||
|
||||
target = _release_target()
|
||||
if not target:
|
||||
console.print(
|
||||
f"[bold red]No prebuilt binary for this platform "
|
||||
f"({platform.system()}/{platform.machine()}).[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
_download_and_replace(latest, target, console)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("self-update failed", exc_info=True)
|
||||
console.print(f"[bold red]Update failed:[/] {e}")
|
||||
console.print(
|
||||
"[dim]You can reinstall manually with:[/] "
|
||||
"[#60a5fa]curl -sSL https://strix.ai/install | bash[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
_write_cache(latest_version=latest, checked_at=time.time())
|
||||
console.print(f"[#22c55e]✓ Updated strix to {latest}[/]")
|
||||
return True
|
||||
+893
-134
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
"""Local web viewer for Strix runs.
|
||||
|
||||
Serves a prebuilt single-page app that renders a run (live or finished) read
|
||||
directly from the run's on-disk files. No cloud dependency, no file picker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from strix.interface.viewer.server import serve
|
||||
|
||||
|
||||
__all__ = ["serve"]
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Viewer email verification state and the relay client.
|
||||
|
||||
The local viewer proxies email verification and encrypted-report delivery to
|
||||
the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay
|
||||
directly, and the report password generated locally is never sent to it.
|
||||
|
||||
State lives in ``~/.strix/viewer-auth.json`` (0600). ``is_verified`` is a local
|
||||
flag that unlocks browsing the run history list; the relay still enforces token
|
||||
expiry when a report is actually sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTH_PATH = Path.home() / ".strix" / "viewer-auth.json"
|
||||
|
||||
_OTP_TIMEOUT = 15
|
||||
_SEND_TIMEOUT = 30
|
||||
|
||||
|
||||
class RelayError(Exception):
|
||||
"""A relay call failed. ``code`` is a stable, machine-readable reason."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
# --- local state ------------------------------------------------------------
|
||||
|
||||
|
||||
def read_auth() -> dict[str, Any] | None:
|
||||
"""Return the stored ``{email, token, verified_at}`` record, or None."""
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
email = data.get("email")
|
||||
token = data.get("token")
|
||||
if not isinstance(email, str) or not email or not isinstance(token, str) or not token:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def parse_expiry(raw: object) -> datetime | None:
|
||||
"""Parse a relay ``expires_at`` value into an aware UTC datetime.
|
||||
|
||||
Accepts both ISO 8601 strings and epoch seconds (as a number or numeric
|
||||
string) so a valid relay expiry is not misread as missing. Returns None only
|
||||
when it is genuinely absent or unparseable; both the local gate (see
|
||||
``is_verified``) and OTP verification (see ``otp_verify``) fail closed on such
|
||||
values, matching the relay, which rejects a token with no valid expiry.
|
||||
"""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
if isinstance(raw, int | float):
|
||||
return _from_epoch(raw)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
return _from_epoch(float(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""The stored ``verified_at`` parsed to a datetime, or None if unusable."""
|
||||
return parse_expiry(record.get("verified_at"))
|
||||
|
||||
|
||||
def _from_epoch(seconds: float) -> datetime | None:
|
||||
"""Epoch seconds → aware UTC datetime, or None if out of range."""
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=UTC)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_verified() -> bool:
|
||||
"""True when a usable email + token record with a valid future expiry exists.
|
||||
|
||||
The expiry returned by OTP verification is enforced here so history stops
|
||||
unlocking once the token lapses. It fails closed: a record whose expiry is
|
||||
absent, blank, or unparseable requires re-verification rather than unlocking
|
||||
forever, keeping the local gate in step with the relay (which rejects an
|
||||
expired token on report send).
|
||||
"""
|
||||
record = read_auth()
|
||||
if record is None:
|
||||
return False
|
||||
expiry = _expiry(record)
|
||||
return expiry is not None and expiry > datetime.now(UTC)
|
||||
|
||||
|
||||
def write_auth(email: str, token: str, verified_at: str) -> None:
|
||||
"""Atomically persist the auth record with 0600 permissions."""
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps({"email": email, "token": token, "verified_at": verified_at})
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""Delete the stored auth record. No-op if it is absent."""
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
# --- relay client -----------------------------------------------------------
|
||||
|
||||
|
||||
def _app_url() -> str:
|
||||
return load_settings().viewer.app_url.rstrip("/")
|
||||
|
||||
|
||||
def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
"""POST JSON to the relay. Returns (status, parsed body).
|
||||
|
||||
Raises RelayError("unavailable") for network/transport failures. HTTP
|
||||
error responses (4xx/5xx) are returned as (status, body) for the caller to
|
||||
map, not raised.
|
||||
"""
|
||||
url = f"{_app_url()}{path}"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("relay request to %s failed: %s", path, exc)
|
||||
raise RelayError("unavailable") from exc
|
||||
return response.status_code, _parse_body(response.content)
|
||||
|
||||
|
||||
def _parse_body(raw: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def otp_start(email: str) -> None:
|
||||
"""Ask the relay to email a verification code. Raises RelayError on failure."""
|
||||
status, data = _post_json("/api/oss/otp/start", {"email": email}, timeout=_OTP_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
# The relay uses 400 both for a malformed address and, separately, to
|
||||
# reject a free/personal email domain (it wants a work email).
|
||||
if data.get("error") == "work_email_required":
|
||||
raise RelayError("work_email_required")
|
||||
raise RelayError("invalid_email")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def otp_verify(email: str, code: str) -> dict[str, Any]:
|
||||
"""Verify a code. Returns ``{token, email, expires_at}`` or raises RelayError."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/otp/verify",
|
||||
{"email": email, "code": code},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200 and isinstance(data.get("token"), str):
|
||||
# A token with no usable expiry cannot unlock history locally (the gate
|
||||
# fails closed), so treat such a response as a failed verification rather
|
||||
# than reporting success and then leaving the user stuck unverified.
|
||||
if parse_expiry(data.get("expires_at")) is None:
|
||||
raise RelayError("unavailable")
|
||||
return data
|
||||
if status == 403:
|
||||
raise RelayError("invalid_code")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def feedback_submit(email: str, message: str) -> None:
|
||||
"""Relay a feedback message + email to Strix. No verification is required;
|
||||
the email is taken as given. Raises RelayError on failure."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/feedback",
|
||||
{"email": email, "message": message},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
code = data.get("error")
|
||||
if code in ("invalid_email", "invalid_message"):
|
||||
raise RelayError(str(code))
|
||||
raise RelayError("invalid_message")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def report_send(
|
||||
token: str,
|
||||
pdf_bytes: bytes,
|
||||
filename: str,
|
||||
run_name: str,
|
||||
target: str,
|
||||
) -> None:
|
||||
"""Forward the encrypted PDF to the relay for delivery.
|
||||
|
||||
The report password is NEVER part of this payload; only the encrypted PDF
|
||||
bytes travel to the relay.
|
||||
"""
|
||||
payload = {
|
||||
"token": token,
|
||||
"pdf_base64": base64.b64encode(pdf_bytes).decode("ascii"),
|
||||
"filename": filename,
|
||||
"run_name": run_name,
|
||||
"target": target,
|
||||
}
|
||||
status, _ = _post_json("/api/oss/report/send", payload, timeout=_SEND_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 401:
|
||||
raise RelayError("reverify")
|
||||
if status == 413:
|
||||
raise RelayError("too_large")
|
||||
if status == 403:
|
||||
raise RelayError("forbidden")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUTH_PATH",
|
||||
"RelayError",
|
||||
"feedback_submit",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
"otp_verify",
|
||||
"read_auth",
|
||||
"report_send",
|
||||
"write_auth",
|
||||
]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""`strix view [<run>]` command: serve a run's viewer UI locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from strix.core.paths import (
|
||||
RUNS_DIR_NAME,
|
||||
latest_run_dir,
|
||||
run_dir_for,
|
||||
run_record_path,
|
||||
runs_base_dir,
|
||||
)
|
||||
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.interface.viewer.transcript import read_run_summary
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_view(argv: list[str]) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="strix view",
|
||||
description="Open a local web view of a Strix run (live or finished).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"run",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=f"Run name under ./{RUNS_DIR_NAME} (defaults to the most recent run).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Port to serve on (default: an available ephemeral port).",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--no-open",
|
||||
action="store_true",
|
||||
help="Do not open the browser automatically.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
console = Console()
|
||||
|
||||
if not bundle_is_built():
|
||||
console.print(
|
||||
"[bold red]Viewer UI is not built.[/]\n"
|
||||
"Build it with: [cyan]cd strix/interface/viewer/frontend && npm ci && npm run build[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
run_dir = _resolve_run_dir(args.run, console)
|
||||
|
||||
httpd, url, token = serve(
|
||||
run_dir,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
)
|
||||
# The tokened URL is what authorizes the browser (steering, report sending,
|
||||
# history). Print it rather than the bare URL so the operator -- and only
|
||||
# the operator -- can open or share an authorized link.
|
||||
open_url = authorized_url(url, token)
|
||||
|
||||
run_name = run_dir.name
|
||||
summary = read_run_summary(run_dir)
|
||||
live = not summary.get("finished", False)
|
||||
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_opened(source="cli", live=live)
|
||||
|
||||
state_label = "[#eab308]live[/]" if live else "[#22c55e]finished[/]"
|
||||
console.print()
|
||||
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at:")
|
||||
# Print the URL alone on its own line with soft_wrap so Rich never inserts a
|
||||
# wrap into the (long, tokened) link -- that keeps it selectable/copyable.
|
||||
console.print(f" [#60a5fa]{open_url}[/]", soft_wrap=True)
|
||||
console.print("[dim]This link authorizes the browser; anyone you share it with can steer[/]")
|
||||
console.print("[dim]a live scan and browse history. Press Ctrl-C to stop the viewer.[/]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Viewer stopped.[/]")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _resolve_run_dir(run: str | None, console: Console) -> Path:
|
||||
if run:
|
||||
run_dir = run_dir_for(run)
|
||||
if not run_record_path(run_dir).is_file():
|
||||
_fail_no_run(console, requested=run)
|
||||
return run_dir
|
||||
|
||||
latest = latest_run_dir()
|
||||
if latest is None:
|
||||
_fail_no_run(console, requested=None)
|
||||
return latest
|
||||
|
||||
|
||||
def _fail_no_run(console: Console, *, requested: str | None) -> NoReturn:
|
||||
base = runs_base_dir()
|
||||
available = (
|
||||
sorted(
|
||||
(child.name for child in base.iterdir() if run_record_path(child).is_file()),
|
||||
reverse=True,
|
||||
)
|
||||
if base.is_dir()
|
||||
else []
|
||||
)
|
||||
|
||||
if requested:
|
||||
console.print(f"[bold red]No run named '{requested}' under ./{RUNS_DIR_NAME}.[/]")
|
||||
else:
|
||||
console.print(f"[bold red]No runs found under ./{RUNS_DIR_NAME}.[/]")
|
||||
|
||||
if available:
|
||||
console.print("Available runs:")
|
||||
for name in available[:20]:
|
||||
console.print(f" [cyan]{name}[/]")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
__all__ = ["run_view"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="./logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4219
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "strix-viewer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^8.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,789 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Radar,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
|
||||
import { SEVERITY_COLORS } from "@/types/issues";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import VulnerabilityDetail from "@/components/vulnerability/VulnerabilityDetail";
|
||||
import { ContentSection } from "@/components/vulnerability/ContentSection";
|
||||
import { IssueSeveritySummary } from "@/components/IssueSeveritySummary";
|
||||
import AgentGraph from "@/components/live/AgentGraph";
|
||||
import { buildGraphAgents } from "@/components/live/AgentTranscript";
|
||||
import AgentDetailModal from "@/components/live/AgentDetailModal";
|
||||
import { ScanPromptComposer } from "@/components/live/ScanPromptComposer";
|
||||
import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
|
||||
import {
|
||||
fetchAll,
|
||||
fetchAuthStatus,
|
||||
fetchCapabilities,
|
||||
fetchRunSummary,
|
||||
fetchRuns,
|
||||
fetchTranscript,
|
||||
fetchVulnerabilities,
|
||||
forgetAuth,
|
||||
type AuthStatus,
|
||||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import { RunDetails } from "@/components/RunDetails";
|
||||
import { TrustToast } from "@/components/TrustToast";
|
||||
import FeedbackView from "@/components/FeedbackView";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
|
||||
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
|
||||
export default function App() {
|
||||
const [activeRun, setActiveRun] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<LoadedRun | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [auth, setAuth] = useState<AuthStatus | null>(null);
|
||||
const [runs, setRuns] = useState<RunsPayload | null>(null);
|
||||
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
|
||||
// Whether this viewer can steer a live scan (true only inside the in-TUI
|
||||
// launcher that shares the running scan's coordinator + event loop).
|
||||
const [canSteer, setCanSteer] = useState(false);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
setAuth(await fetchAuthStatus());
|
||||
} catch {
|
||||
/* auth status is best-effort; the launched run stays viewable */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshRuns = useCallback(async () => {
|
||||
try {
|
||||
setRuns(await fetchRuns());
|
||||
} catch {
|
||||
/* history list is best-effort */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
// Capabilities never change over a session, so fetch once on mount.
|
||||
fetchCapabilities()
|
||||
.then((caps) => setCanSteer(caps.can_steer))
|
||||
.catch(() => {
|
||||
/* absence of steering is the safe default */
|
||||
});
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
// Live polling, scoped to the active run. Re-runs when the active run changes
|
||||
// so switching to a past run (?run=<name>) reloads its data; a finished run
|
||||
// does a single full fetch and stops.
|
||||
const finishedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
finishedRef.current = false;
|
||||
|
||||
const schedule = () => {
|
||||
timer = setTimeout(tick, POLL_MS);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const { summary, raw, finished } = await fetchRunSummary(activeRun);
|
||||
if (cancelled) return;
|
||||
if (finished && !finishedRef.current) {
|
||||
finishedRef.current = true;
|
||||
const full = await fetchAll(activeRun);
|
||||
if (!cancelled) setRun(full);
|
||||
return; // stop polling
|
||||
}
|
||||
const [transcript, vulnerabilities] = await Promise.all([
|
||||
fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })),
|
||||
fetchVulnerabilities(summary.runId, activeRun).catch(() => [] as Vulnerability[]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRun((prev) => ({
|
||||
summary,
|
||||
raw,
|
||||
finished,
|
||||
transcript,
|
||||
vulnerabilities,
|
||||
reportMarkdown: prev?.reportMarkdown ?? null,
|
||||
}));
|
||||
schedule();
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const full = await fetchAll(activeRun);
|
||||
if (cancelled) return;
|
||||
setRun(full);
|
||||
if (full.finished) {
|
||||
finishedRef.current = true;
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [activeRun]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => (run ? severityCounts(run.vulnerabilities) : null),
|
||||
[run]
|
||||
);
|
||||
const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null;
|
||||
const agentCount = run?.transcript.agents.length ?? 0;
|
||||
const verified = auth?.verified === true;
|
||||
|
||||
// Per-run guard for the default view: land on Agents while a scan is live,
|
||||
// Overview once it finishes. Applied at most once per run and never once the
|
||||
// user has navigated manually (userSetView flips the guard).
|
||||
const initialViewAppliedRef = useRef(false);
|
||||
|
||||
// Reset the guard whenever the active run changes so the newly selected run
|
||||
// gets its own default.
|
||||
useEffect(() => {
|
||||
initialViewAppliedRef.current = false;
|
||||
}, [activeRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialViewAppliedRef.current || !run) return;
|
||||
if (run.finished) {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("overview");
|
||||
} else if (agentCount > 0) {
|
||||
// Live and agents have appeared: default to the agent graph. If it is
|
||||
// live but no agents exist yet, wait (do not apply, do not set the flag).
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("agents");
|
||||
}
|
||||
}, [run, agentCount]);
|
||||
|
||||
// User-initiated navigation: mark the default guard applied so the per-run
|
||||
// default effect never yanks the user off the view they chose.
|
||||
const userSetView = useCallback((v: View) => {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView(v);
|
||||
}, []);
|
||||
|
||||
const selectRun = useCallback((name: string) => {
|
||||
setActiveRun(name);
|
||||
setSelectedId(null);
|
||||
setRun(null);
|
||||
setError(null);
|
||||
// Reset the guard so the per-run default applies to the newly selected run.
|
||||
initialViewAppliedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const goEmail = useCallback((skipDisclosure: boolean, surface: string) => {
|
||||
trackCta("email_report", surface);
|
||||
setEmailPurpose("report");
|
||||
setEmailSkipDisclosure(skipDisclosure);
|
||||
userSetView("email");
|
||||
}, [userSetView]);
|
||||
|
||||
// Sidebar entry keeps the disclosure (first place those users see it);
|
||||
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
|
||||
// the Overview CTA already states the tradeoff, so it starts the flow directly.
|
||||
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
|
||||
|
||||
const openHistory = useCallback(() => {
|
||||
void refreshRuns();
|
||||
userSetView("history");
|
||||
}, [refreshRuns, userSetView]);
|
||||
|
||||
const onPastRunsVerified = useCallback(async () => {
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
const onForget = useCallback(async () => {
|
||||
await forgetAuth();
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex">
|
||||
<Sidebar
|
||||
view={view}
|
||||
onSelectView={(v) => {
|
||||
// Clicking a sidebar view always lands on that section's top level,
|
||||
// so leaving a specific issue's detail view and clicking "Issues"
|
||||
// returns to the full findings list.
|
||||
setSelectedId(null);
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
finished={run?.finished ?? false}
|
||||
verified={verified}
|
||||
email={auth?.email ?? null}
|
||||
onOpenEmail={openEmail}
|
||||
onOpenHistory={openHistory}
|
||||
onForget={() => void onForget()}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "topbar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
|
||||
<div className="text-base text-white font-medium tracking-tight">Strix</div>
|
||||
</a>
|
||||
{run && <LiveIndicator finished={run.finished} />}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{verified && runs && !runs.locked && runs.runs.length > 0 && (
|
||||
<RunSwitcher
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "Current run")}
|
||||
onSelect={selectRun}
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "run_in_cloud")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("run_in_cloud", "topbar")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Run in the cloud
|
||||
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
|
||||
page-in transition replays. */}
|
||||
<div
|
||||
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
|
||||
className="animate-page-in space-y-6"
|
||||
>
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
auth={auth}
|
||||
purpose={emailPurpose}
|
||||
skipDisclosure={emailSkipDisclosure}
|
||||
onAuthChanged={() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feedback" ? (
|
||||
<FeedbackView
|
||||
defaultEmail={auth?.email ?? null}
|
||||
onExit={(dest) => setView(dest)}
|
||||
/>
|
||||
) : view === "history" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Past runs</h1>
|
||||
</div>
|
||||
<PastRunsView
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
onSelectRun={selectRun}
|
||||
onVerified={() => void onPastRunsVerified()}
|
||||
/>
|
||||
</div>
|
||||
) : !run && !error ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
|
||||
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
|
||||
<p className="text-sm text-[#888]">Loading run data…</p>
|
||||
</div>
|
||||
) : run && counts ? (
|
||||
<>
|
||||
<SummaryHeader summary={run.summary} />
|
||||
|
||||
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
|
||||
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
|
||||
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
|
||||
Pentest Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
</TabButton>
|
||||
{agentCount > 0 && (
|
||||
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
|
||||
Agents ({agentCount})
|
||||
</TabButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "overview" ? (
|
||||
<OverviewTab
|
||||
summary={run.summary}
|
||||
counts={counts}
|
||||
total={run.vulnerabilities.length}
|
||||
reportMarkdown={run.reportMarkdown}
|
||||
raw={run.raw}
|
||||
finished={run.finished}
|
||||
onOpenEmail={openEmailFromOverview}
|
||||
/>
|
||||
) : view === "agents" && agentCount > 0 ? (
|
||||
<AgentsTab run={run} canSteer={canSteer} />
|
||||
) : selected ? (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to all findings
|
||||
</button>
|
||||
<VulnerabilityDetail vulnerability={selected} />
|
||||
</div>
|
||||
) : (
|
||||
<FindingsList
|
||||
vulnerabilities={run.vulnerabilities}
|
||||
finished={run.finished}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrustToast message={TRUST_BANNER} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSwitcher({
|
||||
runs,
|
||||
activeRun,
|
||||
launchedName,
|
||||
onSelect,
|
||||
}: {
|
||||
runs: RunsPayload;
|
||||
activeRun: string | null;
|
||||
launchedName: string;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeEntry = runs.runs.find((r) => r.name === activeRun);
|
||||
const current = activeEntry ? runTitle(activeEntry.target, activeEntry.name) : launchedName;
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
aria-label="Switch pentest"
|
||||
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
|
||||
>
|
||||
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<span className="flex-shrink-0 text-[#888]">Pentest</span>
|
||||
<span className="max-w-[260px] truncate font-medium">{current}</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
|
||||
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
|
||||
>
|
||||
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
|
||||
Switch pentest
|
||||
</div>
|
||||
{runs.runs.map((r) => {
|
||||
const active = r.name === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={r.name}
|
||||
onMouseDown={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "bg-[rgba(255,255,255,0.04)] text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIndicator({ finished }: { finished: boolean }) {
|
||||
if (finished) {
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#555]" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string | null {
|
||||
if (seconds == null) return null;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
const duration = formatDuration(summary.durationSeconds);
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest results")}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
|
||||
{summary.targets.length > 0 && (
|
||||
<span className="font-mono text-[#aaa]">{summary.targets.join(", ")}</span>
|
||||
)}
|
||||
{summary.scanMode && <Meta label={summary.scanMode} />}
|
||||
{duration && <Meta label={duration} />}
|
||||
{summary.status && <Meta label={summary.status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label }: { label: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-[#333]">·</span>
|
||||
<span className="capitalize">{label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingsList({
|
||||
vulnerabilities,
|
||||
finished,
|
||||
onSelect,
|
||||
}: {
|
||||
vulnerabilities: Vulnerability[];
|
||||
finished: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const sorted = [...vulnerabilities].sort(
|
||||
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
||||
);
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-medium text-white">Stay ahead of new exposures</p>
|
||||
<p className="mt-0.5 mb-3 text-xs text-[#666]">
|
||||
Attack surface monitoring catches new exposures for your org over time.
|
||||
</p>
|
||||
<ProInlineCta
|
||||
label="Attack surface monitoring"
|
||||
desc="Continuous coverage for your whole org."
|
||||
slug="asm"
|
||||
surface="empty_state"
|
||||
icon={Radar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
className="animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-white truncate">{v.title}</span>
|
||||
{v.target && (
|
||||
<span className="block text-xs text-[#666] font-mono truncate">{v.target}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${SEVERITY_COLORS[v.severity]}`}
|
||||
>
|
||||
{v.severity}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip a single leading markdown heading (report sections embed their own). */
|
||||
function stripLeadingHeading(md: string): string {
|
||||
return md.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/, "").trimStart();
|
||||
}
|
||||
|
||||
function dedupeHeadings(md: string): string {
|
||||
const out: string[] = [];
|
||||
let lastHeading: string | null = null;
|
||||
for (const line of md.split("\n")) {
|
||||
const m = line.match(/^#{1,6}\s+(.*)$/);
|
||||
if (m) {
|
||||
const norm = m[1].trim().toLowerCase();
|
||||
if (norm === lastHeading) continue;
|
||||
lastHeading = norm;
|
||||
} else if (line.trim() !== "") {
|
||||
lastHeading = null;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Primary local CTA: email an encrypted PDF. Verify-email affordance, no lock. */
|
||||
function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpenEmail}
|
||||
className="group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ border: "1px solid rgba(16,185,129,0.3)", background: "rgba(16,185,129,0.08)" }}
|
||||
>
|
||||
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white">Email an encrypted PDF report of this run</p>
|
||||
<p className="mt-0.5 text-xs text-[#888]">
|
||||
Encrypted with a key only you can see, email verified with a one-time code before sending.
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Export report to PDF
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
summary,
|
||||
counts,
|
||||
total,
|
||||
reportMarkdown,
|
||||
raw,
|
||||
finished,
|
||||
onOpenEmail,
|
||||
}: {
|
||||
summary: ParsedRunSummary;
|
||||
counts: Record<VulnerabilitySeverity, number>;
|
||||
total: number;
|
||||
reportMarkdown: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
onOpenEmail: () => void;
|
||||
}) {
|
||||
const sections = (
|
||||
[
|
||||
["Executive Summary", summary.executiveSummary],
|
||||
["Technical Analysis", summary.technicalAnalysis],
|
||||
["Methodology", summary.methodology],
|
||||
["Recommendations", summary.recommendations],
|
||||
] as const
|
||||
)
|
||||
.filter(([, content]) => !!content)
|
||||
.map(([title, content]) => ({ title, content: stripLeadingHeading(content as string) }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="animate-card-in">
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. Hidden until the run is
|
||||
finished, since a live scan would only email a partial report. */}
|
||||
{finished && (
|
||||
<div className="animate-card-in">
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
total === 0 && (
|
||||
<p className="text-sm text-[#888]">No summary available for this run yet.</p>
|
||||
)
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${
|
||||
active ? "text-white" : "text-[#666] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{active && <span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
const { agents, events } = run.transcript;
|
||||
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
|
||||
// Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
// Live steering is only possible in-process (canSteer) while the scan runs.
|
||||
const steerable = canSteer && !run.finished;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
|
||||
<span className="text-xs text-[#666]">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 mb-4 text-xs text-[#666]">
|
||||
Click an agent to open its full transcript.
|
||||
</p>
|
||||
<div className="h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden">
|
||||
<AgentGraph
|
||||
agents={graphAgents}
|
||||
selectedAgentId={selectedId}
|
||||
onSelectAgent={(id) => setSelectedId(id)}
|
||||
eventsLoaded
|
||||
eventsEmpty={graphAgents.size === 0}
|
||||
scanCompleted={run.finished}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
|
||||
{steerable && <ScanPromptComposer agents={agents} />}
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Run this pentest with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentDetailModal
|
||||
open={selectedAgent !== null}
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Extracted ProviderIcon from strix-app's AddRepositoryDialog. The dialog itself
|
||||
// (and its next/link dependency) is dropped; the IssueSidebar only needs this SVG
|
||||
// switch to badge a finding's source-control provider. Web-app targets resolve to
|
||||
// provider === null and never reach here (they render a globe icon instead).
|
||||
import { Github, Gitlab } from "lucide-react";
|
||||
|
||||
function BitbucketIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M2.65 3a.72.72 0 0 0-.72.83l2.86 17.39a.98.98 0 0 0 .96.82h13.72a.72.72 0 0 0 .72-.6l2.86-17.4A.72.72 0 0 0 22.3 3H2.65Zm12.1 12.53H9.3L8.06 8.9h7.8l-1.11 6.63Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderIcon({ provider, className }: { provider: string; className?: string }) {
|
||||
const cls = className ?? "w-4 h-4";
|
||||
if (provider === "gitlab") return <Gitlab className={`${cls} text-orange-400`} />;
|
||||
if (provider === "bitbucket") return <BitbucketIcon className={`${cls} text-blue-400`} />;
|
||||
return <Github className={`${cls} text-white`} />;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
otpStart,
|
||||
otpVerify,
|
||||
sendReport,
|
||||
type AuthStatus,
|
||||
} from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* The email-report / email-verification flow rendered as its own page (not a
|
||||
* modal, so it never floats over another surface). Report mode ends in the
|
||||
* one-time password panel; verify mode just confirms the email and returns to
|
||||
* the caller. The page unmounts when you navigate away, so state resets each
|
||||
* time it is opened.
|
||||
*/
|
||||
|
||||
type Step = "disclosure" | "email" | "code" | "sending" | "password";
|
||||
|
||||
interface EmailReportViewProps {
|
||||
activeRun: string | null;
|
||||
auth: AuthStatus | null;
|
||||
purpose: "report" | "verify";
|
||||
/**
|
||||
* Skip the report disclosure and start the flow directly (used by the
|
||||
* Overview CTA, which already states the tradeoff). Unverified users land on
|
||||
* the email step; already-verified users send immediately.
|
||||
*/
|
||||
skipDisclosure?: boolean;
|
||||
/** Refresh auth + runs after a successful verify (lifts state to App). */
|
||||
onAuthChanged: () => void;
|
||||
/** Leave this page (report "Done" -> overview; verify success -> history). */
|
||||
onExit: (dest: "overview" | "history") => void;
|
||||
}
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
const SEND_ERRORS: Record<string, string> = {
|
||||
forbidden: "This email was unsubscribed from Strix, so we cannot send to it.",
|
||||
too_large: "This report is too large to email. Try a smaller run.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailReportView({
|
||||
activeRun,
|
||||
auth,
|
||||
purpose,
|
||||
skipDisclosure = false,
|
||||
onAuthChanged,
|
||||
onExit,
|
||||
}: EmailReportViewProps) {
|
||||
const verified = auth?.verified === true;
|
||||
const verifyOnly = purpose === "verify";
|
||||
// Verify mode (and the Overview CTA, which skips the disclosure) start on the
|
||||
// email step; a verified user who skips the disclosure sends immediately.
|
||||
const [step, setStep] = useState<Step>(() => {
|
||||
if (verifyOnly) return "email";
|
||||
if (skipDisclosure) return verified ? "sending" : "email";
|
||||
return "disclosure";
|
||||
});
|
||||
const [email, setEmail] = useState(auth?.email ?? "");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sentTo, setSentTo] = useState("");
|
||||
const autoSentRef = useRef(false);
|
||||
|
||||
const doSend = async () => {
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await sendReport(activeRun);
|
||||
if (result.ok) {
|
||||
track("report_sent");
|
||||
setPassword(result.password);
|
||||
setFilename(result.filename);
|
||||
setStep("password");
|
||||
return;
|
||||
}
|
||||
if (result.error === "reverify" || result.error === "unverified") {
|
||||
setNotice("Your verification expired. Enter your email to verify again.");
|
||||
setStep("email");
|
||||
return;
|
||||
}
|
||||
setError(SEND_ERRORS[result.error] ?? "Could not send the report. Try again.");
|
||||
setStep("disclosure");
|
||||
};
|
||||
|
||||
const startFlow = () => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
if (verified) void doSend();
|
||||
else setStep("email");
|
||||
};
|
||||
|
||||
// A verified user who skipped the disclosure (Overview CTA) sends on arrival.
|
||||
useEffect(() => {
|
||||
if (!verifyOnly && skipDisclosure && verified && !autoSentRef.current) {
|
||||
autoSentRef.current = true;
|
||||
void doSend();
|
||||
}
|
||||
// Run once on mount; the page remounts fresh each time it is opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose });
|
||||
setSentTo(result.email);
|
||||
onAuthChanged();
|
||||
if (verifyOnly) onExit("history");
|
||||
else void doSend();
|
||||
};
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard may be unavailable; the password is visible to copy manually */
|
||||
}
|
||||
};
|
||||
|
||||
const confirmationEmail = sentTo || auth?.email || email.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit(verifyOnly ? "history" : "overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{verifyOnly ? "Back to past runs" : "Back to results"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{verifyOnly ? "Verify your email" : "Export report to PDF"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
{verifyOnly
|
||||
? "We send a one-time code to confirm it is you."
|
||||
: "Verified by a one-time code sent to your email"}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && step !== "password" && (
|
||||
<p className="mb-4 text-xs text-[#888]">{notice}</p>
|
||||
)}
|
||||
|
||||
{step === "disclosure" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-2.5 rounded-lg p-3.5"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
Only you hold the password; Strix can't read it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startFlow}
|
||||
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Export report
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "email" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "code" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{verifyOnly ? "Verify" : "Verify and send"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "sending" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden="true" />
|
||||
<p className="text-sm text-[#aaa]">Generating and encrypting locally...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "password" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
|
||||
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs text-emerald-200">
|
||||
Sent to {confirmationEmail}. Open the attached PDF with this password.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your one-time password</span>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-black p-3"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<code className="flex-1 break-all font-mono text-base text-white">{password}</code>
|
||||
<button
|
||||
onClick={copyPassword}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#666]">
|
||||
Save this now. Strix never stores it, so we cannot show it again. File:{" "}
|
||||
<span className="font-mono text-[#888]">{filename}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { otpStart, otpVerify } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Compact inline email -> 6-digit-code verify flow. Unlike EmailReportView this
|
||||
* has no page chrome, no report send, and no password panel: it just confirms
|
||||
* the email so the past-runs list can unlock in place. On success it calls
|
||||
* `onVerified` (the parent refreshes auth + runs).
|
||||
*/
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailVerifyInline({ onVerified }: { onVerified: () => void }) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose: "verify" });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose: "verify" });
|
||||
onVerified();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-5 max-w-sm text-left">
|
||||
{error && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && <p className="mb-3 text-xs text-[#888]">{notice}</p>}
|
||||
|
||||
{step === "email" ? (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { submitFeedback } from "@/data/serverSource";
|
||||
import type { View } from "@/App";
|
||||
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_email: "That email doesn't look right.",
|
||||
invalid_message: "Please write a little more.",
|
||||
unavailable: "Couldn't send that just now. Try again.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Feedback & support form. Collects a message plus a work email (no
|
||||
* verification — the email is taken as-is) and relays it to Strix via the local
|
||||
* server. Mirrors EmailReportView's centered-card styling and palette.
|
||||
*/
|
||||
export default function FeedbackView({
|
||||
defaultEmail,
|
||||
onExit,
|
||||
}: {
|
||||
defaultEmail: string | null;
|
||||
onExit: (dest: View) => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState(defaultEmail ?? "");
|
||||
const [step, setStep] = useState<"form" | "sending" | "sent">("form");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await submitFeedback(message.trim(), email.trim());
|
||||
if (result.ok) {
|
||||
setStep("sent");
|
||||
return;
|
||||
}
|
||||
setStep("form");
|
||||
setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to results
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<IoChatbubblesOutline className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Feedback & support</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{step === "sent" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">Thanks, we got it.</p>
|
||||
<p className="mt-1 text-xs text-[#888]">
|
||||
We read every message. If it needs a reply, we'll reach out to the email you gave.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMessage("");
|
||||
setStep("form");
|
||||
}}
|
||||
className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
Bugs, feature requests, or anything else. Tell us what's on your mind.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your feedback</span>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={message}
|
||||
maxLength={MAX_MESSAGE}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="What's working, what's not, what you'd love to see…"
|
||||
className="w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
className="mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{step === "sending" ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface IssueSeveritySummaryFindings {
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
interface IssueSeveritySummaryProps {
|
||||
findings: IssueSeveritySummaryFindings;
|
||||
className?: string;
|
||||
/** Noun for the total count (e.g. "issues", "CVEs"). Defaults to "issues". */
|
||||
unit?: string;
|
||||
/** Optional content rendered at the end of the count row (e.g. a KEV badge). */
|
||||
trailing?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SEVERITIES = [
|
||||
{ key: "critical", label: "critical", dotClass: "bg-red-500", textClass: "text-red-500" },
|
||||
{ key: "high", label: "high", dotClass: "bg-orange-500", textClass: "text-orange-500" },
|
||||
{ key: "medium", label: "medium", dotClass: "bg-yellow-500", textClass: "text-yellow-500" },
|
||||
{ key: "low", label: "low", dotClass: "bg-blue-500", textClass: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
export function IssueSeveritySummary({
|
||||
findings,
|
||||
className,
|
||||
unit = "issues",
|
||||
trailing,
|
||||
}: IssueSeveritySummaryProps) {
|
||||
if (findings.total <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-semibold text-white tabular-nums">{findings.total}</span>
|
||||
<span className="text-sm text-[#666]">{unit}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{SEVERITIES.map(({ key, label, dotClass, textClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className={cn("w-2 h-2 rounded-full", dotClass)} aria-hidden="true" />
|
||||
<span className={cn("text-sm tabular-nums", textClass)}>{count}</span>
|
||||
<span className="text-xs text-[#555]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{trailing ? <div className="flex items-center gap-2">{trailing}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 rounded-full bg-[#222] overflow-hidden flex">
|
||||
{SEVERITIES.map(({ key, dotClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn("h-full", dotClass)}
|
||||
style={{ width: `${(count / findings.total) * 100}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState } from "react";
|
||||
import { History, ChevronRight, Terminal } from "lucide-react";
|
||||
import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import { trackCta } from "@/lib/cta";
|
||||
import EmailVerifyInline from "@/components/EmailVerifyInline";
|
||||
|
||||
/**
|
||||
* "Past runs" panel. Unverified users see a tease with the run count and a
|
||||
* verify affordance (the launched run stays fully visible; the CLI
|
||||
* `strix view <name>` still works). Verified users get the full history and can
|
||||
* switch the active run, which threads ?run=<name> through the data fetches.
|
||||
*/
|
||||
|
||||
const SEV = [
|
||||
{ key: "critical", dot: "bg-red-500", text: "text-red-500" },
|
||||
{ key: "high", dot: "bg-orange-500", text: "text-orange-500" },
|
||||
{ key: "medium", dot: "bg-yellow-500", text: "text-yellow-500" },
|
||||
{ key: "low", dot: "bg-blue-500", text: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
function SeverityChips({ counts }: { counts: RunSeverityCounts }) {
|
||||
const shown = SEV.filter((s) => counts[s.key] > 0);
|
||||
if (shown.length === 0) {
|
||||
return <span className="text-xs text-[#555]">No findings</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{shown.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden="true" />
|
||||
<span className={`text-xs tabular-nums ${s.text}`}>{counts[s.key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time ("just now" / "5m ago" / "3h ago" / "2d ago"), falling back to
|
||||
* the absolute date for anything older than a week (mirrors the pro app).
|
||||
*/
|
||||
function formatTimeAgo(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return formatDate(iso);
|
||||
}
|
||||
|
||||
interface PastRunsViewProps {
|
||||
runs: RunsPayload | null;
|
||||
activeRun: string | null;
|
||||
onSelectRun: (name: string) => void;
|
||||
onVerified: () => void;
|
||||
}
|
||||
|
||||
export default function PastRunsView({
|
||||
runs,
|
||||
activeRun,
|
||||
onSelectRun,
|
||||
onVerified,
|
||||
}: PastRunsViewProps) {
|
||||
const count = runs?.count ?? 0;
|
||||
const [showVerify, setShowVerify] = useState(false);
|
||||
|
||||
if (!runs || runs.locked) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<History className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-white">Browse every run on this machine</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-[#888]">
|
||||
You have {count} past {count === 1 ? "run" : "runs"} on this machine.
|
||||
</p>
|
||||
{showVerify ? (
|
||||
<>
|
||||
<p className="mx-auto mt-3 max-w-sm text-xs text-[#666]">
|
||||
Verify your email with a one-time code to unlock the full history.
|
||||
</p>
|
||||
<EmailVerifyInline onVerified={onVerified} />
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
trackCta("history_unlock", "past_runs");
|
||||
setShowVerify(true);
|
||||
}}
|
||||
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
View runs
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]">
|
||||
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Or open one from the CLI with{" "}
|
||||
<code className="font-mono text-[#888]">strix view <name></code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runs.runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
No past runs found on this machine yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runs.runs.map((run: RunListEntry) => {
|
||||
const active = run.name === activeRun;
|
||||
const date = formatTimeAgo(run.start_time) ?? formatTimeAgo(run.end_time);
|
||||
const title = runTitle(run.target, run.name);
|
||||
return (
|
||||
<button
|
||||
key={run.name}
|
||||
onClick={() => onSelectRun(run.name)}
|
||||
className={`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
active
|
||||
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
|
||||
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">{title}</span>
|
||||
{active && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400" style={{ border: "1px solid rgba(16,185,129,0.3)" }}>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]">
|
||||
{run.scan_mode && <span className="capitalize">{run.scan_mode}</span>}
|
||||
{run.scan_mode && (date || run.status) && <span className="text-[#333]">·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
{date && run.status && <span className="text-[#333]">·</span>}
|
||||
{run.status && <span className="capitalize">{run.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<SeverityChips counts={run.severity_counts} />
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user