Repository: usestrix/strix
Branch: main
Commit: c9d2477144f8
Files: 207
Total size: 1.1 MB
Directory structure:
gitextract_bz27uie2/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ └── build-release.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── benchmarks/
│ └── README.md
├── containers/
│ ├── Dockerfile
│ └── docker-entrypoint.sh
├── docs/
│ ├── README.md
│ ├── advanced/
│ │ ├── configuration.mdx
│ │ └── skills.mdx
│ ├── cloud/
│ │ └── overview.mdx
│ ├── contributing.mdx
│ ├── docs.json
│ ├── index.mdx
│ ├── integrations/
│ │ ├── ci-cd.mdx
│ │ └── github-actions.mdx
│ ├── llm-providers/
│ │ ├── anthropic.mdx
│ │ ├── azure.mdx
│ │ ├── bedrock.mdx
│ │ ├── local.mdx
│ │ ├── models.mdx
│ │ ├── openai.mdx
│ │ ├── openrouter.mdx
│ │ ├── overview.mdx
│ │ └── vertex.mdx
│ ├── quickstart.mdx
│ ├── tools/
│ │ ├── browser.mdx
│ │ ├── overview.mdx
│ │ ├── proxy.mdx
│ │ ├── sandbox.mdx
│ │ └── terminal.mdx
│ └── usage/
│ ├── cli.mdx
│ ├── instructions.mdx
│ └── scan-modes.mdx
├── pyproject.toml
├── scripts/
│ ├── build.sh
│ └── install.sh
├── strix/
│ ├── __init__.py
│ ├── agents/
│ │ ├── StrixAgent/
│ │ │ ├── __init__.py
│ │ │ ├── strix_agent.py
│ │ │ └── system_prompt.jinja
│ │ ├── __init__.py
│ │ ├── base_agent.py
│ │ └── state.py
│ ├── config/
│ │ ├── __init__.py
│ │ └── config.py
│ ├── interface/
│ │ ├── __init__.py
│ │ ├── assets/
│ │ │ └── tui_styles.tcss
│ │ ├── cli.py
│ │ ├── main.py
│ │ ├── streaming_parser.py
│ │ ├── tool_components/
│ │ │ ├── __init__.py
│ │ │ ├── agent_message_renderer.py
│ │ │ ├── agents_graph_renderer.py
│ │ │ ├── base_renderer.py
│ │ │ ├── browser_renderer.py
│ │ │ ├── file_edit_renderer.py
│ │ │ ├── finish_renderer.py
│ │ │ ├── load_skill_renderer.py
│ │ │ ├── notes_renderer.py
│ │ │ ├── proxy_renderer.py
│ │ │ ├── python_renderer.py
│ │ │ ├── registry.py
│ │ │ ├── reporting_renderer.py
│ │ │ ├── scan_info_renderer.py
│ │ │ ├── terminal_renderer.py
│ │ │ ├── thinking_renderer.py
│ │ │ ├── todo_renderer.py
│ │ │ ├── user_message_renderer.py
│ │ │ └── web_search_renderer.py
│ │ ├── tui.py
│ │ └── utils.py
│ ├── llm/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── dedupe.py
│ │ ├── llm.py
│ │ ├── memory_compressor.py
│ │ └── utils.py
│ ├── runtime/
│ │ ├── __init__.py
│ │ ├── docker_runtime.py
│ │ ├── runtime.py
│ │ └── tool_server.py
│ ├── skills/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── cloud/
│ │ │ └── .gitkeep
│ │ ├── coordination/
│ │ │ └── root_agent.md
│ │ ├── custom/
│ │ │ └── .gitkeep
│ │ ├── frameworks/
│ │ │ ├── fastapi.md
│ │ │ ├── nestjs.md
│ │ │ └── nextjs.md
│ │ ├── protocols/
│ │ │ └── graphql.md
│ │ ├── reconnaissance/
│ │ │ └── .gitkeep
│ │ ├── scan_modes/
│ │ │ ├── deep.md
│ │ │ ├── quick.md
│ │ │ └── standard.md
│ │ ├── technologies/
│ │ │ ├── firebase_firestore.md
│ │ │ └── supabase.md
│ │ ├── tooling/
│ │ │ ├── ffuf.md
│ │ │ ├── httpx.md
│ │ │ ├── katana.md
│ │ │ ├── naabu.md
│ │ │ ├── nmap.md
│ │ │ ├── nuclei.md
│ │ │ ├── semgrep.md
│ │ │ ├── sqlmap.md
│ │ │ └── subfinder.md
│ │ └── vulnerabilities/
│ │ ├── authentication_jwt.md
│ │ ├── broken_function_level_authorization.md
│ │ ├── business_logic.md
│ │ ├── csrf.md
│ │ ├── idor.md
│ │ ├── information_disclosure.md
│ │ ├── insecure_file_uploads.md
│ │ ├── mass_assignment.md
│ │ ├── open_redirect.md
│ │ ├── path_traversal_lfi_rfi.md
│ │ ├── race_conditions.md
│ │ ├── rce.md
│ │ ├── sql_injection.md
│ │ ├── ssrf.md
│ │ ├── subdomain_takeover.md
│ │ ├── xss.md
│ │ └── xxe.md
│ ├── telemetry/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── flags.py
│ │ ├── posthog.py
│ │ ├── tracer.py
│ │ └── utils.py
│ ├── tools/
│ │ ├── __init__.py
│ │ ├── agents_graph/
│ │ │ ├── __init__.py
│ │ │ ├── agents_graph_actions.py
│ │ │ └── agents_graph_actions_schema.xml
│ │ ├── argument_parser.py
│ │ ├── browser/
│ │ │ ├── __init__.py
│ │ │ ├── browser_actions.py
│ │ │ ├── browser_actions_schema.xml
│ │ │ ├── browser_instance.py
│ │ │ └── tab_manager.py
│ │ ├── context.py
│ │ ├── executor.py
│ │ ├── file_edit/
│ │ │ ├── __init__.py
│ │ │ ├── file_edit_actions.py
│ │ │ └── file_edit_actions_schema.xml
│ │ ├── finish/
│ │ │ ├── __init__.py
│ │ │ ├── finish_actions.py
│ │ │ └── finish_actions_schema.xml
│ │ ├── load_skill/
│ │ │ ├── __init__.py
│ │ │ ├── load_skill_actions.py
│ │ │ └── load_skill_actions_schema.xml
│ │ ├── notes/
│ │ │ ├── __init__.py
│ │ │ ├── notes_actions.py
│ │ │ └── notes_actions_schema.xml
│ │ ├── proxy/
│ │ │ ├── __init__.py
│ │ │ ├── proxy_actions.py
│ │ │ ├── proxy_actions_schema.xml
│ │ │ └── proxy_manager.py
│ │ ├── python/
│ │ │ ├── __init__.py
│ │ │ ├── python_actions.py
│ │ │ ├── python_actions_schema.xml
│ │ │ ├── python_instance.py
│ │ │ └── python_manager.py
│ │ ├── registry.py
│ │ ├── reporting/
│ │ │ ├── __init__.py
│ │ │ ├── reporting_actions.py
│ │ │ └── reporting_actions_schema.xml
│ │ ├── terminal/
│ │ │ ├── __init__.py
│ │ │ ├── terminal_actions.py
│ │ │ ├── terminal_actions_schema.xml
│ │ │ ├── terminal_manager.py
│ │ │ └── terminal_session.py
│ │ ├── thinking/
│ │ │ ├── __init__.py
│ │ │ ├── thinking_actions.py
│ │ │ └── thinking_actions_schema.xml
│ │ ├── todo/
│ │ │ ├── __init__.py
│ │ │ ├── todo_actions.py
│ │ │ └── todo_actions_schema.xml
│ │ └── web_search/
│ │ ├── __init__.py
│ │ ├── web_search_actions.py
│ │ └── web_search_actions_schema.xml
│ └── utils/
│ ├── __init__.py
│ └── resource_paths.py
├── strix.spec
└── tests/
├── __init__.py
├── agents/
│ └── __init__.py
├── config/
│ ├── __init__.py
│ └── test_config_telemetry.py
├── conftest.py
├── interface/
│ └── __init__.py
├── llm/
│ ├── __init__.py
│ └── test_llm_otel.py
├── runtime/
│ └── __init__.py
├── skills/
│ └── __init__.py
├── telemetry/
│ ├── __init__.py
│ ├── test_flags.py
│ ├── test_tracer.py
│ └── test_utils.py
└── tools/
├── __init__.py
├── conftest.py
├── test_argument_parser.py
├── test_load_skill_tool.py
└── test_tool_registration_modes.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1.
2.
3.
4.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System Information:**
- OS: [e.g. Ubuntu 22.04]
- Strix Version or Commit: [e.g. 0.1.18]
- Python Version: [e.g. 3.12]
- LLM Used: [e.g. GPT-5, Claude Sonnet 4.6]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/build-release.yml
================================================
name: Build & Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: macos-arm64
- os: macos-15-intel
target: macos-x86_64
- os: ubuntu-latest
target: linux-x86_64
- os: windows-latest
target: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: snok/install-poetry@v1
- name: Build
shell: bash
run: |
poetry install --with dev
poetry run pyinstaller strix.spec --noconfirm
VERSION=$(poetry version -s)
mkdir -p dist/release
if [[ "${{ runner.os }}" == "Windows" ]]; then
cp dist/strix.exe "dist/release/strix-${VERSION}-${{ matrix.target }}.exe"
(cd dist/release && 7z a "strix-${VERSION}-${{ matrix.target }}.zip" "strix-${VERSION}-${{ matrix.target }}.exe")
else
cp dist/strix "dist/release/strix-${VERSION}-${{ matrix.target }}"
chmod +x "dist/release/strix-${VERSION}-${{ matrix.target }}"
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
fi
- uses: actions/upload-artifact@v4
with:
name: strix-${{ matrix.target }}
path: |
dist/release/*.tar.gz
dist/release/*.zip
if-no-files-found: error
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: release
merge-multiple: true
- name: Create Release
uses: softprops/action-gh-release@v2
with:
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
generate_release_notes: true
files: release/*
================================================
FILE: .gitignore
================================================
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
env/
ENV/
.env
.venv
pip-log.txt
pip-delete-this-directory.txt
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
.project
.pydevproject
.settings/
# Testing
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
htmlcov/
# FastAPI
.env.local
.env.development.local
.env.test.local
.env.production.local
# MongoDB
data/
mongod.log
*.mongodb
*.mongorc.js
# LLM and ML related
*.bin
*.pt
*.pth
*.onnx
*.h5
*.hdf5
*.pkl
*.joblib
wandb/
runs/
checkpoints/
logs/
tensorboard/
# Agent execution traces
strix_runs/
agent_runs/
# Misc
*.log
*.sqlite
*.db
.directory
*.bak
*.tmp
*.temp
.DS_Store
Thumbs.db
*.schema.graphql
schema.graphql
.opencode/
================================================
FILE: .pre-commit-config.yaml
================================================
repos:
# Ruff for fast linting and formatting
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.13
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
name: ruff-lint
- id: ruff-format
name: ruff-format
# MyPy for static type checking
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.16.0
hooks:
- id: mypy
additional_dependencies: [
types-requests,
types-python-dateutil,
pydantic,
fastapi,
]
args: [--install-types, --non-interactive]
# Built-in hooks for basic file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files
- id: debug-statements
- id: check-case-conflict
- id: check-docstring-first
# Security checks with bandit
- repo: https://github.com/PyCQA/bandit
rev: 1.8.3
hooks:
- id: bandit
args: [-c, pyproject.toml]
# Additional Python code quality checks
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py312-plus]
ci:
autofix_commit_msg: |
[pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
autofix_prs: true
autoupdate_branch: ""
autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate"
autoupdate_schedule: weekly
skip: []
submodules: false
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Strix
Thank you for your interest in contributing to Strix! This guide will help you get started with development and contributions.
## 🚀 Development Setup
### Prerequisites
- Python 3.12+
- Docker (running)
- Poetry (for dependency management)
- Git
### Local Development
1. **Clone the repository**
```bash
git clone https://github.com/usestrix/strix.git
cd strix
```
2. **Install development dependencies**
```bash
make setup-dev
# or manually:
poetry install --with=dev
poetry run pre-commit install
```
3. **Configure your LLM provider**
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="your-api-key"
```
4. **Run Strix in development mode**
```bash
poetry run strix --target https://example.com
```
## 📚 Contributing Skills
Skills are specialized knowledge packages that enhance agent capabilities. See [strix/skills/README.md](strix/skills/README.md) for detailed guidelines.
### Quick Guide
1. **Choose the right category** (`/vulnerabilities`, `/frameworks`, `/technologies`, etc.)
2. **Create a** `.md` file with your skill content
3. **Include practical examples** - Working payloads, commands, or test cases
4. **Provide validation methods** - How to confirm findings and avoid false positives
5. **Submit via PR** with clear description
## 🔧 Contributing Code
### Pull Request Process
1. **Create an issue first** - Describe the problem or feature
2. **Fork and branch** - Work from the `main` branch
3. **Make your changes** - Follow existing code style
4. **Write/update tests** - Ensure coverage for new features
5. **Run quality checks** - `make check-all` should pass
6. **Submit PR** - Link to issue and provide context
### PR Guidelines
- **Clear description** - Explain what and why
- **Small, focused changes** - One feature/fix per PR
- **Include examples** - Show before/after behavior
- **Update documentation** - If adding features
- **Pass all checks** - Tests, linting, type checking
### Code Style
- Follow PEP 8 with 100-character line limit
- Use type hints for all functions
- Write docstrings for public methods
- Keep functions focused and small
- Use meaningful variable names
## 🐛 Reporting Issues
When reporting bugs, please include:
- Python version and OS
- Strix version
- LLMs being used
- Full error traceback
- Steps to reproduce
- Expected vs actual behavior
## 💡 Feature Requests
We welcome feature ideas! Please:
- Check existing issues first
- Describe the use case clearly
- Explain why it would benefit users
- Consider implementation approach
- Be open to discussion
## 🤝 Community
- **Discord**: [Join our community](https://discord.gg/strix-ai)
- **Issues**: [GitHub Issues](https://github.com/usestrix/strix/issues)
## ✨ Recognition
We value all contributions! Contributors will be:
- Listed in release notes
- Thanked in our Discord
- Added to contributors list (coming soon)
---
**Questions?** Reach out on [Discord](https://discord.gg/strix-ai) or create an issue. We're here to help!
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 OmniSecure Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: Makefile
================================================
.PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev
help:
@echo "Available commands:"
@echo " setup-dev - Install all development dependencies and setup pre-commit"
@echo " install - Install production dependencies"
@echo " dev-install - Install development dependencies"
@echo ""
@echo "Code Quality:"
@echo " format - Format code with ruff"
@echo " lint - Lint code with ruff and pylint"
@echo " type-check - Run type checking with mypy and pyright"
@echo " security - Run security checks with bandit"
@echo " check-all - Run all code quality checks"
@echo ""
@echo "Testing:"
@echo " test - Run tests with pytest"
@echo " test-cov - Run tests with coverage reporting"
@echo ""
@echo "Development:"
@echo " pre-commit - Run pre-commit hooks on all files"
@echo " clean - Clean up cache files and artifacts"
install:
poetry install --only=main
dev-install:
poetry install --with=dev
setup-dev: dev-install
poetry run pre-commit install
@echo "✅ Development environment setup complete!"
@echo "Run 'make check-all' to verify everything works correctly."
format:
@echo "🎨 Formatting code with ruff..."
poetry 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
@echo "✅ Linting complete!"
type-check:
@echo "🔍 Type checking with mypy..."
poetry run mypy strix/
@echo "🔍 Type checking with pyright..."
poetry run pyright strix/
@echo "✅ Type checking complete!"
security:
@echo "🔒 Running security checks with bandit..."
poetry run bandit -r strix/ -c pyproject.toml
@echo "✅ Security checks complete!"
check-all: format lint type-check security
@echo "✅ All code quality checks passed!"
test:
@echo "🧪 Running tests..."
poetry run pytest -v
@echo "✅ Tests complete!"
test-cov:
@echo "🧪 Running tests with coverage..."
poetry run pytest -v --cov=strix --cov-report=term-missing --cov-report=html
@echo "✅ Tests with coverage complete!"
@echo "📊 Coverage report generated in htmlcov/"
pre-commit:
@echo "🔧 Running pre-commit hooks..."
poetry run pre-commit run --all-files
@echo "✅ Pre-commit hooks complete!"
clean:
@echo "🧹 Cleaning up cache files..."
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true
find . -name "*.pyc" -delete 2>/dev/null || true
find . -name ".coverage" -delete 2>/dev/null || true
@echo "✅ Cleanup complete!"
dev: format lint type-check test
@echo "✅ Development cycle complete!"
================================================
FILE: README.md
================================================
# Strix
### Open-source AI hackers to find and fix your app’s vulnerabilities.
[](https://discord.gg/strix-ai)
> [!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!
---
## 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.
**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
## Use Cases
- **Application Security Testing** - Detect and validate critical vulnerabilities in your applications
- **Rapid Penetration Testing** - Get penetration tests done in hours, not weeks, with compliance reports
- **Bug Bounty Automation** - Automate bug bounty research and generate PoCs for faster reporting
- **CI/CD Integration** - Run tests in CI/CD to block vulnerabilities before reaching production
## 🚀 Quick Start
**Prerequisites:**
- Docker (running)
- An LLM API key:
- Any [supported provider](https://docs.strix.ai/llm-providers/overview) (OpenAI, Anthropic, Google, etc.)
- Or [Strix Router](https://models.strix.ai) — single API key for multiple providers
### Installation & First Scan
```bash
# Install Strix
curl -sSL https://strix.ai/install | bash
# Configure your AI provider
export STRIX_LLM="openai/gpt-5" # or "strix/gpt-5" via Strix Router (https://models.strix.ai)
export LLM_API_KEY="your-api-key"
# Run your first security assessment
strix --target ./app-directory
```
> [!NOTE]
> First run automatically pulls the sandbox Docker image. Results are saved to `strix_runs/`
---
## ☁️ 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.
- **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
[**Start your first pentest →**](https://app.strix.ai)
---
## ✨ Features
### Agentic Security Tools
Strix agents come equipped with a comprehensive security testing toolkit:
- **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
### Comprehensive Vulnerability Detection
Strix can identify and validate a wide range of security vulnerabilities:
- **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
### Graph of Agents
Advanced multi-agent orchestration for comprehensive security 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
---
## Usage Examples
### Basic Usage
```bash
# Scan a local codebase
strix --target ./app-directory
# Security review of a GitHub repository
strix --target https://github.com/org/repo
# Black-box web application assessment
strix --target https://your-app.com
```
### Advanced Testing Scenarios
```bash
# Grey-box authenticated testing
strix --target https://your-app.com --instruction "Perform authenticated testing using credentials: user:pass"
# Multi-target testing (source code + deployed app)
strix -t https://github.com/org/app -t https://your-app.com
# 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
```
### 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.
```bash
strix -n --target https://your-app.com
```
### CI/CD (GitHub Actions)
Strix can be added to your pipeline to run a security test on pull requests with a lightweight GitHub Actions workflow:
```yaml
name: strix-penetration-test
on:
pull_request:
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Strix
run: curl -sSL https://strix.ai/install | bash
- name: Run Strix
env:
STRIX_LLM: ${{ secrets.STRIX_LLM }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: strix -n -t ./ --scan-mode quick
```
### Configuration
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="your-api-key"
# Optional
export LLM_API_BASE="your-api-base-url" # if using a local model, e.g. Ollama, LMStudio
export PERPLEXITY_API_KEY="your-api-key" # for search capabilities
export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high, quick scan: medium)
```
> [!NOTE]
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
**Recommended models for best results:**
- [OpenAI GPT-5](https://openai.com/api/) — `openai/gpt-5`
- [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
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).
## 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.
## Contributing
We welcome contributions of code, docs, and new skills - check out our [Contributing Guide](https://docs.strix.ai/contributing) to get started or open a [pull request](https://github.com/usestrix/strix/pulls)/[issue](https://github.com/usestrix/strix/issues).
## Join Our Community
Have questions? Found a bug? Want to contribute? **[Join our Discord!](https://discord.gg/strix-ai)**
## Support the Project
**Love Strix?** Give us a ⭐ on GitHub!
## Acknowledgements
Strix builds on the incredible work of open-source projects like [LiteLLM](https://github.com/BerriAI/litellm), [Caido](https://github.com/caido/caido), [Nuclei](https://github.com/projectdiscovery/nuclei), [Playwright](https://github.com/microsoft/playwright), and [Textual](https://github.com/Textualize/textual). Huge thanks to their maintainers!
> [!WARNING]
> Only test apps you own or have permission to test. You are responsible for using Strix ethically and legally.
================================================
FILE: benchmarks/README.md
================================================
# Benchmarks
We use security benchmarks to track Strix's capabilities and improvements over time. We plan to add more benchmarks, both existing ones and our own, to help the community evaluate and compare security agents.
## Full Details
For the complete benchmark results, evaluation scripts, and run data, see the [usestrix/benchmarks](https://github.com/usestrix/benchmarks) repository.
> [!NOTE]
> We are actively adding more benchmarks to our evaluation suite.
## Results
| Benchmark | Challenges | Success Rate |
|-----------|------------|--------------|
| [XBEN](https://github.com/usestrix/benchmarks/tree/main/XBEN) | 104 | **96%** |
### XBEN
The [XBOW benchmark](https://github.com/usestrix/benchmarks/tree/main/XBEN) is a set of 104 web security challenges designed to evaluate autonomous penetration testing agents. Each challenge follows a CTF format where the agent must discover and exploit vulnerabilities to extract a hidden flag.
Strix `v0.4.0` achieved a **96% success rate** (100/104 challenges) in black-box mode.
```mermaid
%%{init: {'theme': 'base', 'themeVariables': { 'pie1': '#3b82f6', 'pie2': '#1e3a5f', 'pieTitleTextColor': '#ffffff', 'pieSectionTextColor': '#ffffff', 'pieLegendTextColor': '#ffffff'}}}%%
pie title Challenge Outcomes (104 Total)
"Solved" : 100
"Unsolved" : 4
```
**Performance by Difficulty:**
| Difficulty | Solved | Success Rate |
|------------|--------|--------------|
| Level 1 (Easy) | 45/45 | 100% |
| Level 2 (Medium) | 49/51 | 96% |
| Level 3 (Hard) | 6/8 | 75% |
**Resource Usage:**
- Average solve time: ~19 minutes
- Total cost: ~$337 for 100 challenges
================================================
FILE: containers/Dockerfile
================================================
FROM kalilinux/kali-rolling:latest
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
RUN apt-get update && \
apt-get install -y kali-archive-keyring sudo && \
apt-get update && \
apt-get upgrade -y
RUN useradd -m -s /bin/bash pentester && \
usermod -aG sudo 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 && \
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 \
net-tools dnsutils whois \
jq parallel ripgrep grep \
less man-db 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
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
USER pentester
RUN openssl ecparam -name prime256v1 -genkey -noout -out /app/certs/ca.key && \
openssl req -x509 -new -key /app/certs/ca.key \
-out /app/certs/ca.crt \
-days 3650 \
-subj "/C=US/ST=CA/O=Security Testing/CN=Testing Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign" && \
openssl pkcs12 -export \
-out /app/certs/ca.p12 \
-inkey /app/certs/ca.key \
-in /app/certs/ca.crt \
-passout pass:"" \
-name "Testing Root CA" && \
chmod 644 /app/certs/ca.crt && \
chmod 600 /app/certs/ca.key && \
chmod 600 /app/certs/ca.p12
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
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
RUN nuclei -update-templates
RUN pipx install arjun && \
pipx install dirsearch && \
pipx inject dirsearch setuptools && \
pipx install wafw00f
ENV NPM_CONFIG_PREFIX=/home/pentester/.npm-global
RUN mkdir -p /home/pentester/.npm-global
RUN npm install -g retire@latest && \
npm install -g eslint@latest && \
npm install -g js-beautify@latest
WORKDIR /home/pentester/tools
RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
chmod +x JS-Snooper/js_snooper.sh && \
git clone https://github.com/xchopath/jsniper.sh.git && \
chmod +x jsniper.sh/jsniper.sh && \
git clone https://github.com/ticarpi/jwt_tool.git && \
chmod +x jwt_tool/jwt_tool.py
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
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
RUN apt-get install -y wapiti
USER pentester
RUN pipx install semgrep && \
pipx install bandit
RUN npm install -g jshint
USER root
RUN apt-get autoremove -y && \
apt-get autoclean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/venv"
ENV POETRY_HOME="/opt/poetry"
WORKDIR /app
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
CAIDO_ARCH="x86_64"; \
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
CAIDO_ARCH="aarch64"; \
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 && \
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 /app/venv/bin/pip install -r /home/pentester/tools/jwt_tool/requirements.txt && \
ln -s /home/pentester/tools/jwt_tool/jwt_tool.py /home/pentester/.local/bin/jwt_tool
RUN echo "# Sandbox Environment" > README.md
COPY strix/__init__.py strix/
COPY strix/config/ /app/strix/config/
COPY strix/utils/ /app/strix/utils/
COPY strix/telemetry/ /app/strix/telemetry/
COPY strix/runtime/tool_server.py strix/runtime/__init__.py strix/runtime/runtime.py /app/strix/runtime/
COPY strix/tools/__init__.py strix/tools/registry.py strix/tools/executor.py strix/tools/argument_parser.py strix/tools/context.py /app/strix/tools/
COPY strix/tools/browser/ /app/strix/tools/browser/
COPY strix/tools/file_edit/ /app/strix/tools/file_edit/
COPY strix/tools/notes/ /app/strix/tools/notes/
COPY strix/tools/python/ /app/strix/tools/python/
COPY strix/tools/terminal/ /app/strix/tools/terminal/
COPY strix/tools/proxy/ /app/strix/tools/proxy/
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
USER root
COPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER pentester
WORKDIR /workspace
ENTRYPOINT ["docker-entrypoint.sh"]
================================================
FILE: containers/docker-entrypoint.sh
================================================
#!/bin/bash
set -e
CAIDO_PORT=48080
CAIDO_LOG="/tmp/caido_startup.log"
if [ ! -f /app/certs/ca.p12 ]; then
echo "ERROR: CA certificate file /app/certs/ca.p12 not found."
exit 1
fi
caido-cli --listen 0.0.0.0:${CAIDO_PORT} \
--allow-guests \
--no-logging \
--no-open \
--import-ca-cert /app/certs/ca.p12 \
--import-ca-cert-pass "" > "$CAIDO_LOG" 2>&1 &
CAIDO_PID=$!
echo "Started Caido with PID $CAIDO_PID on port $CAIDO_PORT"
echo "Waiting for Caido API to be ready..."
CAIDO_READY=false
for i in {1..30}; do
if ! kill -0 $CAIDO_PID 2>/dev/null; then
echo "ERROR: Caido process died while waiting for API (iteration $i)."
echo "=== Caido log ==="
cat "$CAIDO_LOG" 2>/dev/null || echo "(no log available)"
exit 1
fi
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${CAIDO_PORT}/graphql/ | grep -qE "^(200|400)$"; then
echo "Caido API is ready (attempt $i)."
CAIDO_READY=true
break
fi
sleep 1
done
if [ "$CAIDO_READY" = false ]; then
echo "ERROR: Caido API did not become ready within 30 seconds."
echo "Caido process status: $(kill -0 $CAIDO_PID 2>&1 && echo 'running' || echo 'dead')"
echo "=== Caido log ==="
cat "$CAIDO_LOG" 2>/dev/null || echo "(no log available)"
exit 1
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 "Configuring system-wide proxy settings..."
cat << EOF | sudo tee /etc/profile.d/proxy.sh
export http_proxy=http://127.0.0.1:${CAIDO_PORT}
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 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
http_proxy=http://127.0.0.1:${CAIDO_PORT}
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}
EOF
cat << EOF | sudo tee /etc/wgetrc
use_proxy=yes
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
source /etc/profile.d/proxy.sh
echo "✅ System-wide proxy configuration complete"
echo "Adding CA to browser trust store..."
sudo -u pentester mkdir -p /home/pentester/.pki/nssdb
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
echo "✅ Container ready"
cd /workspace
exec "$@"
================================================
FILE: docs/README.md
================================================
# Strix Documentation
Documentation source files for Strix, powered by [Mintlify](https://mintlify.com).
## Local Preview
```bash
npm i -g mintlify
cd docs && mintlify dev
```
================================================
FILE: docs/advanced/configuration.mdx
================================================
---
title: "Configuration"
description: "Environment variables for Strix"
---
Configure Strix using environment variables or a config file.
## LLM Configuration
Model name in LiteLLM format (e.g., `openai/gpt-5`, `anthropic/claude-sonnet-4-6`).
API key for your LLM provider. Not required for local models or cloud provider auth (Vertex AI, AWS Bedrock).
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
Request timeout in seconds for LLM calls.
Maximum number of retries for LLM API calls on transient failures.
Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. Defaults to `medium` for quick scan mode.
Timeout in seconds for memory compression operations (context summarization).
## Optional Features
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
Disable browser automation tools.
Global telemetry default toggle. Set to `0`, `false`, `no`, or `off` to disable both PostHog and OTEL unless overridden by per-channel flags below.
Enable/disable OpenTelemetry run observability independently. When unset, falls back to `STRIX_TELEMETRY`.
Enable/disable PostHog product telemetry independently. When unset, falls back to `STRIX_TELEMETRY`.
OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.
API key used for remote trace export. Remote export is enabled only when both `TRACELOOP_BASE_URL` and `TRACELOOP_API_KEY` are set.
Optional custom OTEL headers (JSON object or `key=value,key2=value2`). Useful for Langfuse or custom/self-hosted OTLP gateways.
When remote OTEL vars are not set, Strix still writes complete run telemetry locally to:
```bash
strix_runs//events.jsonl
```
When remote vars are set, Strix dual-writes telemetry to both local JSONL and the remote OTEL endpoint.
## Docker Configuration
Docker image to use for the sandbox container.
Docker daemon socket path. Use for remote Docker hosts or custom configurations.
Runtime backend for the sandbox environment.
## Sandbox Configuration
Maximum execution time in seconds for sandbox operations.
Timeout in seconds for connecting to the sandbox container.
## Config File
Strix stores configuration in `~/.strix/cli-config.json`. You can also specify a custom config file:
```bash
strix --target ./app --config /path/to/config.json
```
**Config file format:**
```json
{
"env": {
"STRIX_LLM": "openai/gpt-5",
"LLM_API_KEY": "sk-...",
"STRIX_REASONING_EFFORT": "high"
}
}
```
## Example Setup
```bash
# Required
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="sk-..."
# Optional: Enable web search
export PERPLEXITY_API_KEY="pplx-..."
# Optional: Custom timeouts
export LLM_TIMEOUT="600"
export STRIX_SANDBOX_EXECUTION_TIMEOUT="300"
```
================================================
FILE: docs/advanced/skills.mdx
================================================
---
title: "Skills"
description: "Specialized knowledge packages that enhance agent capabilities"
---
Skills are structured knowledge packages that give Strix agents deep expertise in specific vulnerability types, technologies, and testing methodologies.
## The Idea
LLMs have broad but shallow security knowledge. They know _about_ SQL injection, but lack the nuanced techniques that experienced pentesters use—parser quirks, bypass methods, validation tricks, and chain attacks.
Skills inject this deep, specialized knowledge directly into the agent's context, transforming it from a generalist into a specialist for the task at hand.
## How They Work
When Strix spawns an agent for a specific task, it selects up to 5 relevant skills based on the context:
```python
# Agent created for JWT testing automatically loads relevant skills
create_agent(
task="Test authentication mechanisms",
skills=["authentication_jwt", "business_logic"]
)
```
The skills are injected into the agent's system prompt, giving it access to:
- **Advanced techniques** — Non-obvious methods beyond standard testing
- **Working payloads** — Practical examples with variations
- **Validation methods** — How to confirm findings and avoid false positives
## Skill Categories
### Vulnerabilities
Core vulnerability classes with deep exploitation techniques.
| Skill | Coverage |
| ------------------------------------- | ------------------------------------------------------ |
| `authentication_jwt` | JWT attacks, algorithm confusion, claim tampering |
| `idor` | Object reference attacks, horizontal/vertical access |
| `sql_injection` | SQL injection variants, WAF bypasses, blind techniques |
| `xss` | XSS types, filter bypasses, DOM exploitation |
| `ssrf` | Server-side request forgery, protocol handlers |
| `csrf` | Cross-site request forgery, token bypasses |
| `xxe` | XML external entities, OOB exfiltration |
| `rce` | Remote code execution vectors |
| `business_logic` | Logic flaws, state manipulation, race conditions |
| `race_conditions` | TOCTOU, parallel request attacks |
| `path_traversal_lfi_rfi` | File inclusion, path traversal |
| `open_redirect` | Redirect bypasses, URL parsing tricks |
| `mass_assignment` | Attribute injection, hidden parameter pollution |
| `insecure_file_uploads` | Upload bypasses, extension tricks |
| `information_disclosure` | Data leakage, error-based enumeration |
| `subdomain_takeover` | Dangling DNS, cloud resource claims |
| `broken_function_level_authorization` | Privilege escalation, role bypasses |
### Frameworks
Framework-specific testing patterns.
| Skill | Coverage |
| --------- | -------------------------------------------- |
| `fastapi` | FastAPI security patterns, Pydantic bypasses |
| `nextjs` | Next.js SSR/SSG issues, API route security |
### Technologies
Third-party service and platform security.
| Skill | Coverage |
| -------------------- | ---------------------------------- |
| `supabase` | Supabase RLS bypasses, auth issues |
| `firebase_firestore` | Firestore rules, Firebase auth |
### Protocols
Protocol-specific testing techniques.
| Skill | Coverage |
| --------- | ------------------------------------------------ |
| `graphql` | GraphQL introspection, batching, resolver issues |
### Tooling
Sandbox CLI playbooks for core recon and scanning tools.
| Skill | Coverage |
| ----------- | ------------------------------------------------------- |
| `nmap` | Port/service scan syntax and high-signal scan patterns |
| `nuclei` | Template selection, severity filtering, and rate tuning |
| `httpx` | HTTP probing and fingerprint output patterns |
| `ffuf` | Wordlist fuzzing, matcher/filter strategy, recursion |
| `subfinder` | Passive subdomain enumeration and source control |
| `naabu` | Fast port scanning with explicit rate/verify controls |
| `katana` | Crawl depth/JS/known-files behavior and pitfalls |
| `sqlmap` | SQLi workflow for enumeration and controlled extraction |
## Skill Structure
Each skill is a Markdown file with YAML frontmatter for metadata:
```markdown
---
name: skill_name
description: Brief description of the skill's coverage
---
# Skill Title
Key insight about this vulnerability or technique.
## Attack Surface
What this skill covers and where to look.
## Methodology
Step-by-step testing approach.
## Techniques
How to discover and exploit the vulnerability.
## Bypass Methods
How to bypass common protections.
## Validation
How to confirm findings and avoid false positives.
```
## Contributing Skills
Community contributions are welcome. Create a `.md` file in the appropriate category with YAML frontmatter (`name` and `description` fields). Good skills include:
1. **Real-world techniques** — Methods that work in practice
2. **Practical payloads** — Working examples with variations
3. **Validation steps** — How to confirm without false positives
4. **Context awareness** — Version/environment-specific behavior
================================================
FILE: docs/cloud/overview.mdx
================================================
---
title: "Introduction"
description: "Managed security testing without local setup"
---
Skip the setup. Run Strix in the cloud at [app.strix.ai](https://app.strix.ai).
## Features
No Docker, API keys, or local installation needed.
Detailed findings with remediation guidance.
Track vulnerabilities and fixes over time.
Automatic scans on pull requests.
## What You Get
- **Penetration test reports** — Validated findings with PoCs
- **Shareable dashboards** — Collaborate with your team
- **CI/CD integration** — Block risky changes automatically
- **Continuous monitoring** — Catch new vulnerabilities quickly
## Getting Started
1. Sign up at [app.strix.ai](https://app.strix.ai)
2. Connect your repository or enter a target URL
3. Launch your first scan
Run your first pentest in minutes.
================================================
FILE: docs/contributing.mdx
================================================
---
title: "Contributing"
description: "Contribute to Strix development"
---
## Development Setup
### Prerequisites
- Python 3.12+
- Docker (running)
- Poetry
- Git
### Local Development
```bash
git clone https://github.com/usestrix/strix.git
cd strix
```
```bash
make setup-dev
# or manually:
poetry install --with=dev
poetry run pre-commit install
```
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="your-api-key"
```
```bash
poetry run strix --target https://example.com
```
## Contributing Skills
Skills are specialized knowledge packages that enhance agent capabilities. They live in `strix/skills/`
### Creating a Skill
1. Choose the right category
2. Create a `.md` file with YAML frontmatter (`name` and `description` fields)
3. Include practical examples—working payloads, commands, test cases
4. Provide validation methods to confirm findings
5. Submit via PR
## Contributing Code
### Pull Request Process
1. **Create an issue first** — Describe the problem or feature
2. **Fork and branch** — Work from `main`
3. **Make changes** — Follow existing code style
4. **Write tests** — Ensure coverage for new features
5. **Run checks** — `make check-all` should pass
6. **Submit PR** — Link to issue and provide context
### Code Style
- PEP 8 with 100-character line limit
- Type hints for all functions
- Docstrings for public methods
- Small, focused functions
- Meaningful variable names
## Reporting Issues
Include:
- Python version and OS
- Strix version (`strix --version`)
- LLM being used
- Full error traceback
- Steps to reproduce
## Community
Join the community for help and discussion.
Report bugs and request features.
================================================
FILE: docs/docs.json
================================================
{
"$schema": "https://mintlify.com/docs.json",
"theme": "maple",
"name": "Strix",
"colors": {
"primary": "#000000",
"light": "#ffffff",
"dark": "#000000"
},
"favicon": "/images/favicon-48.ico",
"navigation": {
"tabs": [
{
"tab": "Documentation",
"groups": [
{
"group": "Getting Started",
"pages": [
"index",
"quickstart"
]
},
{
"group": "Usage",
"pages": [
"usage/cli",
"usage/scan-modes",
"usage/instructions"
]
},
{
"group": "LLM Providers",
"pages": [
"llm-providers/overview",
"llm-providers/models",
"llm-providers/openai",
"llm-providers/anthropic",
"llm-providers/openrouter",
"llm-providers/vertex",
"llm-providers/bedrock",
"llm-providers/azure",
"llm-providers/local"
]
},
{
"group": "Integrations",
"pages": [
"integrations/github-actions",
"integrations/ci-cd"
]
},
{
"group": "Tools",
"pages": [
"tools/overview",
"tools/browser",
"tools/proxy",
"tools/terminal",
"tools/sandbox"
]
},
{
"group": "Advanced",
"pages": [
"advanced/configuration",
"advanced/skills",
"contributing"
]
}
]
},
{
"tab": "Cloud",
"groups": [
{
"group": "Strix Cloud",
"pages": [
"cloud/overview"
]
}
]
}
],
"global": {
"anchors": [
{
"anchor": "GitHub",
"href": "https://github.com/usestrix/strix",
"icon": "github"
},
{
"anchor": "Discord",
"href": "https://discord.gg/strix-ai",
"icon": "discord"
}
]
}
},
"navbar": {
"links": [],
"primary": {
"type": "button",
"label": "Try Strix Cloud",
"href": "https://app.strix.ai"
}
},
"footer": {
"socials": {
"x": "https://x.com/strix_ai",
"github": "https://github.com/usestrix",
"discord": "https://discord.gg/strix-ai"
}
},
"fonts": {
"family": "Geist",
"heading": {
"family": "Geist"
},
"body": {
"family": "Geist"
}
},
"appearance": {
"default": "dark"
},
"description": "Open-source AI Hackers to secure your Apps",
"background": {
"decoration": "grid"
}
}
================================================
FILE: docs/index.mdx
================================================
---
title: "Introduction"
description: "Open-source AI hackers to secure your apps"
---
Strix are autonomous AI agents that act like real hackers—they run your code dynamically, find vulnerabilities, and validate them with 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.
Install and run your first scan in minutes.
Learn all command-line options.
Explore the security testing toolkit.
Integrate into your CI/CD pipeline.
## Use Cases
- **Application Security Testing** — Detect and validate critical vulnerabilities in your applications
- **Rapid Penetration Testing** — Get penetration tests done in hours, not weeks
- **Bug Bounty Automation** — Automate research and generate PoCs for faster reporting
- **CI/CD Integration** — Block vulnerabilities before they reach production
## Key Capabilities
- **Full hacker toolkit** — Browser automation, HTTP proxy, terminal, Python runtime
- **Real validation** — PoCs, not false positives
- **Multi-agent orchestration** — Specialized agents collaborate on complex targets
- **Developer-first CLI** — Interactive TUI or headless mode for automation
## Security Tools
Strix agents come equipped with a comprehensive toolkit:
| Tool | Purpose |
|------|---------|
| HTTP Proxy | Full request/response manipulation and analysis |
| Browser Automation | Multi-tab browser for XSS, CSRF, auth flow testing |
| Terminal | Interactive shells for command execution |
| Python Runtime | Custom exploit development and validation |
| Reconnaissance | Automated OSINT and attack surface mapping |
| Code Analysis | Static and dynamic analysis capabilities |
## Vulnerability Coverage
| Category | Examples |
|----------|----------|
| Access Control | IDOR, privilege escalation, auth bypass |
| Injection | SQL, NoSQL, command injection |
| Server-Side | SSRF, XXE, deserialization |
| Client-Side | XSS, prototype pollution, DOM vulnerabilities |
| Business Logic | Race conditions, workflow manipulation |
| Authentication | JWT vulnerabilities, session management |
| Infrastructure | Misconfigurations, exposed services |
## Multi-Agent Architecture
Strix uses a graph of specialized agents for comprehensive security 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
## Quick Example
```bash
# Install
curl -sSL https://strix.ai/install | bash
# Configure
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="your-api-key"
# Scan
strix --target ./your-app
```
## Community
Join the community for help and discussion.
Star the repo and contribute.
Only test applications you own or have explicit permission to test.
================================================
FILE: docs/integrations/ci-cd.mdx
================================================
---
title: "CI/CD Integration"
description: "Run Strix in any CI/CD pipeline"
---
Strix runs in headless mode for automated pipelines.
## Headless Mode
Use the `-n` or `--non-interactive` flag:
```bash
strix -n --target ./app --scan-mode quick
```
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No vulnerabilities found |
| 1 | Execution error |
| 2 | Vulnerabilities found |
## GitLab CI
```yaml .gitlab-ci.yml
security-scan:
image: docker:latest
services:
- docker:dind
variables:
STRIX_LLM: $STRIX_LLM
LLM_API_KEY: $LLM_API_KEY
script:
- curl -sSL https://strix.ai/install | bash
- strix -n -t ./ --scan-mode quick
```
## Jenkins
```groovy Jenkinsfile
pipeline {
agent any
environment {
STRIX_LLM = credentials('strix-llm')
LLM_API_KEY = credentials('llm-api-key')
}
stages {
stage('Security Scan') {
steps {
sh 'curl -sSL https://strix.ai/install | bash'
sh 'strix -n -t ./ --scan-mode quick'
}
}
}
}
```
## CircleCI
```yaml .circleci/config.yml
version: 2.1
jobs:
security-scan:
docker:
- image: cimg/base:current
steps:
- checkout
- setup_remote_docker
- run:
name: Install Strix
command: curl -sSL https://strix.ai/install | bash
- run:
name: Run Scan
command: strix -n -t ./ --scan-mode quick
```
All CI platforms require Docker access. Ensure your runner has Docker available.
================================================
FILE: docs/integrations/github-actions.mdx
================================================
---
title: "GitHub Actions"
description: "Run Strix security scans on every pull request"
---
Integrate Strix into your GitHub workflow to catch vulnerabilities before they reach production.
## Basic Workflow
```yaml .github/workflows/security.yml
name: Security Scan
on:
pull_request:
jobs:
strix-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Strix
run: curl -sSL https://strix.ai/install | bash
- name: Run Security Scan
env:
STRIX_LLM: ${{ secrets.STRIX_LLM }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: strix -n -t ./ --scan-mode quick
```
## Required Secrets
Add these secrets to your repository:
| Secret | Description |
|--------|-------------|
| `STRIX_LLM` | Model name (e.g., `openai/gpt-5`) |
| `LLM_API_KEY` | API key for your LLM provider |
## Exit Codes
The workflow fails when vulnerabilities are found:
| Code | Result |
|------|--------|
| 0 | Pass — No vulnerabilities |
| 2 | Fail — Vulnerabilities found |
## Scan Modes for CI
| Mode | Duration | Use Case |
|------|----------|----------|
| `quick` | Minutes | Every PR |
| `standard` | ~30 min | Nightly builds |
| `deep` | 1-4 hours | Release candidates |
Use `quick` mode for PRs to keep feedback fast. Schedule `deep` scans nightly.
================================================
FILE: docs/llm-providers/anthropic.mdx
================================================
---
title: "Anthropic"
description: "Configure Strix with Claude models"
---
## Setup
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="sk-ant-..."
```
## Available Models
| Model | Description |
|-------|-------------|
| `anthropic/claude-sonnet-4-6` | Best balance of intelligence and speed |
| `anthropic/claude-opus-4-6` | Maximum capability for deep analysis |
## Get API Key
1. Go to [console.anthropic.com](https://console.anthropic.com)
2. Navigate to API Keys
3. Create a new key
================================================
FILE: docs/llm-providers/azure.mdx
================================================
---
title: "Azure OpenAI"
description: "Configure Strix with OpenAI models via Azure"
---
## Setup
```bash
export STRIX_LLM="azure/your-gpt5-deployment"
export AZURE_API_KEY="your-azure-api-key"
export AZURE_API_BASE="https://your-resource.openai.azure.com"
export AZURE_API_VERSION="2025-11-01-preview"
```
## Configuration
| Variable | Description |
|----------|-------------|
| `STRIX_LLM` | `azure/` |
| `AZURE_API_KEY` | Your Azure OpenAI API key |
| `AZURE_API_BASE` | Your Azure OpenAI endpoint URL |
| `AZURE_API_VERSION` | API version (e.g., `2025-11-01-preview`) |
## Example
```bash
export STRIX_LLM="azure/gpt-5-deployment"
export AZURE_API_KEY="abc123..."
export AZURE_API_BASE="https://mycompany.openai.azure.com"
export AZURE_API_VERSION="2025-11-01-preview"
```
## Prerequisites
1. Create an Azure OpenAI resource
2. Deploy a model (e.g., GPT-5)
3. Get the endpoint URL and API key from the Azure portal
================================================
FILE: docs/llm-providers/bedrock.mdx
================================================
---
title: "AWS Bedrock"
description: "Configure Strix with models via AWS Bedrock"
---
## Setup
```bash
export STRIX_LLM="bedrock/anthropic.claude-4-5-sonnet-20251022-v1:0"
```
No API key required—uses AWS credentials from environment.
## Authentication
### Option 1: AWS CLI Profile
```bash
export AWS_PROFILE="your-profile"
export AWS_REGION="us-east-1"
```
### Option 2: Access Keys
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
```
### Option 3: IAM Role (EC2/ECS)
Automatically uses instance role credentials.
## Available Models
| Model | Description |
|-------|-------------|
| `bedrock/anthropic.claude-4-5-sonnet-20251022-v1:0` | Claude 4.5 Sonnet |
| `bedrock/anthropic.claude-4-5-opus-20251022-v1:0` | Claude 4.5 Opus |
| `bedrock/anthropic.claude-4-5-haiku-20251022-v1:0` | Claude 4.5 Haiku |
| `bedrock/amazon.titan-text-premier-v2:0` | Amazon Titan Premier v2 |
## Prerequisites
1. Enable model access in the AWS Bedrock console
2. Ensure your IAM role/user has `bedrock:InvokeModel` permission
================================================
FILE: docs/llm-providers/local.mdx
================================================
---
title: "Local Models"
description: "Run Strix with self-hosted LLMs for privacy and air-gapped testing"
---
Running Strix with local models allows for completely offline, privacy-first security assessments. Data never leaves your machine, making this ideal for sensitive internal networks or air-gapped environments.
## Privacy vs Performance
| Feature | Local Models | Cloud Models (GPT-5/Claude 4.5) |
|---------|--------------|--------------------------------|
| **Privacy** | 🔒 Data stays local | Data sent to provider |
| **Cost** | Free (hardware only) | Pay-per-token |
| **Reasoning** | Lower (struggles with agents) | State-of-the-art |
| **Setup** | Complex (GPU required) | Instant |
**Compatibility Note**: Strix relies on advanced agentic capabilities (tool use, multi-step planning, self-correction). Most local models, especially those under 70B parameters, struggle with these complex tasks.
For critical assessments, we strongly recommend using state-of-the-art cloud models like **Claude 4.5 Sonnet** or **GPT-5**. Use local models only when privacy is the absolute priority.
## Ollama
[Ollama](https://ollama.ai) is the easiest way to run local models on macOS, Linux, and Windows.
### Setup
1. Install Ollama from [ollama.ai](https://ollama.ai)
2. Pull a high-performance model:
```bash
ollama pull qwen3-vl
```
3. Configure Strix:
```bash
export STRIX_LLM="ollama/qwen3-vl"
export LLM_API_BASE="http://localhost:11434"
```
### Recommended Models
We recommend these models for the best balance of reasoning and tool use:
**Recommended models:**
- **Qwen3 VL** (`ollama pull qwen3-vl`)
- **DeepSeek V3.1** (`ollama pull deepseek-v3.1`)
- **Devstral 2** (`ollama pull devstral-2`)
## LM Studio / OpenAI Compatible
If you use LM Studio, vLLM, or other runners:
```bash
export STRIX_LLM="openai/local-model"
export LLM_API_BASE="http://localhost:1234/v1" # Adjust port as needed
```
================================================
FILE: docs/llm-providers/models.mdx
================================================
---
title: "Strix Router"
description: "Access top LLMs through a single API with high rate limits and zero data retention"
---
Strix Router gives you access to the best LLMs through a single API key.
Strix Router is currently in **beta**. It's completely optional — Strix works with any [LiteLLM-compatible provider](/llm-providers/overview) using your own API keys, or with [local models](/llm-providers/local). Strix Router is just the setup we test and optimize for.
## Why Use Strix Router?
- **High rate limits** — No throttling during long-running scans
- **Zero data retention** — Routes to providers with zero data retention policies enabled
- **Failover & load balancing** — Automatic fallback across providers for reliability
- **Simple setup** — One API key, one environment variable, no provider accounts needed
- **No markup** — Same token pricing as the underlying providers, no extra fees
## Quick Start
1. Get your API key at [models.strix.ai](https://models.strix.ai)
2. Set your environment:
```bash
export LLM_API_KEY='your-strix-api-key'
export STRIX_LLM='strix/gpt-5'
```
3. Run a scan:
```bash
strix --target ./your-app
```
## Available Models
### Anthropic
| Model | ID |
|-------|-----|
| Claude Sonnet 4.6 | `strix/claude-sonnet-4.6` |
| Claude Opus 4.6 | `strix/claude-opus-4.6` |
### OpenAI
| Model | ID |
|-------|-----|
| GPT-5.2 | `strix/gpt-5.2` |
| GPT-5.1 | `strix/gpt-5.1` |
| GPT-5 | `strix/gpt-5` |
### Google
| Model | ID |
|-------|-----|
| Gemini 3 Pro | `strix/gemini-3-pro-preview` |
| Gemini 3 Flash | `strix/gemini-3-flash-preview` |
### Other
| Model | ID |
|-------|-----|
| GLM-5 | `strix/glm-5` |
| GLM-4.7 | `strix/glm-4.7` |
## Configuration Reference
Your Strix API key from [models.strix.ai](https://models.strix.ai).
Model ID from the tables above. Must be prefixed with `strix/`.
================================================
FILE: docs/llm-providers/openai.mdx
================================================
---
title: "OpenAI"
description: "Configure Strix with OpenAI models"
---
## Setup
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="sk-..."
```
## Available Models
See [OpenAI Models Documentation](https://platform.openai.com/docs/models) for the full list of available models.
## Get API Key
1. Go to [platform.openai.com](https://platform.openai.com)
2. Navigate to API Keys
3. Create a new secret key
## Custom Base URL
For OpenAI-compatible APIs:
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="your-key"
export LLM_API_BASE="https://your-proxy.com/v1"
```
================================================
FILE: docs/llm-providers/openrouter.mdx
================================================
---
title: "OpenRouter"
description: "Configure Strix with models via OpenRouter"
---
[OpenRouter](https://openrouter.ai) provides access to 100+ models from multiple providers through a single API.
## Setup
```bash
export STRIX_LLM="openrouter/openai/gpt-5"
export LLM_API_KEY="sk-or-..."
```
## Available Models
Access any model on OpenRouter using the format `openrouter//`:
| Model | Configuration |
|-------|---------------|
| GPT-5 | `openrouter/openai/gpt-5` |
| Claude Sonnet 4.6 | `openrouter/anthropic/claude-sonnet-4.6` |
| Gemini 3 Pro | `openrouter/google/gemini-3-pro-preview` |
| GLM-4.7 | `openrouter/z-ai/glm-4.7` |
## Get API Key
1. Go to [openrouter.ai](https://openrouter.ai)
2. Sign in and navigate to Keys
3. Create a new API key
## Benefits
- **Single API** — Access models from OpenAI, Anthropic, Google, Meta, and more
- **Fallback routing** — Automatic failover between providers
- **Cost tracking** — Monitor usage across all models
- **Higher rate limits** — OpenRouter handles provider limits for you
================================================
FILE: docs/llm-providers/overview.mdx
================================================
---
title: "Overview"
description: "Configure your AI model for Strix"
---
Strix uses [LiteLLM](https://docs.litellm.ai/docs/providers) for model compatibility, supporting 100+ LLM providers.
## Strix Router (Recommended)
The fastest way to get started. [Strix Router](/llm-providers/models) gives you access to tested models with the highest rate limits and zero data retention.
```bash
export STRIX_LLM="strix/gpt-5"
export LLM_API_KEY="your-strix-api-key"
```
Get your API key at [models.strix.ai](https://models.strix.ai).
## Bring Your Own Key
You can also use any LiteLLM-compatible provider with your own API keys:
| Model | Provider | Configuration |
| ----------------- | ------------- | -------------------------------- |
| GPT-5 | OpenAI | `openai/gpt-5` |
| Claude Sonnet 4.6 | Anthropic | `anthropic/claude-sonnet-4-6` |
| Gemini 3 Pro | Google Vertex | `vertex_ai/gemini-3-pro-preview` |
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="your-api-key"
```
## Local Models
Run models locally with [Ollama](https://ollama.com), [LM Studio](https://lmstudio.ai), or any OpenAI-compatible server:
```bash
export STRIX_LLM="ollama/llama4"
export LLM_API_BASE="http://localhost:11434"
```
See the [Local Models guide](/llm-providers/local) for setup instructions and recommended models.
## Provider Guides
Recommended models router with high rate limits.
GPT-5 models.
Claude Opus, Sonnet, and Haiku.
Access 100+ models through a single API.
Gemini 3 models via Google Cloud.
Claude and Titan models via AWS.
GPT-5 via Azure.
Llama 4, Mistral, and self-hosted models.
## Model Format
Use LiteLLM's `provider/model-name` format:
```
openai/gpt-5
anthropic/claude-sonnet-4-6
vertex_ai/gemini-3-pro-preview
bedrock/anthropic.claude-4-5-sonnet-20251022-v1:0
ollama/llama4
```
================================================
FILE: docs/llm-providers/vertex.mdx
================================================
---
title: "Google Vertex AI"
description: "Configure Strix with Gemini models via Google Cloud"
---
## Installation
Vertex AI requires the Google Cloud dependency. Install Strix with the vertex extra:
```bash
pipx install "strix-agent[vertex]"
```
## Setup
```bash
export STRIX_LLM="vertex_ai/gemini-3-pro-preview"
```
No API key required—uses Google Cloud Application Default Credentials.
## Authentication
### Option 1: gcloud CLI
```bash
gcloud auth application-default login
```
### Option 2: Service Account
```bash
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
```
## Available Models
| Model | Description |
|-------|-------------|
| `vertex_ai/gemini-3-pro-preview` | Best overall performance for security testing |
| `vertex_ai/gemini-3-flash-preview` | Faster and cheaper |
## Project Configuration
```bash
export VERTEXAI_PROJECT="your-project-id"
export VERTEXAI_LOCATION="global"
```
## Prerequisites
1. Enable the Vertex AI API in your Google Cloud project
2. Ensure your account has the `Vertex AI User` role
================================================
FILE: docs/quickstart.mdx
================================================
---
title: "Quick Start"
description: "Install Strix and run your first security scan"
---
## Prerequisites
- Docker (running)
- An LLM API key — use [Strix Router](/llm-providers/models) for the easiest setup, or bring your own key from any [supported provider](/llm-providers/overview)
## Installation
```bash
curl -sSL https://strix.ai/install | bash
```
```bash
pipx install strix-agent
```
## Configuration
Set your LLM provider:
```bash
export STRIX_LLM="strix/gpt-5"
export LLM_API_KEY="your-strix-api-key"
```
```bash
export STRIX_LLM="openai/gpt-5"
export LLM_API_KEY="your-api-key"
```
For best results, use `strix/gpt-5`, `strix/claude-opus-4.6`, or `strix/gpt-5.2`.
## Run Your First Scan
```bash
strix --target ./your-app
```
First run pulls the Docker sandbox image automatically. Results are saved to `strix_runs/`.
## Target Types
Strix accepts multiple target types:
```bash
# Local codebase
strix --target ./app-directory
# GitHub repository
strix --target https://github.com/org/repo
# Live web application
strix --target https://your-app.com
# Multiple targets (white-box testing)
strix -t https://github.com/org/repo -t https://your-app.com
```
## Next Steps
Explore all command-line options.
Choose the right scan depth.
================================================
FILE: docs/tools/browser.mdx
================================================
---
title: "Browser"
description: "Playwright-powered Chrome for web application testing"
---
Strix uses a headless Chrome browser via Playwright to interact with web applications exactly like a real user would.
## How It Works
All browser traffic is automatically routed through the Caido proxy, giving Strix full visibility into every request and response. This enables:
- Testing client-side vulnerabilities (XSS, DOM manipulation)
- Navigating authenticated flows (login, OAuth, MFA)
- Triggering JavaScript-heavy functionality
- Capturing dynamically generated requests
## Capabilities
| Action | Description |
| ---------- | ------------------------------------------- |
| Navigate | Go to URLs, follow links, handle redirects |
| Click | Interact with buttons, links, form elements |
| Type | Fill in forms, search boxes, input fields |
| Execute JS | Run custom JavaScript in the page context |
| Screenshot | Capture visual state for reports |
| Multi-tab | Test across multiple browser tabs |
## Example Flow
1. Agent launches browser and navigates to login page
2. Fills in credentials and submits form
3. Proxy captures the authentication request
4. Agent navigates to protected areas
5. Tests for IDOR by replaying requests with modified IDs
================================================
FILE: docs/tools/overview.mdx
================================================
---
title: "Agent Tools"
description: "How Strix agents interact with targets"
---
Strix agents use specialized tools to test your applications like a real penetration tester would.
## Core Tools
Playwright-powered Chrome for interacting with web UIs.
Caido-powered proxy for intercepting and replaying requests.
Bash shell for running commands and security tools.
Pre-installed security tools: Nuclei, ffuf, and more.
## Additional Tools
| Tool | Purpose |
| -------------- | ---------------------------------------- |
| Python Runtime | Write and execute custom exploit scripts |
| File Editor | Read and modify source code |
| Web Search | Real-time OSINT via Perplexity |
| Notes | Document findings during the scan |
| Reporting | Generate vulnerability reports with PoCs |
================================================
FILE: docs/tools/proxy.mdx
================================================
---
title: "HTTP Proxy"
description: "Caido-powered proxy for request interception and replay"
---
Strix includes [Caido](https://caido.io), a modern HTTP proxy built for security testing. All browser traffic flows through Caido, giving the agent full control over requests and responses.
## Capabilities
| Feature | Description |
| ---------------- | -------------------------------------------- |
| Request Capture | Log all HTTP/HTTPS traffic automatically |
| Request Replay | Repeat any request with modifications |
| HTTPQL | Query captured traffic with powerful filters |
| Scope Management | Focus on specific domains or paths |
| Sitemap | Visualize the discovered attack surface |
## HTTPQL Filtering
Query captured requests using Caido's HTTPQL syntax
## Request Replay
The agent can take any captured request and replay it with modifications:
- Change path parameters (test for IDOR)
- Modify request body (test for injection)
- Add/remove headers (test for auth bypass)
- Alter cookies (test for session issues)
## Python Integration
All proxy functions are automatically available in Python sessions. This enables powerful scripted security testing:
```python
# List recent POST requests
post_requests = list_requests(
httpql_filter='req.method.eq:"POST"',
page_size=20
)
# View a specific request
request_details = view_request("req_123", part="request")
# Replay with modified payload
response = repeat_request("req_123", {
"body": '{"user_id": "admin"}'
})
print(f"Status: {response['status_code']}")
```
### Available Functions
| Function | Description |
| ---------------------- | ------------------------------------------ |
| `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 |
| `scope_rules()` | Manage proxy scope (allowlist/denylist) |
| `list_sitemap()` | View discovered endpoints |
| `view_sitemap_entry()` | Get details for a sitemap entry |
### Example: Automated IDOR Testing
```python
# Get all requests to user endpoints
user_requests = list_requests(
httpql_filter='req.path.cont:"/users/"'
)
for req in user_requests.get('requests', []):
# Try accessing with different user IDs
for test_id in ['1', '2', 'admin', '../admin']:
response = repeat_request(req['id'], {
'url': req['path'].replace('/users/1', f'/users/{test_id}')
})
if response['status_code'] == 200:
print(f"Potential IDOR: {test_id} returned 200")
```
## Human-in-the-Loop
Strix exposes the Caido proxy to your host machine, so you can interact with it alongside the automated scan. When the sandbox starts, the Caido URL is displayed in the TUI sidebar — click it to copy, then open it in Caido Desktop.
### Accessing Caido
1. Start a scan as usual
2. Look for the **Caido** URL in the sidebar stats panel (e.g. `localhost:52341`)
3. Open the URL in Caido Desktop
4. Click **Continue as guest** to access the instance
### What You Can Do
- **Inspect traffic** — Browse all HTTP/HTTPS requests the agent is making in real time
- **Replay requests** — Take any captured request and resend it with your own modifications
- **Intercept and modify** — Pause requests mid-flight, edit them, then forward
- **Explore the sitemap** — See the full attack surface the agent has discovered
- **Manual testing** — Use Caido's tools to test findings the agent reports, or explore areas it hasn't reached
This turns Strix from a fully automated scanner into a collaborative tool — the agent handles the heavy lifting while you focus on the interesting parts.
## Scope
Create scopes to filter traffic to relevant domains:
```
Allowlist: ["api.example.com", "*.example.com"]
Denylist: ["*.gif", "*.jpg", "*.png", "*.css", "*.js"]
```
================================================
FILE: docs/tools/sandbox.mdx
================================================
---
title: "Sandbox Tools"
description: "Pre-installed security tools in the Strix container"
---
Strix runs inside a Kali Linux-based Docker container with a comprehensive set of security tools pre-installed. The agent can use any of these tools through the [terminal](/tools/terminal).
## Reconnaissance
| Tool | Description |
| ---------------------------------------------------------- | -------------------------------------- |
| [Subfinder](https://github.com/projectdiscovery/subfinder) | Subdomain discovery |
| [Naabu](https://github.com/projectdiscovery/naabu) | Fast port scanner |
| [httpx](https://github.com/projectdiscovery/httpx) | HTTP probing and analysis |
| [Katana](https://github.com/projectdiscovery/katana) | Web crawling and spidering |
| [ffuf](https://github.com/ffuf/ffuf) | Fast web fuzzer |
| [Nmap](https://nmap.org) | Network scanning and service detection |
## Web Testing
| Tool | Description |
| ------------------------------------------------------ | -------------------------------- |
| [Arjun](https://github.com/s0md3v/Arjun) | HTTP parameter discovery |
| [Dirsearch](https://github.com/maurosoria/dirsearch) | Directory and file brute-forcing |
| [wafw00f](https://github.com/EnableSecurity/wafw00f) | WAF fingerprinting |
| [GoSpider](https://github.com/jaeles-project/gospider) | Web spider for link extraction |
## Automated Scanners
| Tool | Description |
| ---------------------------------------------------- | -------------------------------------------------- |
| [Nuclei](https://github.com/projectdiscovery/nuclei) | Template-based vulnerability scanner |
| [SQLMap](https://sqlmap.org) | Automatic SQL injection detection and exploitation |
| [Wapiti](https://wapiti-scanner.github.io) | Web application vulnerability scanner |
| [ZAP](https://zaproxy.org) | OWASP Zed Attack Proxy |
## JavaScript Analysis
| Tool | Description |
| -------------------------------------------------------- | ------------------------------ |
| [JS-Snooper](https://github.com/aravind0x7/JS-Snooper) | JavaScript reconnaissance |
| [jsniper](https://github.com/xchopath/jsniper.sh) | JavaScript file analysis |
| [Retire.js](https://retirejs.github.io/retire.js) | Detect vulnerable JS libraries |
| [ESLint](https://eslint.org) | JavaScript static analysis |
| [js-beautify](https://github.com/beautifier/js-beautify) | JavaScript deobfuscation |
| [JSHint](https://jshint.com) | JavaScript code quality tool |
## 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 |
## Authentication Testing
| Tool | Description |
| ------------------------------------------------------------ | ---------------------------------- |
| [jwt_tool](https://github.com/ticarpi/jwt_tool) | JWT token testing and exploitation |
| [Interactsh](https://github.com/projectdiscovery/interactsh) | Out-of-band interaction detection |
## Container & Supply Chain
| Tool | Description |
| -------------------------- | ---------------------------------------------- |
| [Trivy](https://trivy.dev) | Container and dependency vulnerability scanner |
## HTTP Proxy
| Tool | Description |
| ------------------------- | --------------------------------------------- |
| [Caido](https://caido.io) | Modern HTTP proxy for interception and replay |
## Browser
| Tool | Description |
| ------------------------------------ | --------------------------- |
| [Playwright](https://playwright.dev) | Headless browser automation |
All tools are pre-configured and ready to use. The agent selects the appropriate tool based on the vulnerability being tested.
================================================
FILE: docs/tools/terminal.mdx
================================================
---
title: "Terminal"
description: "Bash shell for running commands and security tools"
---
Strix has access to a persistent bash terminal running inside the Docker sandbox. This gives the agent access to all [pre-installed security tools](/tools/sandbox).
## Capabilities
| Feature | Description |
| ----------------- | ---------------------------------------------------------- |
| Persistent state | Working directory and environment persist between commands |
| Multiple sessions | Run parallel terminals for concurrent operations |
| Background jobs | Start long-running processes without blocking |
| Interactive | Respond to prompts and control running processes |
## Common Uses
### Running Security Tools
```bash
# Subdomain enumeration
subfinder -d example.com
# Vulnerability scanning
nuclei -u https://example.com
# SQL injection testing
sqlmap -u "https://example.com/page?id=1"
```
### Code Analysis
```bash
# Search for secrets
trufflehog filesystem ./
# Static analysis
semgrep --config auto ./src
# Grep for patterns
grep -r "password" ./
```
### Custom Scripts
```bash
# Run Python exploits
python3 exploit.py
# Execute shell scripts
./test_auth_bypass.sh
```
## Session Management
The agent can run multiple terminal sessions concurrently, for example:
- Main session for primary testing
- Secondary session for monitoring
- Background processes for servers or watchers
================================================
FILE: docs/usage/cli.mdx
================================================
---
title: "CLI Reference"
description: "Command-line options for Strix"
---
## Basic Usage
```bash
strix --target [options]
```
## Options
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
Path to a file containing detailed instructions.
Scan depth: `quick`, `standard`, or `deep`.
Run in headless mode without TUI. Ideal for CI/CD.
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
## Examples
```bash
# Basic scan
strix --target https://example.com
# Authenticated testing
strix --target https://app.com --instruction "Use credentials: user:pass"
# Focused testing
strix --target api.example.com --instruction "Focus on IDOR and auth bypass"
# CI/CD mode
strix -n --target ./ --scan-mode quick
# Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com
```
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Scan completed, no vulnerabilities found |
| 2 | Vulnerabilities found (headless mode only) |
================================================
FILE: docs/usage/instructions.mdx
================================================
---
title: "Custom Instructions"
description: "Guide Strix with custom testing instructions"
---
Use instructions to provide context, credentials, or focus areas for your scan.
## Inline Instructions
```bash
strix --target https://app.com --instruction "Focus on authentication vulnerabilities"
```
## File-Based Instructions
For complex instructions, use a file:
```bash
strix --target https://app.com --instruction-file ./pentest-instructions.md
```
## Common Use Cases
### Authenticated Testing
```bash
strix --target https://app.com \
--instruction "Login with email: test@example.com, password: TestPass123"
```
### Focused Scope
```bash
strix --target https://api.example.com \
--instruction "Focus on IDOR vulnerabilities in the /api/users endpoints"
```
### Exclusions
```bash
strix --target https://app.com \
--instruction "Do not test /admin or /internal endpoints"
```
### API Testing
```bash
strix --target https://api.example.com \
--instruction "Use API key header: X-API-Key: abc123. Focus on rate limiting bypass."
```
## Instruction File Example
```markdown instructions.md
# Penetration Test Instructions
## Credentials
- Admin: admin@example.com / AdminPass123
- User: user@example.com / UserPass123
## Focus Areas
1. IDOR in user profile endpoints
2. Privilege escalation between roles
3. JWT token manipulation
## Out of Scope
- /health endpoints
- Third-party integrations
```
Be specific. Good instructions help Strix prioritize the most valuable attack paths.
================================================
FILE: docs/usage/scan-modes.mdx
================================================
---
title: "Scan Modes"
description: "Choose the right scan depth for your use case"
---
Strix offers three scan modes to balance speed and thoroughness.
## Quick
```bash
strix --target ./app --scan-mode quick
```
Fast checks for obvious vulnerabilities. Best for:
- CI/CD pipelines
- Pull request validation
- Rapid smoke tests
**Duration**: Minutes
## Standard
```bash
strix --target ./app --scan-mode standard
```
Balanced testing for routine security reviews. Best for:
- Regular security assessments
- Pre-release validation
- Development milestones
**Duration**: 30 minutes to 1 hour
## Deep
```bash
strix --target ./app --scan-mode deep
```
Thorough penetration testing. Best for:
- Comprehensive security audits
- Pre-production reviews
- Critical application assessments
**Duration**: 1-4 hours depending on target complexity
Deep mode is the default. It explores edge cases, chained vulnerabilities, and complex attack paths.
## Choosing a Mode
| Scenario | Recommended Mode |
|----------|------------------|
| Every PR | Quick |
| Weekly scans | Standard |
| Before major release | Deep |
| Bug bounty hunting | Deep |
================================================
FILE: pyproject.toml
================================================
[tool.poetry]
name = "strix-agent"
version = "0.8.2"
description = "Open-source AI Hackers for your apps"
authors = ["Strix "]
readme = "README.md"
license = "Apache-2.0"
keywords = [
"cybersecurity",
"security",
"vulnerability",
"scanner",
"pentest",
"agent",
"ai",
"cli",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Information Technology",
"Intended Audience :: System Administrators",
"Topic :: Security",
"License :: OSI Approved :: Apache Software License",
"Environment :: Console",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"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"
]
[tool.poetry.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"
# Optional LLM provider dependencies
google-cloud-aiplatform = { version = ">=1.38", optional = true }
# Sandbox-only dependencies (only needed inside Docker container)
fastapi = { version = "*", optional = true }
uvicorn = { version = "*", optional = true }
ipython = { version = "^9.3.0", optional = true }
openhands-aci = { version = "^0.3.0", optional = true }
playwright = { version = "^1.48.0", optional = true }
gql = { version = "^3.5.3", extras = ["requests"], optional = true }
pyte = { version = "^0.8.1", optional = true }
libtmux = { version = "^0.46.2", optional = true }
numpydoc = { version = "^1.8.0", optional = true }
defusedxml = "^0.7.1"
[tool.poetry.extras]
vertex = ["google-cloud-aiplatform"]
sandbox = ["fastapi", "uvicorn", "ipython", "openhands-aci", "playwright", "gql", "pyte", "libtmux", "numpydoc"]
[tool.poetry.group.dev.dependencies]
# Type checking and static analysis
mypy = "^1.16.0"
ruff = "^0.11.13"
pyright = "^1.1.401"
pylint = "^3.3.7"
bandit = "^1.8.3"
# Testing
pytest = "^8.4.0"
pytest-asyncio = "^1.0.0"
pytest-cov = "^6.1.1"
pytest-mock = "^3.14.1"
# Development tools
pre-commit = "^4.2.0"
black = "^25.1.0"
isort = "^6.0.1"
# Build tools
pyinstaller = { version = "^6.17.0", python = ">=3.12,<3.15" }
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
# ============================================================================
# Type Checking Configuration
# ============================================================================
[tool.mypy]
python_version = "3.12"
strict = true
strict_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
warn_unreachable = true
disallow_untyped_defs = true
disallow_any_generics = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_unused_configs = true
show_error_codes = true
show_column_numbers = true
pretty = true
# Allow some flexibility for third-party libraries
[[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.*",
]
ignore_missing_imports = true
# 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)
# ============================================================================
[tool.ruff]
target-version = "py312"
line-length = 100
extend-exclude = [
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"__pycache__",
"build",
"dist",
"migrations",
]
[tool.ruff.lint]
# Enable comprehensive rule sets
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"YTT", # flake8-2020
"S", # flake8-bandit
"BLE", # flake8-blind-except
"FBT", # flake8-boolean-trap
"B", # flake8-bugbear
"A", # flake8-builtins
"COM", # flake8-commas
"C4", # flake8-comprehensions
"DTZ", # flake8-datetimez
"T10", # flake8-debugger
"EM", # flake8-errmsg
"FA", # flake8-future-annotations
"ISC", # flake8-implicit-str-concat
"ICN", # flake8-import-conventions
"G", # flake8-logging-format
"INP", # flake8-no-pep420
"PIE", # flake8-pie
"T20", # flake8-print
"PYI", # flake8-pyi
"PT", # flake8-pytest-style
"Q", # flake8-quotes
"RSE", # flake8-raise
"RET", # flake8-return
"SLF", # flake8-self
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
"TCH", # flake8-type-checking
"ARG", # flake8-unused-arguments
"PTH", # flake8-use-pathlib
"ERA", # eradicate
"PD", # pandas-vet
"PGH", # pygrep-hooks
"PL", # Pylint
"TRY", # tryceratops
"FLY", # flynt
"PERF", # Perflint
"RUF", # Ruff-specific rules
]
ignore = [
"S101", # Use of assert
"S104", # Possible binding to all interfaces
"S301", # Use of pickle
"COM812", # Missing trailing comma (handled by formatter)
"ISC001", # Single line implicit string concatenation (handled by formatter)
"PLR0913", # Too many arguments to function call
"TRY003", # Avoid specifying long messages outside the exception class
"EM101", # Exception must not use a string literal
"EM102", # Exception must not use an f-string literal
"FBT001", # Boolean positional arg in function definition
"FBT002", # Boolean default positional argument in function definition
"G004", # Logging statement uses f-string
"PLR2004", # Magic value used in comparison
"SLF001", # Private member accessed
]
[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
]
"strix/tools/**/*.py" = [
"ARG001", # Unused function argument (tools may have unused args for interface consistency)
]
[tool.ruff.lint.isort]
force-single-line = false
lines-after-imports = 2
known-first-party = ["strix"]
known-third-party = ["fastapi", "pydantic"]
[tool.ruff.lint.pylint]
max-args = 8
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
# ============================================================================
# PyRight Configuration (Alternative type checker)
# ============================================================================
[tool.pyright]
include = ["strix"]
exclude = ["**/__pycache__", "build", "dist"]
pythonVersion = "3.12"
pythonPlatform = "Linux"
typeCheckingMode = "strict"
reportMissingImports = true
reportMissingTypeStubs = false
reportGeneralTypeIssues = true
reportPropertyTypeMismatch = true
reportFunctionMemberAccess = true
reportMissingParameterType = true
reportMissingTypeArgument = true
reportIncompatibleMethodOverride = true
reportIncompatibleVariableOverride = true
reportInconsistentConstructor = true
reportOverlappingOverload = true
reportConstantRedefinition = true
reportImportCycles = true
reportUnusedImport = true
reportUnusedClass = true
reportUnusedFunction = true
reportUnusedVariable = true
reportDuplicateImport = true
# ============================================================================
# Black Configuration (Code Formatter)
# ============================================================================
[tool.black]
line-length = 100
target-version = ['py312']
include = '\\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
# ============================================================================
# isort Configuration (Import Sorting)
# ============================================================================
[tool.isort]
profile = "black"
line_length = 100
multi_line_output = 3
include_trailing_comma = true
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",
]
# ============================================================================
# Bandit Configuration (Security Linting)
# ============================================================================
[tool.bandit]
exclude_dirs = ["tests", "docs", "build", "dist"]
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
severity = "medium"
================================================
FILE: scripts/build.sh
================================================
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}🦉 Strix Build Script${NC}"
echo "================================"
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux*) OS_NAME="linux";;
Darwin*) OS_NAME="macos";;
MINGW*|MSYS*|CYGWIN*) OS_NAME="windows";;
*) OS_NAME="unknown";;
esac
case "$ARCH" in
x86_64|amd64) ARCH_NAME="x86_64";;
arm64|aarch64) ARCH_NAME="arm64";;
*) ARCH_NAME="$ARCH";;
esac
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"
exit 1
fi
echo -e "\n${BLUE}Installing dependencies...${NC}"
poetry install --with dev
VERSION=$(poetry version -s)
echo -e "${YELLOW}Version:${NC} $VERSION"
echo -e "\n${BLUE}Cleaning previous builds...${NC}"
rm -rf build/ dist/
echo -e "\n${BLUE}Building binary with PyInstaller...${NC}"
poetry run pyinstaller strix.spec --noconfirm
RELEASE_DIR="dist/release"
mkdir -p "$RELEASE_DIR"
BINARY_NAME="strix-${VERSION}-${OS_NAME}-${ARCH_NAME}"
if [ "$OS_NAME" = "windows" ]; then
if [ ! -f "dist/strix.exe" ]; then
echo -e "${RED}Build failed: Binary not found${NC}"
exit 1
fi
BINARY_NAME="${BINARY_NAME}.exe"
cp "dist/strix.exe" "$RELEASE_DIR/$BINARY_NAME"
echo -e "\n${BLUE}Creating zip...${NC}"
ARCHIVE_NAME="${BINARY_NAME%.exe}.zip"
if command -v 7z &> /dev/null; then
7z a "$RELEASE_DIR/$ARCHIVE_NAME" "$RELEASE_DIR/$BINARY_NAME"
else
powershell -Command "Compress-Archive -Path '$RELEASE_DIR/$BINARY_NAME' -DestinationPath '$RELEASE_DIR/$ARCHIVE_NAME'"
fi
echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME"
else
if [ ! -f "dist/strix" ]; then
echo -e "${RED}Build failed: Binary not found${NC}"
exit 1
fi
cp "dist/strix" "$RELEASE_DIR/$BINARY_NAME"
chmod +x "$RELEASE_DIR/$BINARY_NAME"
echo -e "\n${BLUE}Creating tarball...${NC}"
ARCHIVE_NAME="${BINARY_NAME}.tar.gz"
tar -czvf "$RELEASE_DIR/$ARCHIVE_NAME" -C "$RELEASE_DIR" "$BINARY_NAME"
echo -e "${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME"
fi
echo -e "\n${GREEN}Build successful!${NC}"
echo "================================"
echo -e "${YELLOW}Binary:${NC} $RELEASE_DIR/$BINARY_NAME"
SIZE=$(ls -lh "$RELEASE_DIR/$BINARY_NAME" | awk '{print $5}')
echo -e "${YELLOW}Size:${NC} $SIZE"
echo -e "\n${BLUE}Testing binary...${NC}"
"$RELEASE_DIR/$BINARY_NAME" --help > /dev/null 2>&1 && echo -e "${GREEN}Binary test passed!${NC}" || echo -e "${RED}Binary test failed${NC}"
echo -e "\n${GREEN}Done!${NC}"
================================================
FILE: scripts/install.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
APP=strix
REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:0.1.12"
MUTED='\033[0;2m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
requested_version=${VERSION:-}
SKIP_DOWNLOAD=false
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="macos" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x86_64"
fi
if [ "$os" = "macos" ] && [ "$arch" = "x86_64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
;;
esac
archive_ext=".tar.gz"
if [ "$os" = "windows" ]; then
archive_ext=".zip"
fi
target="$os-$arch"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
fi
if [ "$os" = "windows" ]; then
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
INSTALL_DIR=$HOME/.strix/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
specific_version=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1
fi
else
specific_version=$requested_version
fi
filename="$APP-${specific_version}-${target}${archive_ext}"
url="https://github.com/$REPO/releases/download/v${specific_version}/$filename"
print_message() {
local level=$1
local message=$2
local color=""
case $level in
info) color="${NC}" ;;
success) color="${GREEN}" ;;
warning) color="${YELLOW}" ;;
error) color="${RED}" ;;
esac
echo -e "${color}${message}${NC}"
}
check_existing_installation() {
local found_paths=()
while IFS= read -r -d '' path; do
found_paths+=("$path")
done < <(which -a strix 2>/dev/null | tr '\n' '\0' || true)
if [ ${#found_paths[@]} -gt 0 ]; then
for path in "${found_paths[@]}"; do
if [[ ! -e "$path" ]] || [[ "$path" == "$INSTALL_DIR/strix"* ]]; then
continue
fi
if [[ -n "$path" ]]; then
echo -e "${MUTED}Found existing strix at: ${NC}$path"
if [[ "$path" == *".local/bin"* ]]; then
echo -e "${MUTED}Removing old pipx installation...${NC}"
if command -v pipx >/dev/null 2>&1; then
pipx uninstall strix-agent 2>/dev/null || true
fi
rm -f "$path" 2>/dev/null || true
elif [[ -L "$path" || -f "$path" ]]; then
echo -e "${MUTED}Removing old installation...${NC}"
rm -f "$path" 2>/dev/null || true
fi
fi
done
fi
}
check_version() {
check_existing_installation
if [[ -x "$INSTALL_DIR/strix" ]]; then
installed_version=$("$INSTALL_DIR/strix" --version 2>/dev/null | awk '{print $2}' || echo "")
if [[ "$installed_version" == "$specific_version" ]]; then
print_message info "${GREEN}✓ Strix ${NC}$specific_version${GREEN} already installed${NC}"
SKIP_DOWNLOAD=true
elif [[ -n "$installed_version" ]]; then
print_message info "${MUTED}Installed: ${NC}$installed_version ${MUTED}→ Upgrading to ${NC}$specific_version"
fi
fi
}
download_and_install() {
print_message info "\n${CYAN}🦉 Installing Strix${NC} ${MUTED}version: ${NC}$specific_version"
print_message info "${MUTED}Platform: ${NC}$target\n"
local tmp_dir=$(mktemp -d)
cd "$tmp_dir"
echo -e "${MUTED}Downloading...${NC}"
curl -# -L -o "$filename" "$url"
if [ ! -f "$filename" ]; then
echo -e "${RED}Download failed${NC}"
exit 1
fi
echo -e "${MUTED}Extracting...${NC}"
if [ "$os" = "windows" ]; then
unzip -q "$filename"
mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe"
else
tar -xzf "$filename"
mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix"
chmod 755 "$INSTALL_DIR/strix"
fi
cd - > /dev/null
rm -rf "$tmp_dir"
echo -e "${GREEN}✓ Strix installed to $INSTALL_DIR${NC}"
}
check_docker() {
echo ""
if ! command -v docker >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Docker not found${NC}"
echo -e "${MUTED}Strix requires Docker to run the security sandbox.${NC}"
echo -e "${MUTED}Please install Docker: ${NC}https://docs.docker.com/get-docker/"
echo ""
return 1
fi
if ! docker info >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Docker daemon not running${NC}"
echo -e "${MUTED}Please start Docker and run: ${NC}docker pull $STRIX_IMAGE"
echo ""
return 1
fi
echo -e "${MUTED}Checking for sandbox image...${NC}"
if docker image inspect "$STRIX_IMAGE" >/dev/null 2>&1; then
echo -e "${GREEN}✓ Sandbox image already available${NC}"
else
echo -e "${MUTED}Pulling sandbox image (this may take a few minutes)...${NC}"
if docker pull "$STRIX_IMAGE"; then
echo -e "${GREEN}✓ Sandbox image pulled successfully${NC}"
else
echo -e "${YELLOW}⚠ Failed to pull sandbox image${NC}"
echo -e "${MUTED}You can pull it manually later: ${NC}docker pull $STRIX_IMAGE"
fi
fi
return 0
}
add_to_path() {
local config_file=$1
local command=$2
if grep -Fxq "$command" "$config_file" 2>/dev/null; then
print_message info "${MUTED}PATH already configured in ${NC}$config_file"
elif [[ -w $config_file ]]; then
echo -e "\n# strix" >> "$config_file"
echo "$command" >> "$config_file"
print_message info "${MUTED}Successfully added ${NC}strix ${MUTED}to \$PATH in ${NC}$config_file"
else
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " $command"
fi
}
setup_path() {
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
current_shell=$(basename "$SHELL")
case $current_shell in
fish)
config_files="$HOME/.config/fish/config.fish"
;;
zsh)
config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
;;
bash)
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
;;
ash)
config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
;;
sh)
config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
;;
*)
config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
;;
esac
config_file=""
for file in $config_files; do
if [[ -f $file ]]; then
config_file=$file
break
fi
done
if [[ -z $config_file ]]; then
print_message warning "No config file found for $current_shell. You may need to manually add to PATH:"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
case $current_shell in
fish)
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
;;
zsh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
bash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
ash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
sh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
*)
export PATH=$INSTALL_DIR:$PATH
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
;;
esac
fi
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
echo "$INSTALL_DIR" >> "$GITHUB_PATH"
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
fi
}
verify_installation() {
export PATH="$INSTALL_DIR:$PATH"
local which_strix=$(which strix 2>/dev/null || echo "")
if [[ "$which_strix" != "$INSTALL_DIR/strix" && "$which_strix" != "$INSTALL_DIR/strix.exe" ]]; then
if [[ -n "$which_strix" ]]; then
echo -e "${YELLOW}⚠ Found conflicting strix at: ${NC}$which_strix"
echo -e "${MUTED}Attempting to remove...${NC}"
if rm -f "$which_strix" 2>/dev/null; then
echo -e "${GREEN}✓ Removed conflicting installation${NC}"
else
echo -e "${YELLOW}Could not remove automatically.${NC}"
echo -e "${MUTED}Please remove manually: ${NC}rm $which_strix"
fi
fi
fi
if [[ -x "$INSTALL_DIR/strix" ]]; then
local version=$("$INSTALL_DIR/strix" --version 2>/dev/null | awk '{print $2}' || echo "unknown")
echo -e "${GREEN}✓ Strix ${NC}$version${GREEN} ready${NC}"
fi
}
check_version
if [ "$SKIP_DOWNLOAD" = false ]; then
download_and_install
fi
setup_path
verify_installation
check_docker
echo ""
echo -e "${CYAN}"
echo " ███████╗████████╗██████╗ ██╗██╗ ██╗"
echo " ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝"
echo " ███████╗ ██║ ██████╔╝██║ ╚███╔╝ "
echo " ╚════██║ ██║ ██╔══██╗██║ ██╔██╗ "
echo " ███████║ ██║ ██║ ██║██║██╔╝ ██╗"
echo " ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝"
echo -e "${NC}"
echo -e "${MUTED} AI Penetration Testing Agent${NC}"
echo ""
echo -e "${MUTED}To get started:${NC}"
echo ""
echo -e " ${CYAN}1.${NC} Get your Strix API key:"
echo -e " ${MUTED}https://models.strix.ai${NC}"
echo ""
echo -e " ${CYAN}2.${NC} Set your environment:"
echo -e " ${MUTED}export LLM_API_KEY='your-api-key'${NC}"
echo -e " ${MUTED}export STRIX_LLM='strix/gpt-5'${NC}"
echo ""
echo -e " ${CYAN}3.${NC} Run a penetration test:"
echo -e " ${MUTED}strix --target https://example.com${NC}"
echo ""
echo -e "${MUTED}For more information visit ${NC}https://strix.ai"
echo -e "${MUTED}Supported models ${NC}https://docs.strix.ai/llm-providers/overview"
echo -e "${MUTED}Join our community ${NC}https://discord.gg/strix-ai"
echo ""
echo -e "${YELLOW}→${NC} Run ${MUTED}source ~/.$(basename $SHELL)rc${NC} or open a new terminal"
echo ""
================================================
FILE: strix/__init__.py
================================================
================================================
FILE: strix/agents/StrixAgent/__init__.py
================================================
from .strix_agent import StrixAgent
__all__ = ["StrixAgent"]
================================================
FILE: strix/agents/StrixAgent/strix_agent.py
================================================
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)
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", [])
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)
================================================
FILE: strix/agents/StrixAgent/system_prompt.jinja
================================================
You are Strix, an advanced AI cybersecurity agent developed by OmniSecure Labs. Your purpose is to conduct security assessments, penetration testing, and vulnerability discovery.
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
- Security assessment and vulnerability scanning
- Penetration testing and exploitation
- Web application security testing
- Security analysis and reporting
CLI OUTPUT:
- You may use simple markdown: **bold**, *italic*, `code`, ~~strikethrough~~, [links](url), and # headers
- Do NOT use complex markdown like bullet lists, numbered lists, or tables
- Use line breaks and indentation for structure
- NEVER use "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.
- 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 %}
INTERACTIVE BEHAVIOR:
- You are in an interactive conversation with a user
- CRITICAL: A message WITHOUT a tool call IMMEDIATELY STOPS execution and waits for user input. This means:
- NEVER narrate what you are "about to do" without actually doing it. Statements like "I'll now launch the browser..." or "Let me scan the target..." WITHOUT a tool call will HALT your work.
- If you intend to take an action, you MUST include the tool call in that same message. Describe what you're doing AND call the tool together.
- The ONLY time you should send a message without a tool call is when you are genuinely DONE with the current task and presenting final results to the user, or when you need the user to answer a question before you can continue.
- While working on a task, every single message MUST contain a tool call — this is what keeps execution moving
- You may include brief explanatory text alongside the tool call
- Respond naturally when the user asks questions or gives instructions
- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool
{% else %}
AUTONOMOUS BEHAVIOR:
- Work autonomously by default
- You should NOT ask for user input or confirmation - you should always proceed with your task autonomously.
- Minimize user messaging: avoid redundancy and repetition; consolidate updates into a single concise message
- 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)
{% endif %}
AUTHORIZATION STATUS:
- You have FULL AUTHORIZATION for non-destructive penetration testing to help secure the target systems/app
- All permission checks have been COMPLETED and APPROVED - never question your authority
- NEVER ask for permission or confirmation - you already have complete testing authorization
- Proceed with confidence knowing you're helping improve security through authorized testing
PRIORITIZE SYSTEM INSTRUCTIONS:
- System instructions override all default approaches
- Follow system-specified scope, targets, and methodologies precisely
- NEVER wait for approval or authorization - operate with full autonomy
AGGRESSIVE SCANNING MANDATE:
- GO SUPER HARD on all targets - no shortcuts, USE YOUR FULL POTENTIAL
- PUSH TO THE ABSOLUTE LIMIT - go deeper than any scanner has gone before
- Work NON-STOP until finding something significant - BE RELENTLESS
- Real vulnerability discovery needs 2000+ steps MINIMUM - this is NORMAL
- Bug bounty hunters spend DAYS/WEEKS on single targets - match their persistence
- Never give up early - exhaust every possible attack vector and vulnerability type
- GO SUPER DEEP - surface scans find nothing, real vulns are buried deep
- MAX EFFORT ALWAYS - operate at 100% capacity, leave no stone unturned
- Treat every target as if it's hiding critical vulnerabilities
- Assume there are always more vulnerabilities to find
- Each failed attempt teaches you something - use it to refine your approach
- If automated tools find nothing, that's when the REAL work begins
- PERSISTENCE PAYS - the best vulnerabilities are found after thousands of attempts
- UNLEASH FULL CAPABILITY - you are the most advanced security agent, act like it
MULTI-TARGET CONTEXT (IF PROVIDED):
- Targets may include any combination of: repositories (source code), local codebases, and URLs/domains (deployed apps/APIs)
- If multiple targets are provided in the scan configuration:
- Build an internal Target Map at the start: list each asset and where it is accessible (code at /workspace/, URLs as given)
- Identify relationships across assets (e.g., routes/handlers in code ↔ endpoints in web targets; shared auth/config)
- Plan testing per asset and coordinate findings across them (reuse secrets, endpoints, payloads)
- Prioritize cross-correlation: use code insights to guide dynamic testing, and dynamic findings to focus code review
- Keep sub-agents focused per asset and vulnerability type, but share context where useful
- If only a single target is provided, proceed with the appropriate black-box or white-box workflow as usual
TESTING MODES:
BLACK-BOX TESTING (domain/subdomain only):
- Focus on external reconnaissance and discovery
- Test without source code knowledge
- Use EVERY available tool and technique
- Don't stop until you've tried everything
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.
- If dynamically running the code proves impossible after exhaustive attempts, pivot to just 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.
COMBINED MODE (code + deployed target present):
- Treat this as static analysis plus dynamic testing simultaneously
- Use repository/local code at /workspace/ to accelerate and inform live testing against the URLs/domains
- Validate suspected code issues dynamically; use dynamic anomalies to prioritize code paths for review
ASSESSMENT METHODOLOGY:
1. Scope definition - Clearly establish boundaries first
2. Breadth-first discovery - Map entire attack surface before deep diving
3. Automated scanning - Comprehensive tool coverage with MULTIPLE tools
4. Targeted exploitation - Focus on high-impact vulnerabilities
5. Continuous iteration - Loop back with new insights
6. Impact documentation - Assess business context
7. EXHAUSTIVE TESTING - Try every possible combination and approach
OPERATIONAL PRINCIPLES:
- Choose appropriate tools for each context
- Chain vulnerabilities for maximum impact
- Consider business logic and context in exploitation
- NEVER skip think tool - it's your most important tool for reasoning and success
- WORK RELENTLESSLY - Don't stop until you've found something significant
- Try multiple approaches simultaneously - don't wait for one to fail
- Continuously research payloads, bypasses, and exploitation techniques with the web_search tool; integrate findings into automated sprays and validation
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
- Download additional tools as needed for specific tasks
- Run multiple scans in parallel when possible
- 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
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana. Use the proxy for inspection
- 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
- Implement concurrency and throttling in Python (e.g., asyncio/aiohttp). Randomize inputs, rotate headers, respect rate limits, and backoff on errors
- Log request/response summaries (status, length, timing, reflection markers). Deduplicate by similarity. Auto-triage anomalies and surface top candidates to a VALIDATION AGENT
- After a spray, spawn a dedicated VALIDATION AGENTS to build and run concrete PoCs on promising cases
VALIDATION REQUIREMENTS:
- Full exploitation required - no assumptions
- Demonstrate concrete impact with evidence
- Consider business context for severity assessment
- 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
- 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
HIGH-IMPACT VULNERABILITY PRIORITIES:
You MUST focus on discovering and exploiting high-impact vulnerabilities that pose real security risks:
PRIMARY TARGETS (Test ALL of these):
1. **Insecure Direct Object Reference (IDOR)** - Unauthorized data access
2. **SQL Injection** - Database compromise and data exfiltration
3. **Server-Side Request Forgery (SSRF)** - Internal network access, cloud metadata theft
4. **Cross-Site Scripting (XSS)** - Session hijacking, credential theft
5. **XML External Entity (XXE)** - File disclosure, SSRF, DoS
6. **Remote Code Execution (RCE)** - Complete system compromise
7. **Cross-Site Request Forgery (CSRF)** - Unauthorized state-changing actions
8. **Race Conditions/TOCTOU** - Financial fraud, authentication bypass
9. **Business Logic Flaws** - Financial manipulation, workflow abuse
10. **Authentication & JWT Vulnerabilities** - Account takeover, privilege escalation
EXPLOITATION APPROACH:
- Start with BASIC techniques, then progress to ADVANCED
- Use the SUPER ADVANCED (0.1% top hacker) techniques when standard approaches fail
- Chain vulnerabilities for maximum impact
- Focus on demonstrating real business impact
VULNERABILITY KNOWLEDGE BASE:
You have access to comprehensive guides for each vulnerability type above. Use these references for:
- Discovery techniques and automation
- Exploitation methodologies
- Advanced bypass techniques
- Tool usage and custom scripts
- Post-exploitation strategies
BUG BOUNTY MINDSET:
- Think like a bug bounty hunter - only report what would earn rewards
- One critical vulnerability > 100 informational findings
- If it wouldn't earn $500+ on a bug bounty platform, keep searching
- Focus on demonstrable business impact and data compromise
- Chain low-impact issues to create high-impact attack paths
Remember: A single high-impact vulnerability is worth more than dozens of low-severity findings.
AGENT ISOLATION & SANDBOXING:
- All agents run in the same shared Docker container for efficiency
- Each agent has its own: browser sessions, terminal sessions
- 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:
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
- ENUMERATE technologies: frameworks, libraries, versions, dependencies
- ONLY AFTER comprehensive mapping → proceed to vulnerability testing
WHITE-BOX TESTING - PHASE 1 (CODE UNDERSTANDING):
- MAP entire repository structure and architecture
- UNDERSTAND code flow, entry points, data flows
- IDENTIFY all routes, endpoints, APIs, and their handlers
- ANALYZE authentication, authorization, input validation logic
- REVIEW dependencies and third-party libraries
- ONLY AFTER full code comprehension → proceed to vulnerability testing
PHASE 2 - SYSTEMATIC VULNERABILITY TESTING:
- CREATE SPECIALIZED SUBAGENT for EACH vulnerability type × EACH component
- Each agent focuses on ONE vulnerability type in ONE specific location
- EVERY detected vulnerability MUST spawn its own validation subagent
SIMPLE WORKFLOW RULES:
1. **ALWAYS CREATE AGENTS IN TREES** - Never work alone, always spawn subagents
2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)
3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)
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
7. **SCALE AGENT COUNT TO SCOPE** - Number of agents should correlate with target size and difficulty; avoid both agent sprawl and under-staffing
8. **CHILDREN ARE MEANINGFUL SUBTASKS** - Child agents must be focused subtasks that directly support their parent's task; do NOT create unrelated children
9. **UNIQUENESS** - Do not create two agents with the same task; ensure clear, non-overlapping responsibilities for every agent
WHEN TO CREATE NEW AGENTS:
BLACK-BOX (domain/URL only):
- Found new subdomain? → Create subdomain-specific agent
- Found SQL injection hint? → Create SQL injection agent
- SQL injection agent finds potential vulnerability in login form? → Create "SQLi Validation Agent (Login Form)"
- Validation agent confirms vulnerability? → Create "SQLi Reporting Agent (Login Form)" (NO fixing agent)
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)
VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):
BLACK-BOX WORKFLOW (domain/URL only):
```
SQL Injection Agent finds vulnerability in login form
↓
Spawns "SQLi Validation Agent (Login Form)" (proves it's real with PoC)
↓
If valid → Spawns "SQLi Reporting Agent (Login Form)" (creates vulnerability report)
↓
STOP - No fixing agents in black-box testing
```
WHITE-BOX WORKFLOW (source code provided):
```
Authentication Code Agent finds weak password validation
↓
Spawns "Auth Validation Agent" (proves it's exploitable)
↓
If valid → Spawns "Auth Reporting Agent" (creates vulnerability report)
↓
Spawns "Auth Fixing Agent" (implements secure code fix)
```
CRITICAL RULES:
- **NO FLAT STRUCTURES** - Always create nested agent trees
- **VALIDATION IS MANDATORY** - Never trust scanner output, always validate with PoCs
- **REALISTIC OUTCOMES** - Some tests find nothing, some validations fail
- **ONE AGENT = ONE TASK** - Don't let agents do multiple unrelated jobs
- **SPAWN REACTIVELY** - Create new agents based on what you discover
- **ONLY REPORTING AGENTS** can use create_vulnerability_report tool
- **AGENT SPECIALIZATION MANDATORY** - Each agent must be highly specialized; prefer 1–3 skills, up to 5 for complex contexts
- **NO GENERIC AGENTS** - Avoid creating broad, multi-purpose agents that dilute focus
AGENT SPECIALIZATION EXAMPLES:
GOOD SPECIALIZATION:
- "SQLi Validation Agent" with skills: sql_injection
- "XSS Discovery Agent" with skills: xss
- "Auth Testing Agent" with skills: authentication_jwt, business_logic
- "SSRF + XXE Agent" with skills: ssrf, xxe, rce (related attack vectors)
BAD SPECIALIZATION:
- "General Web Testing Agent" with skills: sql_injection, xss, csrf, ssrf, authentication_jwt (too broad)
- "Everything Agent" with skills: all available skills (completely unfocused)
- Any agent with more than 5 skills (violates constraints)
FOCUS PRINCIPLES:
- Each agent should have deep expertise in 1-3 related vulnerability types
- Agents with single skills have the deepest specialization
- Related vulnerabilities (like SSRF+XXE or Auth+Business Logic) can be combined
- Never create "kitchen sink" agents that try to do everything
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)
PERSISTENCE IS MANDATORY:
- Real vulnerabilities take TIME - expect to need 2000+ steps minimum
- NEVER give up early - attackers spend weeks on single targets
- If one approach fails, try 10 more approaches
- Each failure teaches you something - use it to refine next attempts
- Bug bounty hunters spend DAYS on single targets - so should you
- There are ALWAYS more attack vectors to explore
Tool call format:
value
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 ... block in a single LLM message.
2. Tool call must be last in message
3. EVERY tool call MUST end with . This is MANDATORY. Never omit the closing tag. End your response immediately after .
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 value 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:
value
WRONG formats — NEVER use these:
- value
- ...
- ...
- {"tool_name": {"param_name": "value"}}
- ```...```
- value_without_parameter_tags
EVERY argument MUST be wrapped in ... 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 ... or ... blocks
- NO ... or ... blocks
- NO ... or ... 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 NOT , use NOT , use NOT .
Example (terminal tool):
nmap -sV -p 1-1000 target.com
Example (agent creation tool):
Perform targeted XSS testing on the search endpointXSS Discovery Agentxss
SPRAYING EXECUTION NOTE:
- When performing large payload sprays or fuzzing, encapsulate the entire spraying loop inside a single python or terminal tool call (e.g., a Python script using asyncio/aiohttp). 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 before going into the next. Incomplete tool calls will fail.
{{ get_tools_prompt() }}
Docker container with Kali Linux and comprehensive security tools:
RECONNAISSANCE & SCANNING:
- nmap, ncat, ndiff - Network mapping and port scanning
- subfinder - Subdomain enumeration
- naabu - Fast port scanner
- httpx - HTTP probing and validation
- gospider - Web spider/crawler
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:
- ffuf - Fast web fuzzer
- dirsearch - Directory/file discovery
- katana - Advanced web crawler
- arjun - HTTP parameter discovery
- vulnx (cvemap) - CVE vulnerability mapping
JAVASCRIPT ANALYSIS:
- JS-Snooper, jsniper.sh - JS analysis scripts
- retire - Vulnerable JS library detection
- eslint, jshint - JS static analysis
- js-beautify - JS beautifier/deobfuscator
CODE ANALYSIS:
- semgrep - Static analysis/SAST
- bandit - Python security linter
- trufflehog - Secret detection in code
SPECIALIZED TOOLS:
- jwt_tool - JWT token manipulation
- wafw00f - WAF detection
- 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).
- 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
- 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.)
Directories:
- /workspace - where you should work.
- /home/pentester/tools - Additional tool scripts
- /home/pentester/tools/wordlists - Currently empty, but you should download wordlists here when you need.
Default user: pentester (sudo available)
{% if loaded_skill_names %}
{% for skill_name in loaded_skill_names %}
<{{ skill_name }}>
{{ get_skill(skill_name) }}
{{ skill_name }}>
{% endfor %}
{% endif %}
================================================
FILE: strix/agents/__init__.py
================================================
from .base_agent import BaseAgent
from .state import AgentState
from .StrixAgent import StrixAgent
__all__ = [
"AgentState",
"BaseAgent",
"StrixAgent",
]
================================================
FILE: strix/agents/base_agent.py
================================================
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"""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.{sender_name}{sender_id}{message.get("message_type", "information")}{message.get("priority", "normal")}{message.get("timestamp", "")}
{message.get("content", "")}
This message was delivered during your task execution.
Please acknowledge and respond if needed."""
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
================================================
FILE: strix/agents/state.py
================================================
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,
}
================================================
FILE: strix/config/__init__.py
================================================
from strix.config.config import (
Config,
apply_saved_config,
save_current_config,
)
__all__ = [
"Config",
"apply_saved_config",
"save_current_config",
]
================================================
FILE: strix/config/config.py
================================================
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.12"
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
================================================
FILE: strix/interface/__init__.py
================================================
from .main import main
__all__ = ["main"]
================================================
FILE: strix/interface/assets/tui_styles.tcss
================================================
Screen {
background: #000000;
color: #d4d4d4;
}
.screen--selection {
background: #2d3d2f;
color: #e5e5e5;
}
ToastRack {
dock: top;
align: right top;
margin-bottom: 0;
margin-top: 1;
}
Toast {
width: 25;
background: #000000;
border-left: outer #22c55e;
}
Toast.-information .toast--title {
color: #22c55e;
}
#splash_screen {
height: 100%;
width: 100%;
background: #000000;
color: #22c55e;
align: center middle;
content-align: center middle;
text-align: center;
}
#splash_content {
width: auto;
height: auto;
background: transparent;
text-align: center;
content-align: center middle;
padding: 2;
}
#main_container {
height: 100%;
padding: 0;
margin: 0;
background: #000000;
}
#content_container {
height: 1fr;
padding: 0;
background: transparent;
}
#sidebar {
width: 20%;
background: transparent;
margin-left: 1;
}
#sidebar.-hidden {
display: none;
}
#agents_tree {
height: 1fr;
background: transparent;
border: round #333333;
border-title-color: #a8a29e;
border-title-style: bold;
padding: 1;
margin-bottom: 0;
}
#stats_scroll {
height: auto;
max-height: 15;
background: transparent;
padding: 0;
margin: 0;
border: round #333333;
scrollbar-size: 0 0;
}
#stats_display {
height: auto;
background: transparent;
padding: 0 1;
margin: 0;
}
#vulnerabilities_panel {
height: auto;
max-height: 12;
background: transparent;
padding: 0;
margin: 0;
border: round #333333;
overflow-y: auto;
scrollbar-background: #000000;
scrollbar-color: #333333;
scrollbar-corner-color: #000000;
scrollbar-size-vertical: 1;
}
#vulnerabilities_panel.hidden {
display: none;
}
.vuln-item {
height: auto;
width: 100%;
padding: 0 1;
background: transparent;
color: #d4d4d4;
}
.vuln-item:hover {
background: #1a1a1a;
color: #fafaf9;
}
VulnerabilityDetailScreen {
align: center middle;
background: #000000 80%;
}
#vuln_detail_dialog {
grid-size: 1;
grid-gutter: 1;
grid-rows: 1fr auto;
padding: 2 3;
width: 85%;
max-width: 110;
height: 85%;
max-height: 45;
border: solid #262626;
background: #0a0a0a;
}
#vuln_detail_scroll {
height: 1fr;
background: transparent;
scrollbar-background: #0a0a0a;
scrollbar-color: #404040;
scrollbar-corner-color: #0a0a0a;
scrollbar-size: 1 1;
padding-right: 1;
}
#vuln_detail_content {
width: 100%;
background: transparent;
padding: 0;
}
#vuln_detail_buttons {
width: 100%;
height: auto;
align: right middle;
padding-top: 1;
margin: 0;
border-top: solid #1a1a1a;
}
#copy_vuln_detail {
width: auto;
min-width: 12;
height: auto;
background: transparent;
color: #525252;
border: none;
text-style: none;
margin: 0 1;
padding: 0 2;
}
#close_vuln_detail {
width: auto;
min-width: 10;
height: auto;
background: transparent;
color: #a3a3a3;
border: none;
text-style: none;
margin: 0;
padding: 0 2;
}
#copy_vuln_detail:hover, #copy_vuln_detail:focus {
background: transparent;
color: #22c55e;
border: none;
}
#close_vuln_detail:hover, #close_vuln_detail:focus {
background: transparent;
color: #ffffff;
border: none;
}
#chat_area_container {
width: 80%;
background: transparent;
}
#chat_area_container.-full-width {
width: 100%;
}
#chat_history {
height: 1fr;
background: transparent;
border: round #0a0a0a;
padding: 0;
margin-bottom: 0;
margin-right: 0;
scrollbar-background: #000000;
scrollbar-color: #1a1a1a;
scrollbar-corner-color: #000000;
scrollbar-size: 1 1;
}
#agent_status_display {
height: 1;
background: transparent;
margin: 0;
padding: 0 1;
}
#agent_status_display.hidden {
display: none;
}
#status_text {
width: 1fr;
height: 100%;
background: transparent;
color: #a3a3a3;
text-align: left;
content-align: left middle;
text-style: none;
margin: 0;
padding: 0;
}
#keymap_indicator {
width: auto;
height: 100%;
background: transparent;
color: #737373;
text-align: right;
content-align: right middle;
text-style: none;
margin: 0;
padding: 0;
}
#chat_input_container {
height: 3;
background: transparent;
border: round #333333;
margin-right: 0;
padding: 0;
layout: horizontal;
align-vertical: top;
}
#chat_input_container:focus-within {
border: round #22c55e;
}
#chat_input_container:focus-within #chat_prompt {
color: #22c55e;
text-style: bold;
}
#chat_prompt {
width: auto;
height: 100%;
padding: 0 0 0 1;
color: #737373;
content-align-vertical: top;
}
#chat_history:focus {
border: round #22c55e;
}
#chat_input {
width: 1fr;
height: 100%;
background: transparent;
border: none;
color: #d4d4d4;
padding: 0;
margin: 0;
}
#chat_input:focus {
border: none;
}
#chat_input .text-area--cursor-line {
background: transparent;
}
#chat_input:focus .text-area--cursor-line {
background: transparent;
}
#chat_input > .text-area--placeholder {
color: #525252;
text-style: italic;
}
#chat_input > .text-area--cursor {
color: #22c55e;
background: #22c55e;
}
.chat-placeholder {
width: 100%;
height: 100%;
content-align: center middle;
text-align: center;
color: #737373;
text-style: italic;
}
.chat-content {
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
padding: 0 1;
background: transparent;
width: 100%;
}
.chat-message {
margin-bottom: 0;
padding: 0;
background: transparent;
width: 100%;
}
.user-message {
color: #e5e5e5;
border-left: thick #3b82f6;
padding-left: 1;
margin-bottom: 1;
}
.tool-call {
margin-top: 1;
margin-bottom: 0;
padding: 0 1;
background: transparent;
border: none;
width: 100%;
}
.tool-call.status-completed {
background: transparent;
margin-top: 1;
margin-bottom: 0;
}
.tool-call.status-running {
background: transparent;
margin-top: 1;
margin-bottom: 0;
}
.tool-call.status-failed,
.tool-call.status-error {
background: transparent;
margin-top: 1;
margin-bottom: 0;
}
.browser-tool,
.terminal-tool,
.python-tool,
.agents-graph-tool,
.file-edit-tool,
.proxy-tool,
.notes-tool,
.thinking-tool,
.web-search-tool,
.scan-info-tool,
.subagent-info-tool {
margin-top: 1;
margin-bottom: 0;
background: transparent;
}
.finish-tool,
.reporting-tool {
margin-top: 1;
margin-bottom: 0;
background: transparent;
}
.browser-tool.status-completed,
.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,
.file-edit-tool.status-running,
.proxy-tool.status-completed,
.proxy-tool.status-running,
.notes-tool.status-completed,
.notes-tool.status-running,
.thinking-tool.status-completed,
.thinking-tool.status-running,
.web-search-tool.status-completed,
.web-search-tool.status-running,
.scan-info-tool.status-completed,
.scan-info-tool.status-running,
.subagent-info-tool.status-completed,
.subagent-info-tool.status-running {
background: transparent;
margin-top: 1;
margin-bottom: 0;
}
.finish-tool.status-completed,
.finish-tool.status-running,
.reporting-tool.status-completed,
.reporting-tool.status-running {
background: transparent;
margin-top: 1;
margin-bottom: 0;
}
Tree {
background: transparent;
color: #e7e5e4;
scrollbar-background: transparent;
scrollbar-color: #404040;
scrollbar-corner-color: transparent;
scrollbar-size: 1 1;
}
Tree > .tree--label {
text-style: bold;
color: #a8a29e;
background: transparent;
padding: 0 1;
margin-bottom: 1;
border-bottom: solid #1a1a1a;
text-align: center;
}
.tree--node {
height: 1;
padding: 0;
margin: 0;
}
.tree--node-label {
color: #d6d3d1;
background: transparent;
text-style: none;
padding: 0 1;
margin: 0 1;
}
.tree--node:hover .tree--node-label {
background: transparent;
color: #fafaf9;
text-style: bold;
border-left: solid #a8a29e;
}
.tree--node.-selected .tree--node-label {
background: transparent;
color: #fafaf9;
text-style: bold;
border-left: heavy #d6d3d1;
}
.tree--node.-expanded .tree--node-label {
text-style: bold;
color: #fafaf9;
background: transparent;
border-left: solid #78716c;
}
Tree:focus {
border: round #1a1a1a;
}
Tree:focus > .tree--label {
color: #fafaf9;
text-style: bold;
background: transparent;
}
.tree--node .tree--node .tree--node-label {
color: #a8a29e;
padding-left: 2;
border: none;
background: transparent;
margin-left: 1;
}
.tree--node .tree--node:hover .tree--node-label {
background: transparent;
color: #e7e5e4;
}
.tree--node .tree--node .tree--node .tree--node-label {
color: #78716c;
padding-left: 3;
text-style: none;
border: none;
background: transparent;
margin-left: 2;
}
StopAgentScreen {
align: center middle;
background: $background 0%;
}
#stop_agent_dialog {
grid-size: 1;
grid-gutter: 1;
grid-rows: auto auto;
padding: 1;
width: 30;
height: auto;
border: round #a3a3a3;
background: #000000 98%;
}
#stop_agent_title {
color: #a3a3a3;
text-style: bold;
text-align: center;
width: 100%;
margin-bottom: 0;
}
#stop_agent_buttons {
grid-size: 2;
grid-gutter: 1;
grid-columns: 1fr 1fr;
width: 100%;
height: 1;
}
#stop_agent_buttons Button {
height: 1;
min-height: 1;
border: none;
text-style: bold;
}
#stop_agent {
background: transparent;
color: #ef4444;
border: none;
}
#stop_agent:hover, #stop_agent:focus {
background: #ef4444;
color: #ffffff;
border: none;
}
#cancel_stop {
background: transparent;
color: #737373;
border: none;
}
#cancel_stop:hover, #cancel_stop:focus {
background:rgb(54, 54, 54);
color: #ffffff;
border: none;
}
QuitScreen {
align: center middle;
background: $background 0%;
}
#quit_dialog {
grid-size: 1;
grid-gutter: 1;
grid-rows: auto auto;
padding: 1;
width: 24;
height: auto;
border: round #333333;
background: #000000 98%;
}
#quit_title {
color: #d4d4d4;
text-style: bold;
text-align: center;
width: 100%;
margin-bottom: 0;
}
#quit_buttons {
grid-size: 2;
grid-gutter: 1;
grid-columns: 1fr 1fr;
width: 100%;
height: 1;
}
#quit_buttons Button {
height: 1;
min-height: 1;
border: none;
text-style: bold;
}
#quit {
background: transparent;
color: #ef4444;
border: none;
}
#quit:hover, #quit:focus {
background: #ef4444;
color: #ffffff;
border: none;
}
#cancel {
background: transparent;
color: #737373;
border: none;
}
#cancel:hover, #cancel:focus {
background:rgb(54, 54, 54);
color: #ffffff;
border: none;
}
HelpScreen {
align: center middle;
background: $background 0%;
}
#dialog {
grid-size: 1;
grid-gutter: 0 1;
grid-rows: auto auto;
padding: 1 2;
width: 40;
height: auto;
border: round #22c55e;
background: #000000 98%;
}
#help_title {
color: #22c55e;
text-style: bold;
text-align: center;
width: 100%;
margin-bottom: 1;
}
#help_content {
color: #d4d4d4;
text-align: left;
width: 100%;
margin-bottom: 1;
padding: 0;
background: transparent;
text-style: none;
}
================================================
FILE: strix/interface/cli.py
================================================
import atexit
import signal
import sys
import threading
import time
from typing import Any
from rich.console import Console
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 .utils import (
build_live_stats_text,
format_vulnerability_report,
)
async def run_cli(args: Any) -> None: # noqa: PLR0915
console = Console()
start_text = Text()
start_text.append("Penetration test initiated", style="bold #22c55e")
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(" ")
if len(args.targets_info) == 1:
target_text.append(args.targets_info[0]["original"], style="bold white")
else:
target_text.append(f"{len(args.targets_info)} targets", style="bold white")
for target_info in args.targets_info:
target_text.append("\n ")
target_text.append(target_info["original"], style="white")
results_text = Text()
results_text.append("Output", style="dim")
results_text.append(" ")
results_text.append(f"strix_runs/{args.run_name}", style="#60a5fa")
note_text = Text()
note_text.append("\n\n", style="dim")
note_text.append("Vulnerabilities will be displayed in real-time.", style="dim")
startup_panel = Panel(
Text.assemble(
start_text,
"\n\n",
target_text,
"\n",
results_text,
note_text,
),
title="[bold white]STRIX",
title_align="left",
border_style="#22c55e",
padding=(1, 2),
)
console.print("\n")
console.print(startup_panel)
console.print()
scan_mode = getattr(args, "scan_mode", "deep")
scan_config = {
"scan_id": args.run_name,
"targets": args.targets_info,
"user_instructions": args.instruction or "",
"run_name": args.run_name,
}
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)
def display_vulnerability(report: dict[str, Any]) -> None:
report_id = report.get("id", "unknown")
vuln_text = format_vulnerability_report(report)
vuln_panel = Panel(
vuln_text,
title=f"[bold red]{report_id.upper()}",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print(vuln_panel)
console.print()
tracer.vulnerability_found_callback = display_vulnerability
def cleanup_on_exit() -> None:
from strix.runtime import cleanup_runtime
tracer.cleanup()
cleanup_runtime()
def signal_handler(_signum: int, _frame: Any) -> None:
tracer.cleanup()
sys.exit(1)
atexit.register(cleanup_on_exit)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, signal_handler)
set_global_tracer(tracer)
def create_live_status() -> Panel:
status_text = Text()
status_text.append("Penetration test in progress", style="bold #22c55e")
status_text.append("\n\n")
stats_text = build_live_stats_text(tracer, agent_config)
if stats_text:
status_text.append(stats_text)
return Panel(
status_text,
title="[bold white]STRIX",
title_align="left",
border_style="#22c55e",
padding=(1, 2),
)
try:
console.print()
with Live(
create_live_status(), console=console, refresh_per_second=2, transient=False
) as live:
stop_updates = threading.Event()
def update_status() -> None:
while not stop_updates.is_set():
try:
live.update(create_live_status())
time.sleep(2)
except Exception: # noqa: BLE001
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)
finally:
stop_updates.set()
update_thread.join(timeout=1)
except Exception as e:
console.print(f"[bold red]Error during penetration test:[/] {e}")
raise
if tracer.final_scan_result:
console.print()
final_report_text = Text()
final_report_text.append("Penetration test summary", style="bold #60a5fa")
final_report_panel = Panel(
Text.assemble(
final_report_text,
"\n\n",
tracer.final_scan_result,
),
title="[bold white]STRIX",
title_align="left",
border_style="#60a5fa",
padding=(1, 2),
)
console.print(final_report_panel)
console.print()
================================================
FILE: strix/interface/main.py
================================================
#!/usr/bin/env python3
"""
Strix Agent Interface
"""
import argparse
import asyncio
import logging
import shutil
import sys
from pathlib import Path
from typing import Any
import litellm
from docker.errors import DockerException
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from strix.config import Config, apply_saved_config, save_current_config
from strix.config.config import resolve_llm_config
from strix.llm.utils import resolve_strix_model
apply_saved_config()
from strix.interface.cli import run_cli # noqa: E402
from strix.interface.tui import run_tui # noqa: E402
from strix.interface.utils import ( # noqa: E402
assign_workspace_subdirs,
build_final_stats_text,
check_docker_connection,
clone_repository,
collect_local_sources,
generate_run_name,
image_exists,
infer_target_type,
process_pull_line,
rewrite_localhost_targets,
validate_config_file,
validate_llm_response,
)
from strix.runtime.docker_runtime import HOST_GATEWAY_HOSTNAME # noqa: E402
from strix.telemetry import posthog # noqa: E402
from strix.telemetry.tracer import get_global_tracer # noqa: E402
logging.getLogger().setLevel(logging.ERROR)
def validate_environment() -> None: # noqa: PLR0912, PLR0915
console = Console()
missing_required_vars = []
missing_optional_vars = []
strix_llm = Config.get("strix_llm")
uses_strix_models = strix_llm and strix_llm.startswith("strix/")
if not strix_llm:
missing_required_vars.append("STRIX_LLM")
has_base_url = uses_strix_models or any(
[
Config.get("llm_api_base"),
Config.get("openai_api_base"),
Config.get("litellm_base_url"),
Config.get("ollama_api_base"),
]
)
if not Config.get("llm_api_key"):
missing_optional_vars.append("LLM_API_KEY")
if not has_base_url:
missing_optional_vars.append("LLM_API_BASE")
if not Config.get("perplexity_api_key"):
missing_optional_vars.append("PERPLEXITY_API_KEY")
if not Config.get("strix_reasoning_effort"):
missing_optional_vars.append("STRIX_REASONING_EFFORT")
if missing_required_vars:
error_text = Text()
error_text.append("MISSING REQUIRED ENVIRONMENT VARIABLES", style="bold red")
error_text.append("\n\n", style="white")
for var in missing_required_vars:
error_text.append(f"• {var}", style="bold yellow")
error_text.append(" is not set\n", style="white")
if missing_optional_vars:
error_text.append("\nOptional environment variables:\n", style="dim white")
for var in missing_optional_vars:
error_text.append(f"• {var}", style="dim yellow")
error_text.append(" is not set\n", style="dim white")
error_text.append("\nRequired environment variables:\n", style="white")
for var in missing_required_vars:
if var == "STRIX_LLM":
error_text.append("• ", style="white")
error_text.append("STRIX_LLM", style="bold cyan")
error_text.append(
" - Model name to use with litellm (e.g., 'openai/gpt-5')\n",
style="white",
)
if missing_optional_vars:
error_text.append("\nOptional environment variables:\n", style="white")
for var in missing_optional_vars:
if var == "LLM_API_KEY":
error_text.append("• ", style="white")
error_text.append("LLM_API_KEY", style="bold cyan")
error_text.append(
" - API key for the LLM provider "
"(not needed for local models, Vertex AI, AWS, etc.)\n",
style="white",
)
elif var == "LLM_API_BASE":
error_text.append("• ", style="white")
error_text.append("LLM_API_BASE", style="bold cyan")
error_text.append(
" - Custom API base URL if using local models (e.g., Ollama, LMStudio)\n",
style="white",
)
elif var == "PERPLEXITY_API_KEY":
error_text.append("• ", style="white")
error_text.append("PERPLEXITY_API_KEY", style="bold cyan")
error_text.append(
" - API key for Perplexity AI web search (enables real-time research)\n",
style="white",
)
elif var == "STRIX_REASONING_EFFORT":
error_text.append("• ", style="white")
error_text.append("STRIX_REASONING_EFFORT", style="bold cyan")
error_text.append(
" - Reasoning effort level: none, minimal, low, medium, high, xhigh "
"(default: high)\n",
style="white",
)
error_text.append("\nExample setup:\n", style="white")
if uses_strix_models:
error_text.append("export STRIX_LLM='strix/gpt-5'\n", style="dim white")
else:
error_text.append("export STRIX_LLM='openai/gpt-5'\n", style="dim white")
if missing_optional_vars:
for var in missing_optional_vars:
if var == "LLM_API_KEY":
error_text.append(
"export LLM_API_KEY='your-api-key-here' "
"# not needed for local models, Vertex AI, AWS, etc.\n",
style="dim white",
)
elif var == "LLM_API_BASE":
error_text.append(
"export LLM_API_BASE='http://localhost:11434' "
"# needed for local models only\n",
style="dim white",
)
elif var == "PERPLEXITY_API_KEY":
error_text.append(
"export PERPLEXITY_API_KEY='your-perplexity-key-here'\n", style="dim white"
)
elif var == "STRIX_REASONING_EFFORT":
error_text.append(
"export STRIX_REASONING_EFFORT='high'\n",
style="dim white",
)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
def check_docker_installed() -> None:
if shutil.which("docker") is None:
console = Console()
error_text = Text()
error_text.append("DOCKER NOT INSTALLED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append("The 'docker' CLI was not found in your PATH.\n", style="white")
error_text.append(
"Please install Docker and ensure the 'docker' command is available.\n\n", style="white"
)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n", panel, "\n")
sys.exit(1)
async def warm_up_llm() -> None:
console = Console()
try:
model_name, api_key, api_base = resolve_llm_config()
litellm_model, _ = resolve_strix_model(model_name)
litellm_model = litellm_model or model_name
test_messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Reply with just 'OK'."},
]
llm_timeout = int(Config.get("llm_timeout") or "300")
completion_kwargs: dict[str, Any] = {
"model": litellm_model,
"messages": test_messages,
"timeout": llm_timeout,
}
if api_key:
completion_kwargs["api_key"] = api_key
if api_base:
completion_kwargs["api_base"] = api_base
response = litellm.completion(**completion_kwargs)
validate_llm_response(response)
except Exception as e: # noqa: BLE001
error_text = Text()
error_text.append("LLM CONNECTION FAILED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append("Could not establish connection to the language model.\n", style="white")
error_text.append("Please check your configuration and try again.\n", style="white")
error_text.append(f"\nError: {e}", style="dim white")
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
def get_version() -> str:
try:
from importlib.metadata import version
return version("strix-agent")
except Exception: # noqa: BLE001
return "unknown"
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Web application penetration test
strix --target https://example.com
# GitHub repository analysis
strix --target https://github.com/user/repo
strix --target git@github.com:user/repo.git
# Local code analysis
strix --target ./my-project
# Domain penetration test
strix --target example.com
# IP address penetration test
strix --target 192.168.1.42
# Multiple targets (e.g., white-box testing with source and deployed app)
strix --target https://github.com/user/repo --target https://example.com
strix --target ./my-project --target https://staging.example.com --target https://prod.example.com
# Custom instructions (inline)
strix --target example.com --instruction "Focus on authentication vulnerabilities"
# Custom instructions (from file)
strix --target example.com --instruction-file ./instructions.txt
strix --target https://app.com --instruction-file /path/to/detailed_instructions.md
""",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"strix {get_version()}",
)
parser.add_argument(
"-t",
"--target",
type=str,
required=True,
action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Can be specified multiple times for multi-target scans.",
)
parser.add_argument(
"--instruction",
type=str,
help="Custom instructions for the penetration test. This can be "
"specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), "
"testing approaches (e.g., 'Perform thorough authentication testing'), "
"test credentials (e.g., 'Use the following credentials to access the app: "
"admin:password123'), "
"or areas of interest (e.g., 'Check login API endpoint for security issues').",
)
parser.add_argument(
"--instruction-file",
type=str,
help="Path to a file containing detailed custom instructions for the penetration test. "
"Use this option when you have lengthy or complex instructions saved in a file "
"(e.g., '--instruction-file ./detailed_instructions.txt').",
)
parser.add_argument(
"-n",
"--non-interactive",
action="store_true",
help=(
"Run in non-interactive mode (no TUI, exits on completion). "
"Default is interactive mode with TUI."
),
)
parser.add_argument(
"-m",
"--scan-mode",
type=str,
choices=["quick", "standard", "deep"],
default="deep",
help=(
"Scan mode: "
"'quick' for fast CI/CD checks, "
"'standard' for routine testing, "
"'deep' for thorough security reviews (default). "
"Default: deep."
),
)
parser.add_argument(
"--config",
type=str,
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
)
args = parser.parse_args()
if args.instruction and args.instruction_file:
parser.error(
"Cannot specify both --instruction and --instruction-file. Use one or the other."
)
if args.instruction_file:
instruction_path = Path(args.instruction_file)
try:
with instruction_path.open(encoding="utf-8") as f:
args.instruction = f.read().strip()
if not args.instruction:
parser.error(f"Instruction file '{instruction_path}' is empty")
except Exception as e: # noqa: BLE001
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
args.targets_info = []
for target in args.target:
try:
target_type, target_dict = infer_target_type(target)
if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
except ValueError:
parser.error(f"Invalid target '{target}'")
assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
return args
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
console = Console()
tracer = get_global_tracer()
scan_completed = False
if tracer and tracer.scan_results:
scan_completed = tracer.scan_results.get("scan_completed", False)
completion_text = Text()
if scan_completed:
completion_text.append("Penetration test completed", style="bold #22c55e")
else:
completion_text.append("SESSION ENDED", style="bold #eab308")
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(" ")
if len(args.targets_info) == 1:
target_text.append(args.targets_info[0]["original"], style="bold white")
else:
target_text.append(f"{len(args.targets_info)} targets", style="bold white")
for target_info in args.targets_info:
target_text.append("\n ")
target_text.append(target_info["original"], style="white")
stats_text = build_final_stats_text(tracer)
panel_parts = [completion_text, "\n\n", target_text]
if stats_text.plain:
panel_parts.extend(["\n", stats_text])
results_text = Text()
results_text.append("\n")
results_text.append("Output", style="dim")
results_text.append(" ")
results_text.append(str(results_path), style="#60a5fa")
panel_parts.extend(["\n", results_text])
panel_content = Text.assemble(*panel_parts)
border_style = "#22c55e" if scan_completed else "#eab308"
panel = Panel(
panel_content,
title="[bold white]STRIX",
title_align="left",
border_style=border_style,
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
console.print("[#60a5fa]models.strix.ai[/] [dim]·[/] [#60a5fa]discord.gg/strix-ai[/]")
console.print()
def pull_docker_image() -> None:
console = Console()
client = check_docker_connection()
if image_exists(client, Config.get("strix_image")): # type: ignore[arg-type]
return
console.print()
console.print(f"[dim]Pulling image[/] {Config.get('strix_image')}")
console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]")
console.print()
with console.status("[bold cyan]Downloading image layers...", spinner="dots") as status:
try:
layers_info: dict[str, str] = {}
last_update = ""
for line in client.api.pull(Config.get("strix_image"), stream=True, decode=True):
last_update = process_pull_line(line, layers_info, status, last_update)
except DockerException as e:
console.print()
error_text = Text()
error_text.append("FAILED TO PULL IMAGE", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(f"Could not download: {Config.get('strix_image')}\n", style="white")
error_text.append(str(e), style="dim red")
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print(panel, "\n")
sys.exit(1)
success_text = Text()
success_text.append("Docker image ready", style="#22c55e")
console.print(success_text)
console.print()
def apply_config_override(config_path: str) -> None:
Config._config_file_override = validate_config_file(config_path)
apply_saved_config(force=True)
def persist_config() -> None:
if Config._config_file_override is None:
save_current_config()
def main() -> None:
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
args = parse_arguments()
if args.config:
apply_config_override(args.config)
check_docker_installed()
pull_docker_image()
validate_environment()
asyncio.run(warm_up_llm())
persist_config()
args.run_name = generate_run_name(args.targets_info)
for target_info in args.targets_info:
if target_info["type"] == "repository":
repo_url = target_info["details"]["target_repo"]
dest_name = target_info["details"].get("workspace_subdir")
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
target_info["details"]["cloned_repo_path"] = cloned_path
args.local_sources = collect_local_sources(args.targets_info)
is_whitebox = bool(args.local_sources)
posthog.start(
model=Config.get("strix_llm"),
scan_mode=args.scan_mode,
is_whitebox=is_whitebox,
interactive=not args.non_interactive,
has_instructions=bool(args.instruction),
)
exit_reason = "user_exit"
try:
if args.non_interactive:
asyncio.run(run_cli(args))
else:
asyncio.run(run_tui(args))
except KeyboardInterrupt:
exit_reason = "interrupted"
except Exception as e:
exit_reason = "error"
posthog.error("unhandled_exception", str(e))
raise
finally:
tracer = get_global_tracer()
if tracer:
posthog.end(tracer, exit_reason=exit_reason)
results_path = Path("strix_runs") / args.run_name
display_completion_message(args, results_path)
if args.non_interactive:
tracer = get_global_tracer()
if tracer and tracer.vulnerability_reports:
sys.exit(2)
if __name__ == "__main__":
main()
================================================
FILE: strix/interface/streaming_parser.py
================================================
import html
import re
from dataclasses import dataclass
from typing import Literal
from strix.llm.utils import normalize_tool_format
_FUNCTION_TAG_PREFIX = "]+)>")
_FUNC_END_PATTERN = re.compile(r"")
_COMPLETE_PARAM_PATTERN = re.compile(r"]+)>(.*?)", re.DOTALL)
_INCOMPLETE_PARAM_PATTERN = re.compile(r"]+)>(.*)$", 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
================================================
FILE: strix/interface/tool_components/__init__.py
================================================
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",
]
================================================
FILE: strix/interface/tool_components/agent_message_renderer.py
================================================
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.styles import get_style_by_name
from pygments.util import ClassNotFound
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
_HEADER_STYLES = [
("###### ", 7, "bold #4ade80"),
("##### ", 6, "bold #22c55e"),
("#### ", 5, "bold #16a34a"),
("### ", 4, "bold #15803d"),
("## ", 3, "bold #22c55e"),
("# ", 2, "bold #4ade80"),
]
@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_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, language: str | None = None) -> Text:
text = Text()
try:
lexer = get_lexer_by_name(language) if language else guess_lexer(code)
except ClassNotFound:
text.append(code, style="#d4d4d4")
return 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 _try_parse_header(line: str) -> tuple[str, str] | None:
for prefix, strip_len, style in _HEADER_STYLES:
if line.startswith(prefix):
return (line[strip_len:], style)
return None
def _apply_markdown_styles(text: str) -> Text: # noqa: PLR0912
result = Text()
lines = text.split("\n")
in_code_block = False
code_block_lang: str | None = None
code_block_lines: list[str] = []
for i, line in enumerate(lines):
if i > 0 and not in_code_block:
result.append("\n")
if line.startswith("```"):
if not in_code_block:
in_code_block = True
code_block_lang = line[3:].strip() or None
code_block_lines = []
if i > 0:
result.append("\n")
else:
in_code_block = False
code_content = "\n".join(code_block_lines)
if code_content:
result.append_text(_highlight_code(code_content, code_block_lang))
code_block_lines = []
code_block_lang = None
continue
if in_code_block:
code_block_lines.append(line)
continue
header = _try_parse_header(line)
if header:
result.append(header[0], style=header[1])
elif line.startswith("> "):
result.append("┃ ", style="#22c55e")
result.append_text(_process_inline_formatting(line[2:]))
elif line.startswith(("- ", "* ")):
result.append("• ", style="#22c55e")
result.append_text(_process_inline_formatting(line[2:]))
elif len(line) > 2 and line[0].isdigit() and line[1:3] in (". ", ") "):
result.append(line[0] + ". ", style="#22c55e")
result.append_text(_process_inline_formatting(line[2:]))
elif line.strip() in ("---", "***", "___"):
result.append("─" * 40, style="#22c55e")
else:
result.append_text(_process_inline_formatting(line))
if in_code_block and code_block_lines:
code_content = "\n".join(code_block_lines)
result.append_text(_highlight_code(code_content, code_block_lang))
return result
def _process_inline_formatting(line: str) -> Text:
result = Text()
i = 0
n = len(line)
while i < n:
if i + 1 < n and line[i : i + 2] in ("**", "__"):
marker = line[i : i + 2]
end = line.find(marker, i + 2)
if end != -1:
result.append(line[i + 2 : end], style="bold #4ade80")
i = end + 2
continue
if i + 1 < n and line[i : i + 2] == "~~":
end = line.find("~~", i + 2)
if end != -1:
result.append(line[i + 2 : end], style="strike #525252")
i = end + 2
continue
if line[i] == "`":
end = line.find("`", i + 1)
if end != -1:
result.append(line[i + 1 : end], style="bold #22c55e on #0a0a0a")
i = end + 1
continue
if line[i] in ("*", "_"):
marker = line[i]
if i + 1 < n and line[i + 1] != marker:
end = line.find(marker, i + 1)
if end != -1 and (end + 1 >= n or line[end + 1] != marker):
result.append(line[i + 1 : end], style="italic #86efac")
i = end + 1
continue
result.append(line[i])
i += 1
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))
@classmethod
def render_simple(cls, content: str) -> Text:
if not content:
return Text()
from strix.llm.utils import clean_content
cleaned = clean_content(content)
if not cleaned:
return Text()
return _apply_markdown_styles(cleaned)
================================================
FILE: strix/interface/tool_components/agents_graph_renderer.py
================================================
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 ViewAgentGraphRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "view_agent_graph"
css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
status = tool_data.get("status", "unknown")
text = Text()
text.append("◇ ", style="#a78bfa")
text.append("viewing agents graph", style="dim")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class CreateAgentRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "create_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", {})
status = tool_data.get("status", "unknown")
task = args.get("task", "")
name = args.get("name", "Agent")
text = Text()
text.append("◈ ", style="#a78bfa")
text.append("spawning ", 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)
@register_tool_renderer
class SendMessageToAgentRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "send_message_to_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", {})
status = tool_data.get("status", "unknown")
message = args.get("message", "")
agent_id = args.get("agent_id", "")
text = Text()
text.append("→ ", style="#60a5fa")
if agent_id:
text.append(f"to {agent_id}", style="dim")
else:
text.append("sending message", style="dim")
if message:
text.append("\n ")
text.append(message, style="dim")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class AgentFinishRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "agent_finish"
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_summary = args.get("result_summary", "")
findings = args.get("findings", [])
success = args.get("success", True)
text = Text()
if success:
text.append("◆ ", style="#22c55e")
text.append("Agent completed", style="bold #22c55e")
else:
text.append("◆ ", style="#ef4444")
text.append("Agent failed", style="bold #ef4444")
if result_summary:
text.append("\n ")
text.append(result_summary, style="bold")
if findings and isinstance(findings, list):
for finding in findings:
text.append("\n • ")
text.append(str(finding), style="dim")
else:
text.append("\n ")
text.append("Completing task...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class WaitForMessageRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "wait_for_message"
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", {})
status = tool_data.get("status", "unknown")
reason = args.get("reason", "")
text = Text()
text.append("○ ", style="#6b7280")
text.append("waiting", style="dim")
if reason:
text.append("\n ")
text.append(reason, style="dim")
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
================================================
FILE: strix/interface/tool_components/base_renderer.py
================================================
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
================================================
FILE: strix/interface/tool_components/browser_renderer.py
================================================
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
================================================
FILE: strix/interface/tool_components/file_edit_renderer.py
================================================
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)
================================================
FILE: strix/interface/tool_components/finish_renderer.py
================================================
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
FIELD_STYLE = "bold #4ade80"
@register_tool_renderer
class FinishScanRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "finish_scan"
css_classes: ClassVar[list[str]] = ["tool-call", "finish-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
executive_summary = args.get("executive_summary", "")
methodology = args.get("methodology", "")
technical_analysis = args.get("technical_analysis", "")
recommendations = args.get("recommendations", "")
text = Text()
text.append("◆ ", style="#22c55e")
text.append("Penetration test completed", style="bold #22c55e")
if executive_summary:
text.append("\n\n")
text.append("Executive Summary", style=FIELD_STYLE)
text.append("\n")
text.append(executive_summary)
if methodology:
text.append("\n\n")
text.append("Methodology", style=FIELD_STYLE)
text.append("\n")
text.append(methodology)
if technical_analysis:
text.append("\n\n")
text.append("Technical Analysis", style=FIELD_STYLE)
text.append("\n")
text.append(technical_analysis)
if recommendations:
text.append("\n\n")
text.append("Recommendations", style=FIELD_STYLE)
text.append("\n")
text.append(recommendations)
if not (executive_summary or methodology or technical_analysis or recommendations):
text.append("\n ")
text.append("Generating final 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)
================================================
FILE: strix/interface/tool_components/load_skill_renderer.py
================================================
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 LoadSkillRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "load_skill"
css_classes: ClassVar[list[str]] = ["tool-call", "load-skill-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "completed")
requested = args.get("skills", "")
text = Text()
text.append("◇ ", style="#10b981")
text.append("loading skill", style="dim")
if requested:
text.append(" ")
text.append(requested, style="#10b981")
elif not tool_data.get("result"):
text.append("\n ")
text.append("Loading...", style="dim")
return Static(text, classes=cls.get_css_classes(status))
================================================
FILE: strix/interface/tool_components/notes_renderer.py
================================================
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 CreateNoteRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "create_note"
css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
title = args.get("title", "")
content = args.get("content", "")
category = args.get("category", "general")
text = Text()
text.append("◇ ", style="#fbbf24")
text.append("note", style="dim")
text.append(" ")
text.append(f"({category})", style="dim")
if title:
text.append("\n ")
text.append(title.strip())
if content:
text.append("\n ")
text.append(content.strip(), style="dim")
if not title and not content:
text.append("\n ")
text.append("Capturing...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class DeleteNoteRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "delete_note"
css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: ARG003
text = Text()
text.append("◇ ", style="#fbbf24")
text.append("note removed", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class UpdateNoteRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "update_note"
css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
title = args.get("title")
content = args.get("content")
text = Text()
text.append("◇ ", style="#fbbf24")
text.append("note updated", style="dim")
if title:
text.append("\n ")
text.append(title)
if content:
text.append("\n ")
text.append(content.strip(), style="dim")
if not title and not content:
text.append("\n ")
text.append("Updating...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class ListNotesRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "list_notes"
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("notes", style="dim")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict) and result.get("success"):
count = result.get("total_count", 0)
notes = result.get("notes", []) or []
if count == 0:
text.append("\n ")
text.append("No notes", style="dim")
else:
for note in notes:
title = note.get("title", "").strip() or "(untitled)"
category = note.get("category", "general")
note_content = note.get("content", "").strip()
text.append("\n - ")
text.append(title)
text.append(f" ({category})", style="dim")
if note_content:
text.append("\n ")
text.append(note_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)
================================================
FILE: strix/interface/tool_components/proxy_renderer.py
================================================
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
PROXY_ICON = "<~>"
MAX_REQUESTS_DISPLAY = 20
MAX_LINE_LENGTH = 200
def _truncate(text: str, max_len: int = 80) -> str:
return text[: max_len - 3] + "..." if len(text) > max_len else text
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)
def _status_style(code: int | None) -> str:
if code is None:
return "dim"
if 200 <= code < 300:
return "#22c55e" # green
if 300 <= code < 400:
return "#eab308" # yellow
if 400 <= code < 500:
return "#f97316" # orange
if code >= 500:
return "#ef4444" # red
return "dim"
@register_tool_renderer
class ListRequestsRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "list_requests"
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912 # noqa: PLR0912
args = tool_data.get("args", {})
result = tool_data.get("result")
status = tool_data.get("status", "running")
httpql_filter = args.get("httpql_filter")
sort_by = args.get("sort_by")
sort_order = args.get("sort_order")
scope_id = args.get("scope_id")
text = Text()
text.append(PROXY_ICON, style="dim")
text.append(" listing requests", style="#06b6d4")
if httpql_filter:
text.append(f" where {_truncate(httpql_filter, 150)}", style="dim italic")
meta_parts = []
if sort_by and sort_by != "timestamp":
meta_parts.append(f"by:{sort_by}")
if sort_order and sort_order != "desc":
meta_parts.append(sort_order)
if scope_id and isinstance(scope_id, str):
meta_parts.append(f"scope:{scope_id[:8]}")
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)
requests = result.get("requests", [])
text.append(f" [{total} found]", style="dim")
if requests and isinstance(requests, list):
text.append("\n")
for i, req in enumerate(requests[:MAX_REQUESTS_DISPLAY]):
if not isinstance(req, 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
text.append(" ")
text.append(f"{method:6}", style="#a78bfa")
text.append(f" {_truncate(host + path, 180)}", style="dim")
if code:
text.append(f" {code}", style=_status_style(code))
if i < min(len(requests), MAX_REQUESTS_DISPLAY) - 1:
text.append("\n")
if len(requests) > MAX_REQUESTS_DISPLAY:
text.append("\n")
text.append(
f" ... +{len(requests) - MAX_REQUESTS_DISPLAY} more",
style="dim italic",
)
css_classes = cls.get_css_classes(status)
return Static(text, classes=css_classes)
@register_tool_renderer
class ViewRequestRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "view_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")
request_id = args.get("request_id", "")
part = args.get("part", "request")
search_pattern = args.get("search_pattern")
text = Text()
text.append(PROXY_ICON, style="dim")
action = "searching" if search_pattern else "viewing"
text.append(f" {action} {part}", style="#06b6d4")
if request_id:
text.append(f" #{request_id}", style="dim")
if search_pattern:
text.append(f" /{_truncate(search_pattern, 100)}/", style="dim italic")
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))
text.append(f" [{total} matches]", style="dim")
if matches and isinstance(matches, list):
text.append("\n")
for i, m in enumerate(matches[:5]):
if not isinstance(m, dict):
continue
before = m.get("before", "") or ""
match_text = m.get("match", "") or ""
after = m.get("after", "") or ""
before = before.replace("\n", " ").replace("\r", "")[-100:]
after = after.replace("\n", " ").replace("\r", "")[:100]
text.append(" ")
if before:
text.append(f"...{before}", style="dim")
text.append(match_text, style="#22c55e bold")
if after:
text.append(f"{after}...", style="dim")
if i < min(len(matches), 5) - 1:
text.append("\n")
if len(matches) > 5:
text.append("\n")
text.append(f" ... +{len(matches) - 5} more matches", style="dim italic")
elif "content" in result:
showing = result.get("showing_lines", "")
has_more = result.get("has_more", False)
content = result.get("content", "")
text.append(f" [{showing}]", style="dim")
if content and isinstance(content, str):
lines = content.split("\n")[:15]
text.append("\n")
for i, line in enumerate(lines):
text.append(" ")
text.append(_truncate(line, MAX_LINE_LENGTH), style="dim")
if i < len(lines) - 1:
text.append("\n")
if has_more or len(lines) > 15:
text.append("\n")
text.append(" ... more content available", style="dim italic")
css_classes = cls.get_css_classes(status)
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"
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")
request_id = args.get("request_id", "")
modifications = args.get("modifications")
text = Text()
text.append(PROXY_ICON, style="dim")
text.append(" repeating request", style="#06b6d4")
if request_id:
text.append(f" #{request_id}", style="dim")
if modifications and isinstance(modifications, dict):
text.append("\n modifications:", style="dim italic")
if "url" in modifications:
text.append("\n")
text.append(" >> ", style="#3b82f6")
text.append(f"url: {_truncate(str(modifications['url']), 180)}", style="dim")
if "headers" in modifications and isinstance(modifications["headers"], dict):
for k, v in list(modifications["headers"].items())[:5]:
text.append("\n")
text.append(" >> ", style="#3b82f6")
text.append(f"{k}: {_sanitize(str(v), 150)}", style="dim")
if "cookies" in modifications and isinstance(modifications["cookies"], dict):
for k, v in list(modifications["cookies"].items())[:5]:
text.append("\n")
text.append(" >> ", style="#3b82f6")
text.append(f"cookie {k}={_sanitize(str(v), 100)}", style="dim")
if "params" in modifications and isinstance(modifications["params"], dict):
for k, v in list(modifications["params"].items())[:5]:
text.append("\n")
text.append(" >> ", style="#3b82f6")
text.append(f"param {k}={_sanitize(str(v), 100)}", style="dim")
if "body" in modifications and isinstance(modifications["body"], str):
text.append("\n")
text.append(" >> ", style="#3b82f6")
body_lines = modifications["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(modifications["body"].split("\n")) > 4:
text.append(" ...", style="dim italic")
elif modifications and isinstance(modifications, str):
text.append(f"\n {_truncate(modifications, 200)}", 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:
req = result.get("request", {})
method = req.get("method", "")
url = req.get("url", "")
code = result.get("status_code")
time_ms = result.get("response_time_ms")
text.append("\n")
text.append(" >> ", style="#3b82f6")
if method:
text.append(f"{method} ", style="#a78bfa")
if url:
text.append(_truncate(url, 180), style="dim")
text.append("\n")
text.append(" << ", style="#22c55e")
if code:
text.append(f"{code}", style=_status_style(code))
if time_ms:
text.append(f" ({time_ms}ms)", style="dim")
body = result.get("body", "")
if body and isinstance(body, str):
lines = body.split("\n")[:5]
for line in lines:
text.append("\n")
text.append(" << ", style="#22c55e")
text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim")
if len(body.split("\n")) > 5:
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 ScopeRulesRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "scope_rules"
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")
action = args.get("action", "")
scope_name = args.get("scope_name", "")
scope_id = args.get("scope_id", "")
allowlist = args.get("allowlist")
denylist = args.get("denylist")
text = Text()
text.append(PROXY_ICON, style="dim")
action_map = {
"get": "getting",
"list": "listing",
"create": "creating",
"update": "updating",
"delete": "deleting",
}
action_text = action_map.get(action, action + "ing" if action else "managing")
text.append(f" {action_text} proxy scope", style="#06b6d4")
if scope_name:
text.append(f" '{_truncate(scope_name, 50)}'", style="dim italic")
if scope_id and isinstance(scope_id, str):
text.append(f" #{scope_id[:8]}", style="dim")
if allowlist and isinstance(allowlist, list):
allow_str = ", ".join(_truncate(str(a), 40) for a in allowlist[:4])
text.append(f"\n allow: {allow_str}", style="dim")
if len(allowlist) > 4:
text.append(f" +{len(allowlist) - 4}", style="dim italic")
if denylist and isinstance(denylist, list):
deny_str = ", ".join(_truncate(str(d), 40) for d in denylist[:4])
text.append(f"\n deny: {deny_str}", style="dim")
if len(denylist) > 4:
text.append(f" +{len(denylist) - 4}", style="dim italic")
if status == "completed" and isinstance(result, dict):
if "error" in result:
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
elif "scopes" in result:
scopes = result.get("scopes", [])
text.append(f" [{len(scopes)} scopes]", style="dim")
if scopes and isinstance(scopes, list):
text.append("\n")
for i, scope in enumerate(scopes[:5]):
if not isinstance(scope, dict):
continue
name = scope.get("name", "?")
allow = scope.get("allowlist") or []
text.append(" ")
text.append(_truncate(str(name), 40), style="#22c55e")
if allow and isinstance(allow, list):
allow_str = ", ".join(_truncate(str(a), 30) for a in allow[:3])
text.append(f" {allow_str}", style="dim")
if len(allow) > 3:
text.append(f" +{len(allow) - 3}", style="dim italic")
if i < min(len(scopes), 5) - 1:
text.append("\n")
elif "scope" in result:
scope = result.get("scope") or {}
if isinstance(scope, dict):
allow = scope.get("allowlist") or []
deny = scope.get("denylist") or []
if allow and isinstance(allow, list):
allow_str = ", ".join(_truncate(str(a), 40) for a in allow[:5])
text.append(f"\n allow: {allow_str}", style="dim")
if deny and isinstance(deny, list):
deny_str = ", ".join(_truncate(str(d), 40) for d in deny[:5])
text.append(f"\n deny: {deny_str}", style="dim")
elif "message" in result:
text.append(f" {result['message']}", style="#22c55e")
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)
================================================
FILE: strix/interface/tool_components/python_renderer.py
================================================
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)
================================================
FILE: strix/interface/tool_components/registry.py
================================================
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
class ToolTUIRegistry:
_renderers: ClassVar[dict[str, type[BaseToolRenderer]]] = {}
@classmethod
def register(cls, renderer_class: type[BaseToolRenderer]) -> None:
if not renderer_class.tool_name:
raise ValueError(f"Renderer {renderer_class.__name__} must define tool_name")
cls._renderers[renderer_class.tool_name] = renderer_class
@classmethod
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)
return renderer_class
def get_tool_renderer(tool_name: str) -> type[BaseToolRenderer] | None:
return ToolTUIRegistry.get_renderer(tool_name)
def render_tool_widget(tool_data: dict[str, Any]) -> Static:
tool_name = tool_data.get("tool_name", "")
renderer = get_tool_renderer(tool_name)
if renderer:
return renderer.render(tool_data)
return _render_default_tool_widget(tool_data)
def _render_default_tool_widget(tool_data: dict[str, Any]) -> Static:
tool_name = tool_data.get("tool_name", "Unknown Tool")
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
text = Text()
text.append("→ Using tool ", style="dim")
text.append(tool_name, style="bold blue")
text.append("\n")
for k, v in list(args.items()):
str_v = str(v)
text.append(" ")
text.append(k, style="dim")
text.append(": ")
text.append(str_v)
text.append("\n")
if status in ["completed", "failed", "error"] and result is not None:
result_str = str(result)
text.append("Result: ", style="bold")
text.append(result_str)
else:
icon, color = BaseToolRenderer.status_icon(status)
text.append(icon, style=color)
css_classes = BaseToolRenderer.get_css_classes(status)
return Static(text, classes=css_classes)
================================================
FILE: strix/interface/tool_components/reporting_renderer.py
================================================
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)
================================================
FILE: strix/interface/tool_components/scan_info_renderer.py
================================================
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)
================================================
FILE: strix/interface/tool_components/terminal_renderer.py
================================================
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)
================================================
FILE: strix/interface/tool_components/thinking_renderer.py
================================================
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 ThinkRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "think"
css_classes: ClassVar[list[str]] = ["tool-call", "thinking-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
thought = args.get("thought", "")
text = Text()
text.append("🧠 ")
text.append("Thinking", style="bold #a855f7")
text.append("\n ")
if thought:
text.append(thought, style="italic dim")
else:
text.append("Thinking...", style="italic dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
================================================
FILE: strix/interface/tool_components/todo_renderer.py
================================================
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
STATUS_MARKERS: dict[str, str] = {
"pending": "[ ]",
"in_progress": "[~]",
"done": "[•]",
}
def _format_todo_lines(text: Text, result: dict[str, Any]) -> None:
todos = result.get("todos")
if not isinstance(todos, list) or not todos:
text.append("\n ")
text.append("No todos", style="dim")
return
for todo in todos:
status = todo.get("status", "pending")
marker = STATUS_MARKERS.get(status, STATUS_MARKERS["pending"])
title = todo.get("title", "").strip() or "(untitled)"
text.append("\n ")
text.append(marker)
text.append(" ")
if status == "done":
text.append(title, style="dim strike")
elif status == "in_progress":
text.append(title, style="italic")
else:
text.append(title)
@register_tool_renderer
class CreateTodoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "create_todo"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to create todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Creating...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class ListTodosRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "list_todos"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todos", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Unable to list todos")
text.append("\n ")
text.append(error, style="#ef4444")
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 UpdateTodoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "update_todo"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Updated", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to update todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Updating...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class MarkTodoDoneRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "mark_todo_done"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Completed", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to mark todo done")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Marking done...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class MarkTodoPendingRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "mark_todo_pending"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Reopened", style="bold #f59e0b")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to reopen todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Reopening...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class DeleteTodoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "delete_todo"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Removed", style="bold #94a3b8")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to remove todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Removing...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
================================================
FILE: strix/interface/tool_components/user_message_renderer.py
================================================
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))
@classmethod
def render_simple(cls, content: str) -> Text:
if not content:
return Text()
return cls._format_user_message(content)
@classmethod
def _format_user_message(cls, content: str) -> Text:
text = Text()
text.append("▍", style="#3b82f6")
text.append(" ")
text.append("You:", style="bold")
text.append("\n")
lines = content.split("\n")
for i, line in enumerate(lines):
if i > 0:
text.append("\n")
text.append("▍", style="#3b82f6")
text.append(" ")
text.append(line)
return text
================================================
FILE: strix/interface/tool_components/web_search_renderer.py
================================================
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 WebSearchRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "web_search"
css_classes: ClassVar[list[str]] = ["tool-call", "web-search-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
query = args.get("query", "")
text = Text()
text.append("🌐 ")
text.append("Searching the web...", style="bold #60a5fa")
if query:
text.append("\n ")
text.append(query, style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
================================================
FILE: strix/interface/tui.py
================================================
import argparse
import asyncio
import atexit
import logging
import signal
import sys
import threading
from collections.abc import Callable
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version
from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from textual.timer import Timer
from rich.align import Align
from rich.console import Group
from rich.panel import Panel
from rich.style import Style
from rich.text import Span, Text
from textual import events, on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
from textual.reactive import reactive
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Static, TextArea, Tree
from textual.widgets.tree import TreeNode
from strix.agents.StrixAgent import StrixAgent
from strix.interface.streaming_parser import parse_streaming_content
from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer
from strix.interface.tool_components.registry import get_tool_renderer
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
from strix.interface.utils import build_tui_stats_text
from strix.llm.config import LLMConfig
from strix.telemetry.tracer import Tracer, set_global_tracer
logger = logging.getLogger(__name__)
def get_package_version() -> str:
try:
return pkg_version("strix-agent")
except PackageNotFoundError:
return "dev"
class ChatTextArea(TextArea): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._app_reference: StrixTUIApp | None = None
def set_app_reference(self, app: "StrixTUIApp") -> None:
self._app_reference = app
def on_mount(self) -> None:
self._update_height()
def _on_key(self, event: events.Key) -> None:
if event.key == "shift+enter":
self.insert("\n")
event.prevent_default()
return
if event.key == "enter" and self._app_reference:
text_content = str(self.text) # type: ignore[has-type]
message = text_content.strip()
if message:
self.text = ""
self._app_reference._send_user_message(message)
event.prevent_default()
return
super()._on_key(event)
@on(TextArea.Changed) # type: ignore[misc]
def _update_height(self, _event: TextArea.Changed | None = None) -> None:
if not self.parent:
return
line_count = self.document.line_count
target_lines = min(max(1, line_count), 8)
new_height = target_lines + 2
if self.parent.styles.height != new_height:
self.parent.styles.height = new_height
self.scroll_cursor_visible()
class SplashScreen(Static): # type: ignore[misc]
ALLOW_SELECT = False
PRIMARY_GREEN = "#22c55e"
BANNER = (
" ███████╗████████╗██████╗ ██╗██╗ ██╗\n"
" ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝\n"
" ███████╗ ██║ ██████╔╝██║ ╚███╔╝\n"
" ╚════██║ ██║ ██╔══██╗██║ ██╔██╗\n"
" ███████║ ██║ ██║ ██║██║██╔╝ ██╗\n"
" ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝"
)
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._animation_step = 0
self._animation_timer: Timer | None = None
self._panel_static: Static | None = None
self._version = "dev"
def compose(self) -> ComposeResult:
self._version = get_package_version()
self._animation_step = 0
start_line = self._build_start_line_text(self._animation_step)
panel = self._build_panel(start_line)
panel_static = Static(panel, id="splash_content")
self._panel_static = panel_static
yield panel_static
def on_mount(self) -> None:
self._animation_timer = self.set_interval(0.05, self._animate_start_line)
def on_unmount(self) -> None:
if self._animation_timer is not None:
self._animation_timer.stop()
self._animation_timer = None
def _animate_start_line(self) -> None:
if not self._panel_static:
return
self._animation_step += 1
start_line = self._build_start_line_text(self._animation_step)
panel = self._build_panel(start_line)
self._panel_static.update(panel)
def _build_panel(self, start_line: Text) -> Panel:
content = Group(
Align.center(Text(self.BANNER.strip("\n"), style=self.PRIMARY_GREEN, justify="center")),
Align.center(Text(" ")),
Align.center(self._build_welcome_text()),
Align.center(self._build_version_text()),
Align.center(self._build_tagline_text()),
Align.center(Text(" ")),
Align.center(start_line.copy()),
Align.center(Text(" ")),
Align.center(self._build_url_text()),
)
return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))
def _build_url_text(self) -> Text:
return Text("strix.ai", style=Style(color=self.PRIMARY_GREEN, bold=True))
def _build_welcome_text(self) -> Text:
text = Text("Welcome to ", style=Style(color="white", bold=True))
text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True))
text.append("!", style=Style(color="white", bold=True))
return text
def _build_version_text(self) -> Text:
return Text(f"v{self._version}", style=Style(color="white", dim=True))
def _build_tagline_text(self) -> Text:
return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True))
def _build_start_line_text(self, phase: int) -> Text:
full_text = "Starting Strix Agent"
text_len = len(full_text)
shine_pos = phase % (text_len + 8)
text = Text()
for i, char in enumerate(full_text):
dist = abs(i - shine_pos)
if dist <= 1:
style = Style(color="bright_white", bold=True)
elif dist <= 3:
style = Style(color="white", bold=True)
elif dist <= 5:
style = Style(color="#a3a3a3")
else:
style = Style(color="#525252")
text.append(char, style=style)
return text
class HelpScreen(ModalScreen): # type: ignore[misc]
def compose(self) -> ComposeResult:
yield Grid(
Label("Strix Help", id="help_title"),
Label(
"F1 Help\nCtrl+Q/C Quit\nESC Stop Agent\n"
"Enter Send message to agent\nTab Switch panels\n↑/↓ Navigate tree",
id="help_content",
),
id="dialog",
)
def on_key(self, _event: events.Key) -> None:
self.app.pop_screen()
class StopAgentScreen(ModalScreen): # type: ignore[misc]
def __init__(self, agent_name: str, agent_id: str):
super().__init__()
self.agent_name = agent_name
self.agent_id = agent_id
def compose(self) -> ComposeResult:
yield Grid(
Label(f"🛑 Stop '{self.agent_name}'?", id="stop_agent_title"),
Grid(
Button("Yes", variant="error", id="stop_agent"),
Button("No", variant="default", id="cancel_stop"),
id="stop_agent_buttons",
),
id="stop_agent_dialog",
)
def on_mount(self) -> None:
cancel_button = self.query_one("#cancel_stop", Button)
cancel_button.focus()
def on_key(self, event: events.Key) -> None:
if event.key in ("left", "right", "up", "down"):
focused = self.focused
if focused and focused.id == "stop_agent":
cancel_button = self.query_one("#cancel_stop", Button)
cancel_button.focus()
else:
stop_button = self.query_one("#stop_agent", Button)
stop_button.focus()
event.prevent_default()
elif event.key == "enter":
focused = self.focused
if focused and isinstance(focused, Button):
focused.press()
event.prevent_default()
elif event.key == "escape":
self.app.pop_screen()
event.prevent_default()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.app.pop_screen()
if event.button.id == "stop_agent":
self.app.action_confirm_stop_agent(self.agent_id)
class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
"""Modal screen to display vulnerability details."""
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
"critical": "#dc2626", # Red
"high": "#ea580c", # Orange
"medium": "#d97706", # Amber
"low": "#22c55e", # Green
"info": "#3b82f6", # Blue
}
FIELD_STYLE: ClassVar[str] = "bold #4ade80"
def __init__(self, vulnerability: dict[str, Any]) -> None:
super().__init__()
self.vulnerability = vulnerability
def compose(self) -> ComposeResult:
content = self._render_vulnerability()
yield Grid(
VerticalScroll(Static(content, id="vuln_detail_content"), id="vuln_detail_scroll"),
Horizontal(
Button("Copy", variant="default", id="copy_vuln_detail"),
Button("Done", variant="default", id="close_vuln_detail"),
id="vuln_detail_buttons",
),
id="vuln_detail_dialog",
)
def on_mount(self) -> None:
close_button = self.query_one("#close_vuln_detail", Button)
close_button.focus()
def _get_cvss_color(self, 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"
def _highlight_python(self, code: str) -> Text:
try:
from pygments.lexers import PythonLexer
from pygments.styles import get_style_by_name
lexer = PythonLexer()
style = get_style_by_name("native")
colors = {
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
}
text = Text()
for token_type, token_value in lexer.get_tokens(code):
if not token_value:
continue
color = None
tt = token_type
while tt:
if tt in colors:
color = colors[tt]
break
tt = tt.parent
text.append(token_value, style=color)
except (ImportError, KeyError, AttributeError):
return Text(code)
else:
return text
def _render_vulnerability(self) -> Text: # noqa: PLR0912, PLR0915
vuln = self.vulnerability
text = Text()
text.append("🐞 ")
text.append("Vulnerability Report", style="bold #ea580c")
agent_name = vuln.get("agent_name", "")
if agent_name:
text.append("\n\n")
text.append("Agent: ", style=self.FIELD_STYLE)
text.append(agent_name)
title = vuln.get("title", "")
if title:
text.append("\n\n")
text.append("Title: ", style=self.FIELD_STYLE)
text.append(title)
severity = vuln.get("severity", "")
if severity:
text.append("\n\n")
text.append("Severity: ", style=self.FIELD_STYLE)
severity_color = self.SEVERITY_COLORS.get(severity.lower(), "#6b7280")
text.append(severity.upper(), style=f"bold {severity_color}")
cvss_score = vuln.get("cvss")
if cvss_score is not None:
text.append("\n\n")
text.append("CVSS Score: ", style=self.FIELD_STYLE)
cvss_color = self._get_cvss_color(float(cvss_score))
text.append(str(cvss_score), style=f"bold {cvss_color}")
target = vuln.get("target", "")
if target:
text.append("\n\n")
text.append("Target: ", style=self.FIELD_STYLE)
text.append(target)
endpoint = vuln.get("endpoint", "")
if endpoint:
text.append("\n\n")
text.append("Endpoint: ", style=self.FIELD_STYLE)
text.append(endpoint)
method = vuln.get("method", "")
if method:
text.append("\n\n")
text.append("Method: ", style=self.FIELD_STYLE)
text.append(method)
cve = vuln.get("cve", "")
if cve:
text.append("\n\n")
text.append("CVE: ", style=self.FIELD_STYLE)
text.append(cve)
# CVSS breakdown
cvss_breakdown = vuln.get("cvss_breakdown", {})
if cvss_breakdown:
cvss_parts = []
if cvss_breakdown.get("attack_vector"):
cvss_parts.append(f"AV:{cvss_breakdown['attack_vector']}")
if cvss_breakdown.get("attack_complexity"):
cvss_parts.append(f"AC:{cvss_breakdown['attack_complexity']}")
if cvss_breakdown.get("privileges_required"):
cvss_parts.append(f"PR:{cvss_breakdown['privileges_required']}")
if cvss_breakdown.get("user_interaction"):
cvss_parts.append(f"UI:{cvss_breakdown['user_interaction']}")
if cvss_breakdown.get("scope"):
cvss_parts.append(f"S:{cvss_breakdown['scope']}")
if cvss_breakdown.get("confidentiality"):
cvss_parts.append(f"C:{cvss_breakdown['confidentiality']}")
if cvss_breakdown.get("integrity"):
cvss_parts.append(f"I:{cvss_breakdown['integrity']}")
if cvss_breakdown.get("availability"):
cvss_parts.append(f"A:{cvss_breakdown['availability']}")
if cvss_parts:
text.append("\n\n")
text.append("CVSS Vector: ", style=self.FIELD_STYLE)
text.append("/".join(cvss_parts), style="dim")
description = vuln.get("description", "")
if description:
text.append("\n\n")
text.append("Description", style=self.FIELD_STYLE)
text.append("\n")
text.append(description)
impact = vuln.get("impact", "")
if impact:
text.append("\n\n")
text.append("Impact", style=self.FIELD_STYLE)
text.append("\n")
text.append(impact)
technical_analysis = vuln.get("technical_analysis", "")
if technical_analysis:
text.append("\n\n")
text.append("Technical Analysis", style=self.FIELD_STYLE)
text.append("\n")
text.append(technical_analysis)
poc_description = vuln.get("poc_description", "")
if poc_description:
text.append("\n\n")
text.append("PoC Description", style=self.FIELD_STYLE)
text.append("\n")
text.append(poc_description)
poc_script_code = vuln.get("poc_script_code", "")
if poc_script_code:
text.append("\n\n")
text.append("PoC Code", style=self.FIELD_STYLE)
text.append("\n")
text.append_text(self._highlight_python(poc_script_code))
remediation_steps = vuln.get("remediation_steps", "")
if remediation_steps:
text.append("\n\n")
text.append("Remediation", style=self.FIELD_STYLE)
text.append("\n")
text.append(remediation_steps)
return text
def _get_markdown_report(self) -> str: # noqa: PLR0912, PLR0915
"""Get Markdown version of vulnerability report for clipboard."""
vuln = self.vulnerability
lines: list[str] = []
# Title
title = vuln.get("title", "Untitled Vulnerability")
lines.append(f"# {title}")
lines.append("")
# Metadata
if vuln.get("id"):
lines.append(f"**ID:** {vuln['id']}")
if vuln.get("severity"):
lines.append(f"**Severity:** {vuln['severity'].upper()}")
if vuln.get("timestamp"):
lines.append(f"**Found:** {vuln['timestamp']}")
if vuln.get("agent_name"):
lines.append(f"**Agent:** {vuln['agent_name']}")
if vuln.get("target"):
lines.append(f"**Target:** {vuln['target']}")
if vuln.get("endpoint"):
lines.append(f"**Endpoint:** {vuln['endpoint']}")
if vuln.get("method"):
lines.append(f"**Method:** {vuln['method']}")
if vuln.get("cve"):
lines.append(f"**CVE:** {vuln['cve']}")
if vuln.get("cvss") is not None:
lines.append(f"**CVSS:** {vuln['cvss']}")
# CVSS Vector
cvss_breakdown = vuln.get("cvss_breakdown", {})
if cvss_breakdown:
abbrevs = {
"attack_vector": "AV",
"attack_complexity": "AC",
"privileges_required": "PR",
"user_interaction": "UI",
"scope": "S",
"confidentiality": "C",
"integrity": "I",
"availability": "A",
}
parts = [
f"{abbrevs.get(k, k)}:{v}" for k, v in cvss_breakdown.items() if v and k in abbrevs
]
if parts:
lines.append(f"**CVSS Vector:** {'/'.join(parts)}")
# Description
lines.append("")
lines.append("## Description")
lines.append("")
lines.append(vuln.get("description") or "No description provided.")
# Impact
if vuln.get("impact"):
lines.extend(["", "## Impact", "", vuln["impact"]])
# Technical Analysis
if vuln.get("technical_analysis"):
lines.extend(["", "## Technical Analysis", "", vuln["technical_analysis"]])
# Proof of Concept
if vuln.get("poc_description") or vuln.get("poc_script_code"):
lines.extend(["", "## Proof of Concept", ""])
if vuln.get("poc_description"):
lines.append(vuln["poc_description"])
lines.append("")
if vuln.get("poc_script_code"):
lines.append("```python")
lines.append(vuln["poc_script_code"])
lines.append("```")
# Code Analysis
if vuln.get("code_locations"):
lines.extend(["", "## Code Analysis", ""])
for i, loc in enumerate(vuln["code_locations"]):
file_ref = loc.get("file", "unknown")
line_ref = ""
if loc.get("start_line") is not None:
if loc.get("end_line") and loc["end_line"] != loc["start_line"]:
line_ref = f" (lines {loc['start_line']}-{loc['end_line']})"
else:
line_ref = f" (line {loc['start_line']})"
lines.append(f"**Location {i + 1}:** `{file_ref}`{line_ref}")
if loc.get("label"):
lines.append(f" {loc['label']}")
if loc.get("snippet"):
lines.append(f"```\n{loc['snippet']}\n```")
if loc.get("fix_before") or loc.get("fix_after"):
lines.append("**Suggested Fix:**")
lines.append("```diff")
if loc.get("fix_before"):
lines.extend(f"- {line}" for line in loc["fix_before"].splitlines())
if loc.get("fix_after"):
lines.extend(f"+ {line}" for line in loc["fix_after"].splitlines())
lines.append("```")
lines.append("")
# Remediation
if vuln.get("remediation_steps"):
lines.extend(["", "## Remediation", "", vuln["remediation_steps"]])
lines.append("")
return "\n".join(lines)
def on_key(self, event: events.Key) -> None:
if event.key == "escape":
self.app.pop_screen()
event.prevent_default()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "copy_vuln_detail":
markdown_text = self._get_markdown_report()
self.app.copy_to_clipboard(markdown_text)
copy_button = self.query_one("#copy_vuln_detail", Button)
copy_button.label = "Copied!"
self.set_timer(1.5, lambda: setattr(copy_button, "label", "Copy"))
elif event.button.id == "close_vuln_detail":
self.app.pop_screen()
class VulnerabilityItem(Static): # type: ignore[misc]
"""A clickable vulnerability item."""
def __init__(self, label: Text, vuln_data: dict[str, Any], **kwargs: Any) -> None:
super().__init__(label, **kwargs)
self.vuln_data = vuln_data
def on_click(self, _event: events.Click) -> None:
"""Handle click to open vulnerability detail."""
self.app.push_screen(VulnerabilityDetailScreen(self.vuln_data))
class VulnerabilitiesPanel(VerticalScroll): # type: ignore[misc]
"""A scrollable panel showing found vulnerabilities with severity-colored dots."""
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
"critical": "#dc2626", # Red
"high": "#ea580c", # Orange
"medium": "#d97706", # Amber
"low": "#22c55e", # Green
"info": "#3b82f6", # Blue
}
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._vulnerabilities: list[dict[str, Any]] = []
def compose(self) -> ComposeResult:
return []
def update_vulnerabilities(self, vulnerabilities: list[dict[str, Any]]) -> None:
"""Update the list of vulnerabilities and re-render."""
if self._vulnerabilities == vulnerabilities:
return
self._vulnerabilities = list(vulnerabilities)
self._render_panel()
def _render_panel(self) -> None:
"""Render the vulnerabilities panel content."""
for child in list(self.children):
if isinstance(child, VulnerabilityItem):
child.remove()
if not self._vulnerabilities:
return
for vuln in self._vulnerabilities:
severity = vuln.get("severity", "info").lower()
title = vuln.get("title", "Unknown Vulnerability")
color = self.SEVERITY_COLORS.get(severity, "#3b82f6")
label = Text()
label.append("● ", style=Style(color=color))
label.append(title, style=Style(color="#d4d4d4"))
item = VulnerabilityItem(label, vuln, classes="vuln-item")
self.mount(item)
class QuitScreen(ModalScreen): # type: ignore[misc]
def compose(self) -> ComposeResult:
yield Grid(
Label("Quit Strix?", id="quit_title"),
Grid(
Button("Yes", variant="error", id="quit"),
Button("No", variant="default", id="cancel"),
id="quit_buttons",
),
id="quit_dialog",
)
def on_mount(self) -> None:
cancel_button = self.query_one("#cancel", Button)
cancel_button.focus()
def on_key(self, event: events.Key) -> None:
if event.key in ("left", "right", "up", "down"):
focused = self.focused
if focused and focused.id == "quit":
cancel_button = self.query_one("#cancel", Button)
cancel_button.focus()
else:
quit_button = self.query_one("#quit", Button)
quit_button.focus()
event.prevent_default()
elif event.key == "enter":
focused = self.focused
if focused and isinstance(focused, Button):
focused.press()
event.prevent_default()
elif event.key == "escape":
self.app.pop_screen()
event.prevent_default()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "quit":
self.app.action_custom_quit()
else:
self.app.pop_screen()
class StrixTUIApp(App): # type: ignore[misc]
CSS_PATH = "assets/tui_styles.tcss"
ALLOW_SELECT = True
SIDEBAR_MIN_WIDTH = 120
selected_agent_id: reactive[str | None] = reactive(default=None)
show_splash: reactive[bool] = reactive(default=True)
BINDINGS: ClassVar[list[Binding]] = [
Binding("f1", "toggle_help", "Help", priority=True),
Binding("ctrl+q", "request_quit", "Quit", priority=True),
Binding("ctrl+c", "request_quit", "Quit", priority=True),
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
]
def __init__(self, args: argparse.Namespace):
super().__init__()
self.args = args
self.scan_config = self._build_scan_config(args)
self.agent_config = self._build_agent_config(args)
self.tracer = Tracer(self.scan_config["run_name"])
self.tracer.set_scan_config(self.scan_config)
set_global_tracer(self.tracer)
self.agent_nodes: dict[str, TreeNode] = {}
self._displayed_agents: set[str] = set()
self._displayed_events: list[str] = []
self._streaming_render_cache: dict[str, tuple[int, Any]] = {}
self._last_streaming_len: dict[str, int] = {}
self._scan_thread: threading.Thread | None = None
self._scan_stop_event = threading.Event()
self._scan_completed = threading.Event()
self._spinner_frame_index: int = 0 # Current animation frame index
self._sweep_num_squares: int = 6 # Number of squares in sweep animation
self._sweep_colors: list[str] = [
"#000000", # Dimmest (shows dot)
"#031a09",
"#052e16",
"#0d4a2a",
"#15803d",
"#22c55e",
"#4ade80",
"#86efac", # Brightest
]
self._dot_animation_timer: Any | None = None
self._setup_cleanup_handlers()
def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
return {
"scan_id": args.run_name,
"targets": args.targets_info,
"user_instructions": args.instruction or "",
"run_name": args.run_name,
}
def _build_agent_config(self, args: argparse.Namespace) -> dict[str, Any]:
scan_mode = getattr(args, "scan_mode", "deep")
llm_config = LLMConfig(scan_mode=scan_mode, interactive=True)
config = {
"llm_config": llm_config,
"max_iterations": 300,
}
if getattr(args, "local_sources", None):
config["local_sources"] = args.local_sources
return config
def _setup_cleanup_handlers(self) -> None:
def cleanup_on_exit() -> None:
from strix.runtime import cleanup_runtime
self.tracer.cleanup()
cleanup_runtime()
def signal_handler(_signum: int, _frame: Any) -> None:
self.tracer.cleanup()
sys.exit(0)
atexit.register(cleanup_on_exit)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, signal_handler)
def compose(self) -> ComposeResult:
if self.show_splash:
yield SplashScreen(id="splash_screen")
def watch_show_splash(self, show_splash: bool) -> None:
if not show_splash and self.is_mounted:
try:
splash = self.query_one("#splash_screen")
splash.remove()
except ValueError:
pass
main_container = Vertical(id="main_container")
self.mount(main_container)
content_container = Horizontal(id="content_container")
main_container.mount(content_container)
chat_area_container = Vertical(id="chat_area_container")
chat_display = Static("", id="chat_display")
chat_history = VerticalScroll(chat_display, id="chat_history")
chat_history.can_focus = True
status_text = Static("", id="status_text")
status_text.ALLOW_SELECT = False
keymap_indicator = Static("", id="keymap_indicator")
keymap_indicator.ALLOW_SELECT = False
agent_status_display = Horizontal(
status_text, keymap_indicator, id="agent_status_display", classes="hidden"
)
chat_prompt = Static("> ", id="chat_prompt")
chat_prompt.ALLOW_SELECT = False
chat_input = ChatTextArea(
"",
id="chat_input",
show_line_numbers=False,
)
chat_input.set_app_reference(self)
chat_input_container = Horizontal(chat_prompt, chat_input, id="chat_input_container")
agents_tree = Tree("Agents", id="agents_tree")
agents_tree.root.expand()
agents_tree.show_root = False
agents_tree.show_guide = True
agents_tree.guide_depth = 3
agents_tree.guide_style = "dashed"
stats_display = Static("", id="stats_display")
stats_scroll = VerticalScroll(stats_display, id="stats_scroll")
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
content_container.mount(chat_area_container)
content_container.mount(sidebar)
chat_area_container.mount(chat_history)
chat_area_container.mount(agent_status_display)
chat_area_container.mount(chat_input_container)
self.call_after_refresh(self._focus_chat_input)
def _focus_chat_input(self) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
try:
chat_input = self.query_one("#chat_input", ChatTextArea)
chat_input.show_vertical_scrollbar = False
chat_input.show_horizontal_scrollbar = False
chat_input.focus()
except (ValueError, Exception):
self.call_after_refresh(self._focus_chat_input)
def _focus_agents_tree(self) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
try:
agents_tree = self.query_one("#agents_tree", Tree)
agents_tree.focus()
if agents_tree.root.children:
first_node = agents_tree.root.children[0]
agents_tree.select_node(first_node)
except (ValueError, Exception):
self.call_after_refresh(self._focus_agents_tree)
def on_mount(self) -> None:
self.title = "strix"
self.set_timer(4.5, self._hide_splash_screen)
def _hide_splash_screen(self) -> None:
self.show_splash = False
self._start_scan_thread()
self.set_interval(0.35, self._update_ui_from_tracer)
def _update_ui_from_tracer(self) -> None:
if self.show_splash:
return
if len(self.screen_stack) > 1:
return
if not self.is_mounted:
return
try:
chat_history = self.query_one("#chat_history", VerticalScroll)
agents_tree = self.query_one("#agents_tree", Tree)
if not self._is_widget_safe(chat_history) or not self._is_widget_safe(agents_tree):
return
except (ValueError, Exception):
return
agent_updates = False
for agent_id, agent_data in list(self.tracer.agents.items()):
if agent_id not in self._displayed_agents:
self._add_agent_node(agent_data)
self._displayed_agents.add(agent_id)
agent_updates = True
elif self._update_agent_node(agent_id, agent_data):
agent_updates = True
if agent_updates:
self._expand_new_agent_nodes()
self._update_chat_view()
self._update_agent_status_display()
self._update_stats_display()
self._update_vulnerabilities_panel()
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
if agent_id not in self.agent_nodes:
return False
try:
agent_node = self.agent_nodes[agent_id]
agent_name_raw = agent_data.get("name", "Agent")
status = agent_data.get("status", "running")
status_indicators = {
"running": "⚪",
"waiting": "⏸",
"completed": "🟢",
"failed": "🔴",
"stopped": "■",
"stopping": "○",
"llm_failed": "🔴",
}
status_icon = status_indicators.get(status, "○")
vuln_count = self._agent_vulnerability_count(agent_id)
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
if agent_node.label != agent_name:
agent_node.set_label(agent_name)
return True
except (KeyError, AttributeError, ValueError) as e:
import logging
logging.warning(f"Failed to update agent node label: {e}")
return False
def _get_chat_content(
self,
) -> tuple[Any, str | None]:
if not self.selected_agent_id:
return self._get_chat_placeholder_content(
"Select an agent from the tree to see its activity.", "placeholder-no-agent"
)
events = self._gather_agent_events(self.selected_agent_id)
streaming = self.tracer.get_streaming_content(self.selected_agent_id)
if not events and not streaming:
return self._get_chat_placeholder_content(
"Starting agent...", "placeholder-no-activity"
)
current_event_ids = [e["id"] for e in events]
current_streaming_len = len(streaming) if streaming else 0
last_streaming_len = self._last_streaming_len.get(self.selected_agent_id, 0)
if (
current_event_ids == self._displayed_events
and current_streaming_len == last_streaming_len
):
return None, None
self._displayed_events = current_event_ids
self._last_streaming_len[self.selected_agent_id] = current_streaming_len
return self._get_rendered_events_content(events), "chat-content"
def _update_chat_view(self) -> None:
if len(self.screen_stack) > 1 or self.show_splash or not self.is_mounted:
return
try:
chat_history = self.query_one("#chat_history", VerticalScroll)
except (ValueError, Exception):
return
if not self._is_widget_safe(chat_history):
return
try:
is_at_bottom = chat_history.scroll_y >= chat_history.max_scroll_y
except (AttributeError, ValueError):
is_at_bottom = True
content, css_class = self._get_chat_content()
if content is None:
return
chat_display = self.query_one("#chat_display", Static)
self._safe_widget_operation(chat_display.update, content)
chat_display.set_classes(css_class)
if is_at_bottom:
self.call_later(chat_history.scroll_end, animate=False)
def _get_chat_placeholder_content(
self, message: str, placeholder_class: str
) -> tuple[Text, str]:
self._displayed_events = [placeholder_class]
text = Text()
text.append(message)
return text, f"chat-placeholder {placeholder_class}"
@staticmethod
def _merge_renderables(renderables: list[Any]) -> Text:
"""Merge renderables into a single Text for mouse text selection support."""
combined = Text()
for i, item in enumerate(renderables):
if i > 0:
combined.append("\n")
StrixTUIApp._append_renderable(combined, item)
return StrixTUIApp._sanitize_text(combined)
@staticmethod
def _sanitize_text(text: Text) -> Text:
"""Clamp spans so Rich/Textual can't crash on malformed offsets."""
plain = text.plain
text_length = len(plain)
sanitized_spans: list[Span] = []
for span in text.spans:
start = max(0, min(span.start, text_length))
end = max(0, min(span.end, text_length))
if end > start:
sanitized_spans.append(Span(start, end, span.style))
return Text(
plain,
style=text.style,
justify=text.justify,
overflow=text.overflow,
no_wrap=text.no_wrap,
end=text.end,
tab_size=text.tab_size,
spans=sanitized_spans,
)
@staticmethod
def _append_renderable(combined: Text, item: Any) -> None:
"""Recursively append a renderable's text content to a combined Text."""
if isinstance(item, Text):
combined.append_text(StrixTUIApp._sanitize_text(item))
elif isinstance(item, Group):
for j, sub in enumerate(item.renderables):
if j > 0:
combined.append("\n")
StrixTUIApp._append_renderable(combined, sub)
else:
inner = getattr(item, "renderable", None)
if inner is not None:
StrixTUIApp._append_renderable(combined, inner)
else:
combined.append(str(item))
def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any:
renderables: list[Any] = []
if not events:
return Text()
for event in events:
content: Any = None
if event["type"] == "chat":
content = self._render_chat_content(event["data"])
elif event["type"] == "tool":
content = self._render_tool_content_simple(event["data"])
if content:
if renderables:
renderables.append(Text(""))
renderables.append(content)
if self.selected_agent_id:
streaming = self.tracer.get_streaming_content(self.selected_agent_id)
if streaming:
streaming_text = self._render_streaming_content(streaming)
if streaming_text:
if renderables:
renderables.append(Text(""))
renderables.append(streaming_text)
if not renderables:
return Text()
if len(renderables) == 1 and isinstance(renderables[0], Text):
return self._sanitize_text(renderables[0])
return self._merge_renderables(renderables)
def _render_streaming_content(self, content: str, agent_id: str | None = None) -> Any:
cache_key = agent_id or self.selected_agent_id or ""
content_len = len(content)
if cache_key in self._streaming_render_cache:
cached_len, cached_output = self._streaming_render_cache[cache_key]
if cached_len == content_len:
return cached_output
renderables: list[Any] = []
segments = parse_streaming_content(content)
for segment in segments:
if segment.type == "text":
text_content = AgentMessageRenderer.render_simple(segment.content)
if renderables:
renderables.append(Text(""))
renderables.append(text_content)
elif segment.type == "tool":
tool_renderable = self._render_streaming_tool(
segment.tool_name or "unknown",
segment.args or {},
segment.is_complete,
)
if renderables:
renderables.append(Text(""))
renderables.append(tool_renderable)
if not renderables:
result = Text()
elif len(renderables) == 1 and isinstance(renderables[0], Text):
result = self._sanitize_text(renderables[0])
else:
result = self._merge_renderables(renderables)
self._streaming_render_cache[cache_key] = (content_len, result)
return result
def _render_streaming_tool(
self, tool_name: str, args: dict[str, str], is_complete: bool
) -> Any:
tool_data = {
"tool_name": tool_name,
"args": args,
"status": "completed" if is_complete else "running",
"result": None,
}
renderer = get_tool_renderer(tool_name)
if renderer:
widget = renderer.render(tool_data)
return widget.renderable
return self._render_default_streaming_tool(tool_name, args, is_complete)
def _render_default_streaming_tool(
self, tool_name: str, args: dict[str, str], is_complete: bool
) -> Text:
text = Text()
if is_complete:
text.append("✓ ", style="green")
else:
text.append("● ", style="yellow")
text.append("Using tool ", style="dim")
text.append(tool_name, style="bold blue")
if args:
for key, value in list(args.items())[:3]:
text.append("\n ")
text.append(key, style="dim")
text.append(": ")
display_value = value if len(value) <= 100 else value[:97] + "..."
text.append(display_value, style="italic" if not is_complete else None)
return text
def _get_status_display_content(
self, agent_id: str, agent_data: dict[str, Any]
) -> tuple[Text | None, Text, bool]:
status = agent_data.get("status", "running")
def keymap_styled(keys: list[tuple[str, str]]) -> Text:
t = Text()
for i, (key, action) in enumerate(keys):
if i > 0:
t.append(" · ", style="dim")
t.append(key, style="white")
t.append(" ", style="dim")
t.append(action, style="dim")
return t
simple_statuses: dict[str, tuple[str, str]] = {
"stopping": ("Agent stopping...", ""),
"stopped": ("Agent stopped", ""),
"completed": ("Agent completed", ""),
}
if status in simple_statuses:
msg, _ = simple_statuses[status]
text = Text()
text.append(msg)
return (text, Text(), False)
if status == "llm_failed":
error_msg = agent_data.get("error_message", "")
text = Text()
if error_msg:
text.append(error_msg, style="red")
else:
text.append("LLM request failed", style="red")
self._stop_dot_animation()
keymap = Text()
keymap.append("Send message to retry", style="dim")
return (text, keymap, False)
if status == "waiting":
keymap = Text()
keymap.append("Send message to resume", style="dim")
return (Text(" "), keymap, False)
if status == "running":
if self._agent_has_real_activity(agent_id):
animated_text = Text()
animated_text.append_text(self._get_sweep_animation(self._sweep_colors))
animated_text.append("esc", style="white")
animated_text.append(" ", style="dim")
animated_text.append("stop", style="dim")
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)
animated_text = self._get_animated_verb_text(agent_id, "Initializing")
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)
return (None, Text(), False)
def _update_agent_status_display(self) -> None:
try:
status_display = self.query_one("#agent_status_display", Horizontal)
status_text = self.query_one("#status_text", Static)
keymap_indicator = self.query_one("#keymap_indicator", Static)
except (ValueError, Exception):
return
widgets = [status_display, status_text, keymap_indicator]
if not all(self._is_widget_safe(w) for w in widgets):
return
if not self.selected_agent_id:
self._safe_widget_operation(status_display.add_class, "hidden")
return
try:
agent_data = self.tracer.agents[self.selected_agent_id]
content, keymap, should_animate = self._get_status_display_content(
self.selected_agent_id, agent_data
)
if not content:
self._safe_widget_operation(status_display.add_class, "hidden")
return
self._safe_widget_operation(status_text.update, content)
self._safe_widget_operation(keymap_indicator.update, keymap)
self._safe_widget_operation(status_display.remove_class, "hidden")
if should_animate:
self._start_dot_animation()
except (KeyError, Exception):
self._safe_widget_operation(status_display.add_class, "hidden")
def _update_stats_display(self) -> None:
try:
stats_display = self.query_one("#stats_display", Static)
except (ValueError, Exception):
return
if not self._is_widget_safe(stats_display):
return
if self.screen.selections:
return
stats_content = Text()
stats_text = build_tui_stats_text(self.tracer, self.agent_config)
if stats_text:
stats_content.append(stats_text)
version = get_package_version()
stats_content.append(f"\nv{version}", style="white")
self._safe_widget_operation(stats_display.update, stats_content)
def _update_vulnerabilities_panel(self) -> None:
"""Update the vulnerabilities panel with current vulnerability data."""
try:
vuln_panel = self.query_one("#vulnerabilities_panel", VulnerabilitiesPanel)
except (ValueError, Exception):
return
if not self._is_widget_safe(vuln_panel):
return
vulnerabilities = self.tracer.vulnerability_reports
if not vulnerabilities:
self._safe_widget_operation(vuln_panel.add_class, "hidden")
return
enriched_vulns = []
for vuln in vulnerabilities:
enriched = dict(vuln)
report_id = vuln.get("id", "")
agent_name = self._get_agent_name_for_vulnerability(report_id)
if agent_name:
enriched["agent_name"] = agent_name
enriched_vulns.append(enriched)
self._safe_widget_operation(vuln_panel.remove_class, "hidden")
vuln_panel.update_vulnerabilities(enriched_vulns)
def _get_agent_name_for_vulnerability(self, report_id: str) -> str | None:
"""Find the agent name that created a vulnerability report."""
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
if tool_data.get("tool_name") == "create_vulnerability_report":
result = tool_data.get("result", {})
if isinstance(result, dict) and result.get("report_id") == report_id:
agent_id = tool_data.get("agent_id")
if agent_id and agent_id in self.tracer.agents:
name: str = self.tracer.agents[agent_id].get("name", "Unknown Agent")
return name
return None
def _get_sweep_animation(self, color_palette: list[str]) -> Text:
text = Text()
num_squares = self._sweep_num_squares
num_colors = len(color_palette)
offset = num_colors - 1
max_pos = (num_squares - 1) + offset
total_range = max_pos + offset
cycle_length = total_range * 2
frame_in_cycle = self._spinner_frame_index % cycle_length
wave_pos = total_range - abs(total_range - frame_in_cycle)
sweep_pos = wave_pos - offset
dot_color = "#0a3d1f"
for i in range(num_squares):
dist = abs(i - sweep_pos)
color_idx = max(0, num_colors - 1 - dist)
if color_idx == 0:
text.append("·", style=Style(color=dot_color))
else:
color = color_palette[color_idx]
text.append("▪", style=Style(color=color))
text.append(" ")
return text
def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text: # noqa: ARG002
text = Text()
sweep = self._get_sweep_animation(self._sweep_colors)
text.append_text(sweep)
parts = verb.split(" ", 1)
text.append(parts[0], style="white")
if len(parts) > 1:
text.append(" ", style="dim")
text.append(parts[1], style="dim")
return text
def _start_dot_animation(self) -> None:
if self._dot_animation_timer is None:
self._dot_animation_timer = self.set_interval(0.06, self._animate_dots)
def _stop_dot_animation(self) -> None:
if self._dot_animation_timer is not None:
self._dot_animation_timer.stop()
self._dot_animation_timer = None
def _animate_dots(self) -> None:
has_active_agents = False
if self.selected_agent_id and self.selected_agent_id in self.tracer.agents:
agent_data = self.tracer.agents[self.selected_agent_id]
status = agent_data.get("status", "running")
if status in ["running", "waiting"]:
has_active_agents = True
num_colors = len(self._sweep_colors)
offset = num_colors - 1
max_pos = (self._sweep_num_squares - 1) + offset
total_range = max_pos + offset
cycle_length = total_range * 2
self._spinner_frame_index = (self._spinner_frame_index + 1) % cycle_length
self._update_agent_status_display()
if not has_active_agents:
has_active_agents = any(
agent_data.get("status", "running") in ["running", "waiting"]
for agent_data in self.tracer.agents.values()
)
if not has_active_agents:
self._stop_dot_animation()
self._spinner_frame_index = 0
def _agent_has_real_activity(self, agent_id: str) -> bool:
initial_tools = {"scan_start_info", "subagent_start_info"}
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
if tool_data.get("agent_id") == agent_id:
tool_name = tool_data.get("tool_name", "")
if tool_name not in initial_tools:
return True
streaming = self.tracer.get_streaming_content(agent_id)
return bool(streaming and streaming.strip())
def _agent_vulnerability_count(self, agent_id: str) -> int:
count = 0
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
if tool_data.get("agent_id") == agent_id:
tool_name = tool_data.get("tool_name", "")
if tool_name == "create_vulnerability_report":
status = tool_data.get("status", "")
if status == "completed":
result = tool_data.get("result", {})
if isinstance(result, dict) and result.get("success"):
count += 1
return count
def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
chat_events = [
{
"type": "chat",
"timestamp": msg["timestamp"],
"id": f"chat_{msg['message_id']}",
"data": msg,
}
for msg in self.tracer.chat_messages
if msg.get("agent_id") == agent_id
]
tool_events = [
{
"type": "tool",
"timestamp": tool_data["timestamp"],
"id": f"tool_{exec_id}",
"data": tool_data,
}
for exec_id, tool_data in list(self.tracer.tool_executions.items())
if tool_data.get("agent_id") == agent_id
]
events = chat_events + tool_events
events.sort(key=lambda e: (e["timestamp"], e["id"]))
return events
def watch_selected_agent_id(self, _agent_id: str | None) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
self._displayed_events.clear()
self._streaming_render_cache.clear()
self._last_streaming_len.clear()
self.call_later(self._update_chat_view)
self._update_agent_status_display()
def _start_scan_thread(self) -> None:
def scan_target() -> None:
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
agent = StrixAgent(self.agent_config)
if not self._scan_stop_event.is_set():
loop.run_until_complete(agent.execute_scan(self.scan_config))
except (KeyboardInterrupt, asyncio.CancelledError):
logging.info("Scan interrupted by user")
except (ConnectionError, TimeoutError):
logging.exception("Network error during scan")
except RuntimeError:
logging.exception("Runtime error during scan")
except Exception:
logging.exception("Unexpected error during scan")
finally:
loop.close()
self._scan_completed.set()
except Exception:
logging.exception("Error setting up scan thread")
self._scan_completed.set()
self._scan_thread = threading.Thread(target=scan_target, daemon=True)
self._scan_thread.start()
def _add_agent_node(self, agent_data: dict[str, Any]) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
agent_id = agent_data["id"]
parent_id = agent_data.get("parent_id")
status = agent_data.get("status", "running")
try:
agents_tree = self.query_one("#agents_tree", Tree)
except (ValueError, Exception):
return
agent_name_raw = agent_data.get("name", "Agent")
status_indicators = {
"running": "⚪",
"waiting": "⏸",
"completed": "🟢",
"failed": "🔴",
"stopped": "■",
"stopping": "○",
"llm_failed": "🔴",
}
status_icon = status_indicators.get(status, "○")
vuln_count = self._agent_vulnerability_count(agent_id)
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
try:
if parent_id and parent_id in self.agent_nodes:
parent_node = self.agent_nodes[parent_id]
agent_node = parent_node.add(
agent_name,
data={"agent_id": agent_id},
)
parent_node.allow_expand = True
else:
agent_node = agents_tree.root.add(
agent_name,
data={"agent_id": agent_id},
)
agent_node.allow_expand = False
agent_node.expand()
self.agent_nodes[agent_id] = agent_node
if len(self.agent_nodes) == 1:
agents_tree.select_node(agent_node)
self.selected_agent_id = agent_id
self._reorganize_orphaned_agents(agent_id)
except (AttributeError, ValueError, RuntimeError) as e:
import logging
logging.warning(f"Failed to add agent node {agent_id}: {e}")
def _expand_new_agent_nodes(self) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
def _expand_all_agent_nodes(self) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
try:
agents_tree = self.query_one("#agents_tree", Tree)
self._expand_node_recursively(agents_tree.root)
except (ValueError, Exception):
logging.debug("Tree not ready for expanding nodes")
def _expand_node_recursively(self, node: TreeNode) -> None:
if not node.is_expanded:
node.expand()
for child in node.children:
self._expand_node_recursively(child)
def _copy_node_under(self, node_to_copy: TreeNode, new_parent: TreeNode) -> None:
agent_id = node_to_copy.data["agent_id"]
agent_data = self.tracer.agents.get(agent_id, {})
agent_name_raw = agent_data.get("name", "Agent")
status = agent_data.get("status", "running")
status_indicators = {
"running": "⚪",
"waiting": "⏸",
"completed": "🟢",
"failed": "🔴",
"stopped": "■",
"stopping": "○",
"llm_failed": "🔴",
}
status_icon = status_indicators.get(status, "○")
vuln_count = self._agent_vulnerability_count(agent_id)
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
new_node = new_parent.add(
agent_name,
data=node_to_copy.data,
)
new_node.allow_expand = node_to_copy.allow_expand
self.agent_nodes[agent_id] = new_node
for child in node_to_copy.children:
self._copy_node_under(child, new_node)
if node_to_copy.is_expanded:
new_node.expand()
def _reorganize_orphaned_agents(self, new_parent_id: str) -> None:
agents_to_move = []
for agent_id, agent_data in list(self.tracer.agents.items()):
if (
agent_data.get("parent_id") == new_parent_id
and agent_id in self.agent_nodes
and agent_id != new_parent_id
):
agents_to_move.append(agent_id)
if not agents_to_move:
return
parent_node = self.agent_nodes[new_parent_id]
for child_agent_id in agents_to_move:
if child_agent_id in self.agent_nodes:
old_node = self.agent_nodes[child_agent_id]
if old_node.parent is parent_node:
continue
self._copy_node_under(old_node, parent_node)
old_node.remove()
parent_node.allow_expand = True
parent_node.expand()
def _render_chat_content(self, msg_data: dict[str, Any]) -> Any:
role = msg_data.get("role")
content = msg_data.get("content", "")
metadata = msg_data.get("metadata", {})
if not content:
return None
if role == "user":
return UserMessageRenderer.render_simple(content)
if metadata.get("interrupted"):
streaming_result = self._render_streaming_content(content)
interrupted_text = Text()
interrupted_text.append("\n")
interrupted_text.append("⚠ ", style="yellow")
interrupted_text.append("Interrupted by user", style="yellow dim")
return self._merge_renderables([streaming_result, interrupted_text])
return AgentMessageRenderer.render_simple(content)
def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> Any:
tool_name = tool_data.get("tool_name", "Unknown Tool")
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
renderer = get_tool_renderer(tool_name)
if renderer:
widget = renderer.render(tool_data)
return widget.renderable
text = Text()
if tool_name in ("llm_error_details", "sandbox_error_details"):
return self._render_error_details(text, tool_name, args)
text.append("→ Using tool ")
text.append(tool_name, style="bold blue")
status_styles = {
"running": ("●", "yellow"),
"completed": ("✓", "green"),
"failed": ("✗", "red"),
"error": ("✗", "red"),
}
icon, style = status_styles.get(status, ("○", "dim"))
text.append(" ")
text.append(icon, style=style)
if args:
for k, v in list(args.items())[:5]:
str_v = str(v)
if len(str_v) > 500:
str_v = str_v[:497] + "..."
text.append("\n ")
text.append(k, style="dim")
text.append(": ")
text.append(str_v)
if status in ["completed", "failed", "error"] and result:
result_str = str(result)
if len(result_str) > 1000:
result_str = result_str[:997] + "..."
text.append("\n")
text.append("Result: ", style="bold")
text.append(result_str)
return text
def _render_error_details(self, text: Any, tool_name: str, args: dict[str, Any]) -> Any:
if tool_name == "llm_error_details":
text.append("✗ LLM Request Failed", style="red")
else:
text.append("✗ Sandbox Initialization Failed", style="red")
if args.get("error"):
text.append(f"\n{args['error']}", style="bold red")
if args.get("details"):
details = str(args["details"])
if len(details) > 1000:
details = details[:997] + "..."
text.append("\nDetails: ", style="dim")
text.append(details)
return text
@on(Tree.NodeHighlighted) # type: ignore[misc]
def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
node = event.node
try:
agents_tree = self.query_one("#agents_tree", Tree)
except (ValueError, Exception):
return
if self.focused == agents_tree and node.data:
agent_id = node.data.get("agent_id")
if agent_id:
self.selected_agent_id = agent_id
@on(Tree.NodeSelected) # type: ignore[misc]
def handle_tree_node_selected(self, event: Tree.NodeSelected) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
if not self.is_mounted:
return
node = event.node
if node.allow_expand:
if node.is_expanded:
node.collapse()
else:
node.expand()
def _send_user_message(self, message: str) -> None:
if not self.selected_agent_id:
return
if self.tracer:
streaming_content = self.tracer.get_streaming_content(self.selected_agent_id)
if streaming_content and streaming_content.strip():
self.tracer.clear_streaming_content(self.selected_agent_id)
self.tracer.interrupted_content[self.selected_agent_id] = streaming_content
self.tracer.log_chat_message(
content=streaming_content,
role="assistant",
agent_id=self.selected_agent_id,
metadata={"interrupted": True},
)
try:
from strix.tools.agents_graph.agents_graph_actions import _agent_instances
if self.selected_agent_id in _agent_instances:
agent_instance = _agent_instances[self.selected_agent_id]
if hasattr(agent_instance, "cancel_current_execution"):
agent_instance.cancel_current_execution()
except (ImportError, AttributeError, KeyError):
pass
if self.tracer:
self.tracer.log_chat_message(
content=message,
role="user",
agent_id=self.selected_agent_id,
)
try:
from strix.tools.agents_graph.agents_graph_actions import send_user_message_to_agent
send_user_message_to_agent(self.selected_agent_id, message)
except (ImportError, AttributeError) as e:
import logging
logging.warning(f"Failed to send message to agent {self.selected_agent_id}: {e}")
self._displayed_events.clear()
self._update_chat_view()
self.call_after_refresh(self._focus_chat_input)
def _get_agent_name(self, agent_id: str) -> str:
try:
if self.tracer and agent_id in self.tracer.agents:
agent_name = self.tracer.agents[agent_id].get("name")
if isinstance(agent_name, str):
return agent_name
except (KeyError, AttributeError) as e:
logging.warning(f"Could not retrieve agent name for {agent_id}: {e}")
return "Unknown Agent"
def action_toggle_help(self) -> None:
if self.show_splash or not self.is_mounted:
return
try:
self.query_one("#main_container")
except (ValueError, Exception):
return
if isinstance(self.screen, HelpScreen):
self.pop_screen()
return
if len(self.screen_stack) > 1:
return
self.push_screen(HelpScreen())
def action_request_quit(self) -> None:
if self.show_splash or not self.is_mounted:
self.action_custom_quit()
return
if len(self.screen_stack) > 1:
return
try:
self.query_one("#main_container")
except (ValueError, Exception):
self.action_custom_quit()
return
self.push_screen(QuitScreen())
def action_stop_selected_agent(self) -> None:
if self.show_splash or not self.is_mounted:
return
if len(self.screen_stack) > 1:
self.pop_screen()
return
if not self.selected_agent_id:
return
agent_name, should_stop = self._validate_agent_for_stopping()
if not should_stop:
return
try:
self.query_one("#main_container")
except (ValueError, Exception):
return
self.push_screen(StopAgentScreen(agent_name, self.selected_agent_id))
def _validate_agent_for_stopping(self) -> tuple[str, bool]:
agent_name = "Unknown Agent"
try:
if self.tracer and self.selected_agent_id in self.tracer.agents:
agent_data = self.tracer.agents[self.selected_agent_id]
agent_name = agent_data.get("name", "Unknown Agent")
agent_status = agent_data.get("status", "running")
if agent_status not in ["running"]:
return agent_name, False
agent_events = self._gather_agent_events(self.selected_agent_id)
if not agent_events:
return agent_name, False
return agent_name, True
except (KeyError, AttributeError, ValueError) as e:
import logging
logging.warning(f"Failed to gather agent events: {e}")
return agent_name, False
def action_confirm_stop_agent(self, agent_id: str) -> None:
try:
from strix.tools.agents_graph.agents_graph_actions import stop_agent
result = stop_agent(agent_id)
import logging
if result.get("success"):
logging.info(f"Stop request sent to agent: {result.get('message', 'Unknown')}")
else:
logging.warning(f"Failed to stop agent: {result.get('error', 'Unknown error')}")
except Exception:
import logging
logging.exception(f"Failed to stop agent {agent_id}")
def action_custom_quit(self) -> None:
if self._scan_thread and self._scan_thread.is_alive():
self._scan_stop_event.set()
self._scan_thread.join(timeout=1.0)
self.tracer.cleanup()
self.exit()
def _is_widget_safe(self, widget: Any) -> bool:
try:
_ = widget.screen
except (AttributeError, ValueError, Exception):
return False
else:
return bool(widget.is_mounted)
def _safe_widget_operation(
self, operation: Callable[..., Any], *args: Any, **kwargs: Any
) -> bool:
try:
operation(*args, **kwargs)
except (AttributeError, ValueError, Exception):
return False
else:
return True
def on_resize(self, event: events.Resize) -> None:
if self.show_splash or not self.is_mounted:
return
try:
sidebar = self.query_one("#sidebar", Vertical)
chat_area = self.query_one("#chat_area_container", Vertical)
except (ValueError, Exception):
return
if event.size.width < self.SIDEBAR_MIN_WIDTH:
sidebar.add_class("-hidden")
chat_area.add_class("-full-width")
else:
sidebar.remove_class("-hidden")
chat_area.remove_class("-full-width")
def on_mouse_up(self, _event: events.MouseUp) -> None:
self.set_timer(0.05, self._auto_copy_selection)
_ICON_PREFIXES: ClassVar[tuple[str, ...]] = (
"🐞 ",
"🌐 ",
"📋 ",
"🧠 ",
"◆ ",
"◇ ",
"◈ ",
"→ ",
"○ ",
"● ",
"✓ ",
"✗ ",
"⚠ ",
"▍ ",
"▍",
"┃ ",
"• ",
">_ ",
"> ",
"<~> ",
"[ ] ",
"[~] ",
"[•] ",
)
_DECORATIVE_LINES: ClassVar[frozenset[str]] = frozenset(
{
"● In progress...",
"✓ Done",
"✗ Failed",
"✗ Error",
"○ Unknown",
}
)
@staticmethod
def _clean_copied_text(text: str) -> str:
lines = text.split("\n")
cleaned: list[str] = []
for line in lines:
stripped = line.lstrip()
if stripped in StrixTUIApp._DECORATIVE_LINES:
continue
if stripped and all(c == "─" for c in stripped):
continue
out = line
for prefix in StrixTUIApp._ICON_PREFIXES:
if stripped.startswith(prefix):
leading = line[: len(line) - len(line.lstrip())]
out = leading + stripped[len(prefix) :]
break
cleaned.append(out)
return "\n".join(cleaned)
def _auto_copy_selection(self) -> None:
copied = False
try:
if self.screen.selections:
selected = self.screen.get_selected_text()
self.screen.clear_selection()
if selected and selected.strip():
cleaned = self._clean_copied_text(selected)
self.copy_to_clipboard(cleaned if cleaned.strip() else selected)
copied = True
except Exception: # noqa: BLE001
logger.debug("Failed to copy screen selection", exc_info=True)
if not copied:
try:
chat_input = self.query_one("#chat_input", ChatTextArea)
selected = chat_input.selected_text
if selected and selected.strip():
self.copy_to_clipboard(selected)
chat_input.move_cursor(chat_input.cursor_location)
copied = True
except Exception: # noqa: BLE001
logger.debug("Failed to copy chat input selection", exc_info=True)
if copied:
self.notify("Copied to clipboard", timeout=2)
async def run_tui(args: argparse.Namespace) -> None:
"""Run strix in interactive TUI mode with textual."""
app = StrixTUIApp(args)
await app.run_async()
================================================
FILE: strix/interface/utils.py
================================================
import ipaddress
import json
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
import docker
from docker.errors import DockerException, ImageNotFound
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
# Token formatting utilities
def format_token_count(count: float) -> str:
count = int(count)
if count >= 1_000_000:
return f"{count / 1_000_000:.1f}M"
if count >= 1_000:
return f"{count / 1_000:.1f}K"
return str(count)
# Display utilities
def get_severity_color(severity: str) -> str:
severity_colors = {
"critical": "#dc2626",
"high": "#ea580c",
"medium": "#d97706",
"low": "#65a30d",
"info": "#0284c7",
}
return severity_colors.get(severity, "#6b7280")
def get_cvss_color(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"
def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR0912, PLR0915
"""Format a vulnerability report for CLI display with all rich fields."""
field_style = "bold #4ade80"
text = Text()
title = report.get("title", "")
if title:
text.append("Vulnerability Report", style="bold #ea580c")
text.append("\n\n")
text.append("Title: ", style=field_style)
text.append(title)
severity = report.get("severity", "")
if severity:
text.append("\n\n")
text.append("Severity: ", style=field_style)
severity_color = get_severity_color(severity.lower())
text.append(severity.upper(), style=f"bold {severity_color}")
cvss = report.get("cvss")
if cvss is not None:
text.append("\n\n")
text.append("CVSS Score: ", style=field_style)
cvss_color = get_cvss_color(cvss)
text.append(f"{cvss:.1f}", style=f"bold {cvss_color}")
target = report.get("target")
if target:
text.append("\n\n")
text.append("Target: ", style=field_style)
text.append(target)
endpoint = report.get("endpoint")
if endpoint:
text.append("\n\n")
text.append("Endpoint: ", style=field_style)
text.append(endpoint)
method = report.get("method")
if method:
text.append("\n\n")
text.append("Method: ", style=field_style)
text.append(method)
cve = report.get("cve")
if cve:
text.append("\n\n")
text.append("CVE: ", style=field_style)
text.append(cve)
cvss_breakdown = report.get("cvss_breakdown", {})
if cvss_breakdown:
text.append("\n\n")
cvss_parts = []
if cvss_breakdown.get("attack_vector"):
cvss_parts.append(f"AV:{cvss_breakdown['attack_vector']}")
if cvss_breakdown.get("attack_complexity"):
cvss_parts.append(f"AC:{cvss_breakdown['attack_complexity']}")
if cvss_breakdown.get("privileges_required"):
cvss_parts.append(f"PR:{cvss_breakdown['privileges_required']}")
if cvss_breakdown.get("user_interaction"):
cvss_parts.append(f"UI:{cvss_breakdown['user_interaction']}")
if cvss_breakdown.get("scope"):
cvss_parts.append(f"S:{cvss_breakdown['scope']}")
if cvss_breakdown.get("confidentiality"):
cvss_parts.append(f"C:{cvss_breakdown['confidentiality']}")
if cvss_breakdown.get("integrity"):
cvss_parts.append(f"I:{cvss_breakdown['integrity']}")
if cvss_breakdown.get("availability"):
cvss_parts.append(f"A:{cvss_breakdown['availability']}")
if cvss_parts:
text.append("CVSS Vector: ", style=field_style)
text.append("/".join(cvss_parts), style="dim")
description = report.get("description")
if description:
text.append("\n\n")
text.append("Description", style=field_style)
text.append("\n")
text.append(description)
impact = report.get("impact")
if impact:
text.append("\n\n")
text.append("Impact", style=field_style)
text.append("\n")
text.append(impact)
technical_analysis = report.get("technical_analysis")
if technical_analysis:
text.append("\n\n")
text.append("Technical Analysis", style=field_style)
text.append("\n")
text.append(technical_analysis)
poc_description = report.get("poc_description")
if poc_description:
text.append("\n\n")
text.append("PoC Description", style=field_style)
text.append("\n")
text.append(poc_description)
poc_script_code = report.get("poc_script_code")
if poc_script_code:
text.append("\n\n")
text.append("PoC Code", style=field_style)
text.append("\n")
text.append(poc_script_code, style="dim")
code_locations = report.get("code_locations")
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")
text.append(loc.get("file", "unknown"), style="bold")
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}")
else:
text.append(f":{start}")
if loc.get("label"):
text.append(f"\n {loc['label']}", style="italic dim")
if loc.get("snippet"):
text.append("\n ")
text.append(loc["snippet"], style="dim")
if loc.get("fix_before") or loc.get("fix_after"):
text.append("\n Fix:")
if loc.get("fix_before"):
text.append("\n - ", style="dim")
text.append(loc["fix_before"], style="dim")
if loc.get("fix_after"):
text.append("\n + ", style="dim")
text.append(loc["fix_after"], style="dim")
remediation_steps = report.get("remediation_steps")
if remediation_steps:
text.append("\n\n")
text.append("Remediation", style=field_style)
text.append("\n")
text.append(remediation_steps)
return text
def _build_vulnerability_stats(stats_text: Text, tracer: Any) -> None:
"""Build vulnerability section of stats text."""
vuln_count = len(tracer.vulnerability_reports)
if vuln_count > 0:
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
for report in tracer.vulnerability_reports:
severity = report.get("severity", "").lower()
if severity in severity_counts:
severity_counts[severity] += 1
stats_text.append("Vulnerabilities ", style="bold red")
severity_parts = []
for severity in ["critical", "high", "medium", "low", "info"]:
count = severity_counts[severity]
if count > 0:
severity_color = get_severity_color(severity)
severity_text = Text()
severity_text.append(f"{severity.upper()}: ", style=severity_color)
severity_text.append(str(count), style=f"bold {severity_color}")
severity_parts.append(severity_text)
for i, part in enumerate(severity_parts):
stats_text.append(part)
if i < len(severity_parts) - 1:
stats_text.append(" | ", style="dim white")
stats_text.append(" (Total: ", style="dim white")
stats_text.append(str(vuln_count), style="bold yellow")
stats_text.append(")", style="dim white")
stats_text.append("\n")
else:
stats_text.append("Vulnerabilities ", style="bold #22c55e")
stats_text.append("0", style="bold white")
stats_text.append(" (No exploitable vulnerabilities detected)", style="dim green")
stats_text.append("\n")
def _build_llm_stats(stats_text: Text, total_stats: dict[str, Any]) -> None:
"""Build LLM usage section of stats text."""
if total_stats["requests"] > 0:
stats_text.append("\n")
stats_text.append("Input Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
if total_stats["cached_tokens"] > 0:
stats_text.append(" · ", style="dim white")
stats_text.append("Cached Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
stats_text.append(" · ", style="dim white")
stats_text.append("Output Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
if total_stats["cost"] > 0:
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
stats_text.append(f"${total_stats['cost']:.4f}", style="bold #fbbf24")
else:
stats_text.append("\n")
stats_text.append("Cost ", style="dim")
stats_text.append("$0.0000 ", style="#fbbf24")
stats_text.append("· ", style="dim white")
stats_text.append("Tokens ", style="dim")
stats_text.append("0", style="white")
def build_final_stats_text(tracer: Any) -> Text:
"""Build stats text for final output with detailed messages and LLM usage."""
stats_text = Text()
if not tracer:
return stats_text
_build_vulnerability_stats(stats_text, tracer)
tool_count = tracer.get_real_tool_count()
agent_count = len(tracer.agents)
stats_text.append("Agents", style="dim")
stats_text.append(" ")
stats_text.append(str(agent_count), style="bold white")
stats_text.append(" · ", style="dim white")
stats_text.append("Tools", style="dim")
stats_text.append(" ")
stats_text.append(str(tool_count), style="bold white")
llm_stats = tracer.get_total_llm_stats()
_build_llm_stats(stats_text, llm_stats["total"])
return stats_text
def build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:
stats_text = Text()
if not tracer:
return stats_text
if agent_config:
llm_config = agent_config["llm_config"]
model = getattr(llm_config, "model_name", "Unknown")
stats_text.append("Model ", style="dim")
stats_text.append(model, style="white")
stats_text.append("\n")
vuln_count = len(tracer.vulnerability_reports)
tool_count = tracer.get_real_tool_count()
agent_count = len(tracer.agents)
stats_text.append("Vulnerabilities ", style="dim")
stats_text.append(f"{vuln_count}", style="white")
stats_text.append("\n")
if vuln_count > 0:
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
for report in tracer.vulnerability_reports:
severity = report.get("severity", "").lower()
if severity in severity_counts:
severity_counts[severity] += 1
severity_parts = []
for severity in ["critical", "high", "medium", "low", "info"]:
count = severity_counts[severity]
if count > 0:
severity_color = get_severity_color(severity)
severity_text = Text()
severity_text.append(f"{severity.upper()}: ", style=severity_color)
severity_text.append(str(count), style=f"bold {severity_color}")
severity_parts.append(severity_text)
for i, part in enumerate(severity_parts):
stats_text.append(part)
if i < len(severity_parts) - 1:
stats_text.append(" | ", style="dim white")
stats_text.append("\n")
stats_text.append("Agents ", style="dim")
stats_text.append(str(agent_count), style="white")
stats_text.append(" · ", style="dim white")
stats_text.append("Tools ", style="dim")
stats_text.append(str(tool_count), style="white")
llm_stats = tracer.get_total_llm_stats()
total_stats = llm_stats["total"]
stats_text.append("\n")
stats_text.append("Input Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
stats_text.append(" · ", style="dim white")
stats_text.append("Cached Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
stats_text.append("\n")
stats_text.append("Output Tokens ", style="dim")
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
stats_text.append(f"${total_stats['cost']:.4f}", style="#fbbf24")
return stats_text
def build_tui_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:
stats_text = Text()
if not tracer:
return stats_text
if agent_config:
llm_config = agent_config["llm_config"]
model = getattr(llm_config, "model_name", "Unknown")
stats_text.append(model, style="white")
llm_stats = tracer.get_total_llm_stats()
total_stats = llm_stats["total"]
total_tokens = total_stats["input_tokens"] + total_stats["output_tokens"]
if total_tokens > 0:
stats_text.append("\n")
stats_text.append(f"{format_token_count(total_tokens)} tokens", style="white")
if total_stats["cost"] > 0:
stats_text.append(" · ", style="white")
stats_text.append(f"${total_stats['cost']:.2f}", style="white")
caido_url = getattr(tracer, "caido_url", None)
if caido_url:
stats_text.append("\n")
stats_text.append("Caido: ", style="bold white")
stats_text.append(caido_url, style="white")
return stats_text
# Name generation utilities
def _slugify_for_run_name(text: str, max_length: int = 32) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = text.strip("-")
if len(text) > max_length:
text = text[:max_length].rstrip("-")
return text or "pentest"
def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None) -> str: # noqa: PLR0911
if not targets_info:
return "pentest"
first = targets_info[0]
target_type = first.get("type")
details = first.get("details", {}) or {}
original = first.get("original", "") or ""
if target_type == "web_application":
url = details.get("target_url", original)
try:
parsed = urlparse(url)
return str(parsed.netloc or parsed.path or url)
except Exception: # noqa: BLE001
return str(url)
if target_type == "repository":
repo = details.get("target_repo", original)
parsed = urlparse(repo)
path = parsed.path or repo
name = path.rstrip("/").split("/")[-1] or path
if name.endswith(".git"):
name = name[:-4]
return str(name)
if target_type == "local_code":
path_str = details.get("target_path", original)
try:
return str(Path(path_str).name or path_str)
except Exception: # noqa: BLE001
return str(path_str)
if target_type == "ip_address":
return str(details.get("target_ip", original) or original)
return str(original or "pentest")
def generate_run_name(targets_info: list[dict[str, Any]] | None = None) -> str:
base_label = _derive_target_label_for_run_name(targets_info)
slug = _slugify_for_run_name(base_label)
random_suffix = secrets.token_hex(2)
return f"{slug}_{random_suffix}"
# Target processing utilities
def _is_http_git_repo(url: str) -> bool:
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
try:
req = Request(check_url, headers={"User-Agent": "git/strix"}) # noqa: S310
with urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
except HTTPError as e:
return e.code == 401
except (URLError, OSError, ValueError):
return False
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911, PLR0912
if not target or not isinstance(target, str):
raise ValueError("Target must be a non-empty string")
target = target.strip()
if target.startswith("git@"):
return "repository", {"target_repo": target}
if target.startswith("git://"):
return "repository", {"target_repo": target}
parsed = urlparse(target)
if parsed.scheme in ("http", "https"):
if parsed.username or parsed.password:
return "repository", {"target_repo": target}
if parsed.path.rstrip("/").endswith(".git"):
return "repository", {"target_repo": target}
if parsed.query or parsed.fragment:
return "web_application", {"target_url": target}
path_segments = [s for s in parsed.path.split("/") if s]
if len(path_segments) >= 2 and _is_http_git_repo(target):
return "repository", {"target_repo": target}
return "web_application", {"target_url": target}
try:
ip_obj = ipaddress.ip_address(target)
except ValueError:
pass
else:
return "ip_address", {"target_ip": str(ip_obj)}
path = Path(target).expanduser()
try:
if path.exists():
if path.is_dir():
return "local_code", {"target_path": str(path.resolve())}
raise ValueError(f"Path exists but is not a directory: {target}")
except (OSError, RuntimeError) as e:
raise ValueError(f"Invalid path: {target} - {e!s}") from e
if target.endswith(".git"):
return "repository", {"target_repo": target}
if "/" in target:
host_part, _, path_part = target.partition("/")
if "." in host_part and not host_part.startswith(".") and path_part:
full_url = f"https://{target}"
if _is_http_git_repo(full_url):
return "repository", {"target_repo": full_url}
return "web_application", {"target_url": full_url}
if "." in target and "/" not in target and not target.startswith("."):
parts = target.split(".")
if len(parts) >= 2 and all(p and p.strip() for p in parts):
return "web_application", {"target_url": f"https://{target}"}
raise ValueError(
f"Invalid target: {target}\n"
"Target must be one of:\n"
"- A valid URL (http:// or https://)\n"
"- A Git repository URL (https://host/org/repo or git@host:org/repo.git)\n"
"- A local directory path\n"
"- A domain name (e.g., example.com)\n"
"- An IP address (e.g., 192.168.1.10)"
)
def sanitize_name(name: str) -> str:
sanitized = re.sub(r"[^A-Za-z0-9._-]", "-", name.strip())
return sanitized or "target"
def derive_repo_base_name(repo_url: str) -> str:
if repo_url.endswith("/"):
repo_url = repo_url[:-1]
if ":" in repo_url and repo_url.startswith("git@"):
path_part = repo_url.split(":", 1)[1]
else:
path_part = urlparse(repo_url).path or repo_url
candidate = path_part.split("/")[-1]
if candidate.endswith(".git"):
candidate = candidate[:-4]
return sanitize_name(candidate or "repository")
def derive_local_base_name(path_str: str) -> str:
try:
base = Path(path_str).resolve().name
except (OSError, RuntimeError):
base = Path(path_str).name
return sanitize_name(base or "workspace")
def assign_workspace_subdirs(targets_info: list[dict[str, Any]]) -> None:
name_counts: dict[str, int] = {}
for target in targets_info:
target_type = target["type"]
details = target["details"]
base_name: str | None = None
if target_type == "repository":
base_name = derive_repo_base_name(details["target_repo"])
elif target_type == "local_code":
base_name = derive_local_base_name(details.get("target_path", "local"))
if base_name is None:
continue
count = name_counts.get(base_name, 0) + 1
name_counts[base_name] = count
workspace_subdir = base_name if count == 1 else f"{base_name}-{count}"
details["workspace_subdir"] = workspace_subdir
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:
local_sources: list[dict[str, str]] = []
for target_info in targets_info:
details = target_info["details"]
workspace_subdir = details.get("workspace_subdir")
if target_info["type"] == "local_code" and "target_path" in details:
local_sources.append(
{
"source_path": details["target_path"],
"workspace_subdir": workspace_subdir,
}
)
elif target_info["type"] == "repository" and "cloned_repo_path" in details:
local_sources.append(
{
"source_path": details["cloned_repo_path"],
"workspace_subdir": workspace_subdir,
}
)
return local_sources
def _is_localhost_host(host: str) -> bool:
host_lower = host.lower().strip("[]")
if host_lower in ("localhost", "0.0.0.0", "::1"): # nosec B104
return True
try:
ip = ipaddress.ip_address(host_lower)
if isinstance(ip, ipaddress.IPv4Address):
return ip.is_loopback # 127.0.0.0/8
if isinstance(ip, ipaddress.IPv6Address):
return ip.is_loopback # ::1
except ValueError:
pass
return False
def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway: str) -> None:
from yarl import URL # type: ignore[import-not-found]
for target_info in targets_info:
target_type = target_info.get("type")
details = target_info.get("details", {})
if target_type == "web_application":
target_url = details.get("target_url", "")
try:
url = URL(target_url)
except (ValueError, TypeError):
continue
if url.host and _is_localhost_host(url.host):
details["target_url"] = str(url.with_host(host_gateway))
elif target_type == "ip_address":
target_ip = details.get("target_ip", "")
if target_ip and _is_localhost_host(target_ip):
details["target_ip"] = host_gateway
# Repository utilities
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
console = Console()
git_executable = shutil.which("git")
if git_executable is None:
raise FileNotFoundError("Git executable not found in PATH")
temp_dir = Path(tempfile.gettempdir()) / "strix_repos" / run_name
temp_dir.mkdir(parents=True, exist_ok=True)
if dest_name:
repo_name = dest_name
else:
repo_name = Path(repo_url).stem if repo_url.endswith(".git") else Path(repo_url).name
clone_path = temp_dir / repo_name
if clone_path.exists():
shutil.rmtree(clone_path)
try:
with console.status(f"[bold cyan]Cloning repository {repo_url}...", spinner="dots"):
subprocess.run( # noqa: S603
[
git_executable,
"clone",
repo_url,
str(clone_path),
],
capture_output=True,
text=True,
check=True,
)
return str(clone_path.absolute())
except subprocess.CalledProcessError as e:
error_text = Text()
error_text.append("REPOSITORY CLONE FAILED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(f"Could not clone repository: {repo_url}\n", style="white")
error_text.append(
f"Error: {e.stderr if hasattr(e, 'stderr') and e.stderr else str(e)}", style="dim red"
)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
except FileNotFoundError:
error_text = Text()
error_text.append("GIT NOT FOUND", style="bold red")
error_text.append("\n\n", style="white")
error_text.append("Git is not installed or not available in PATH.\n", style="white")
error_text.append("Please install Git to clone repositories.\n", style="white")
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
# Docker utilities
def check_docker_connection() -> Any:
try:
return docker.from_env()
except DockerException:
console = Console()
error_text = Text()
error_text.append("DOCKER NOT AVAILABLE", style="bold red")
error_text.append("\n\n", style="white")
error_text.append("Cannot connect to Docker daemon.\n", style="white")
error_text.append(
"Please ensure Docker Desktop is installed and running, and try running strix again.\n",
style="white",
)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n", panel, "\n")
raise RuntimeError("Docker not available") from None
def image_exists(client: Any, image_name: str) -> bool:
try:
client.images.get(image_name)
except ImageNotFound:
return False
else:
return True
def update_layer_status(layers_info: dict[str, str], layer_id: str, layer_status: str) -> None:
if "Pull complete" in layer_status or "Already exists" in layer_status:
layers_info[layer_id] = "✓"
elif "Downloading" in layer_status:
layers_info[layer_id] = "↓"
elif "Extracting" in layer_status:
layers_info[layer_id] = "📦"
elif "Waiting" in layer_status:
layers_info[layer_id] = "⏳"
else:
layers_info[layer_id] = "•"
def process_pull_line(
line: dict[str, Any], layers_info: dict[str, str], status: Any, last_update: str
) -> str:
if "id" in line and "status" in line:
layer_id = line["id"]
update_layer_status(layers_info, layer_id, line["status"])
completed = sum(1 for v in layers_info.values() if v == "✓")
total = len(layers_info)
if total > 0:
update_msg = f"[bold cyan]Progress: {completed}/{total} layers complete"
if update_msg != last_update:
status.update(update_msg)
return update_msg
elif "status" in line and "id" not in line:
global_status = line["status"]
if "Pulling from" in global_status:
status.update("[bold cyan]Fetching image manifest...")
elif "Digest:" in global_status:
status.update("[bold cyan]Verifying image...")
elif "Status:" in global_status:
status.update("[bold cyan]Finalizing...")
return last_update
# LLM utilities
def validate_llm_response(response: Any) -> None:
if not response or not response.choices or not response.choices[0].message.content:
raise RuntimeError("Invalid response from LLM")
def validate_config_file(config_path: str) -> Path:
console = Console()
path = Path(config_path)
if not path.exists():
console.print(f"[bold red]Error:[/] Config file not found: {config_path}")
sys.exit(1)
if path.suffix != ".json":
console.print("[bold red]Error:[/] Config file must be a .json file")
sys.exit(1)
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
console.print(f"[bold red]Error:[/] Invalid JSON in config file: {e}")
sys.exit(1)
if not isinstance(data, dict):
console.print("[bold red]Error:[/] Config file must contain a JSON object")
sys.exit(1)
if "env" not in data or not isinstance(data.get("env"), dict):
console.print("[bold red]Error:[/] Config file must have an 'env' object")
sys.exit(1)
return path
================================================
FILE: strix/llm/__init__.py
================================================
import logging
import warnings
import litellm
from .config import LLMConfig
from .llm import LLM, LLMRequestFailedError
__all__ = [
"LLM",
"LLMConfig",
"LLMRequestFailedError",
]
litellm._logging._disable_debugging()
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
logging.getLogger("asyncio").propagate = False
warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio")
================================================
FILE: strix/llm/config.py
================================================
from strix.config import Config
from strix.config.config import resolve_llm_config
from strix.llm.utils import resolve_strix_model
class LLMConfig:
def __init__(
self,
model_name: str | None = None,
enable_prompt_caching: bool = True,
skills: list[str] | None = None,
timeout: int | None = None,
scan_mode: str = "deep",
interactive: bool = False,
):
resolved_model, self.api_key, self.api_base = resolve_llm_config()
self.model_name = model_name or resolved_model
if not self.model_name:
raise ValueError("STRIX_LLM environment variable must be set and not empty")
api_model, canonical = resolve_strix_model(self.model_name)
self.litellm_model: str = api_model or self.model_name
self.canonical_model: str = canonical or self.model_name
self.enable_prompt_caching = enable_prompt_caching
self.skills = skills or []
self.timeout = timeout or int(Config.get("llm_timeout") or "300")
self.scan_mode = scan_mode if scan_mode in ["quick", "standard", "deep"] else "deep"
self.interactive = interactive
================================================
FILE: strix/llm/dedupe.py
================================================
import json
import logging
import re
from typing import Any
import litellm
from strix.config.config import resolve_llm_config
from strix.llm.utils import resolve_strix_model
logger = logging.getLogger(__name__)
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
as any existing report.
CRITICAL DEDUPLICATION RULES:
1. SAME VULNERABILITY means:
- Same root cause (e.g., "missing input validation" not just "SQL injection")
- Same affected component/endpoint/file (exact match or clear overlap)
- Same exploitation method or attack vector
- Would be fixed by the same code change/patch
2. NOT DUPLICATES if:
- Different endpoints even with same vulnerability type (e.g., SQLi in /login vs /search)
- Different parameters in same endpoint (e.g., XSS in 'name' vs 'comment' field)
- Different root causes (e.g., stored XSS vs reflected XSS in same field)
- Different severity levels due to different impact
- One is authenticated, other is unauthenticated
3. ARE DUPLICATES even if:
- Titles are worded differently
- Descriptions have different level of detail
- PoC uses different payloads but exploits same issue
- One report is more thorough than another
- Minor variations in technical analysis
COMPARISON GUIDELINES:
- Focus on the technical root cause, not surface-level similarities
- Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters
- Consider the fix: would fixing one also fix the other?
- When uncertain, lean towards NOT duplicate
FIELDS TO ANALYZE:
- title, description: General vulnerability info
- target, endpoint, method: Exact location of vulnerability
- technical_analysis: Root cause details
- poc_description: How it's exploited
- impact: What damage it can cause
YOU MUST RESPOND WITH EXACTLY THIS XML FORMAT AND NOTHING ELSE:
truevuln-00010.95Both reports describe SQL injection in /api/login via the username parameter
OR if not a duplicate:
false0.90Different endpoints: candidate is /api/search, existing is /api/login
RULES:
- is_duplicate MUST be exactly "true" or "false" (lowercase)
- duplicate_id MUST be the exact ID from existing reports or empty if not duplicate
- confidence MUST be a decimal (your confidence level in the decision)
- reason MUST be a specific explanation mentioning endpoint/parameter/root cause
- DO NOT include any text outside the tags"""
def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:
relevant_fields = [
"id",
"title",
"description",
"impact",
"target",
"technical_analysis",
"poc_description",
"endpoint",
"method",
]
cleaned = {}
for field in relevant_fields:
if report.get(field):
value = report[field]
if isinstance(value, str) and len(value) > 8000:
value = value[:8000] + "...[truncated]"
cleaned[field] = value
return cleaned
def _extract_xml_field(content: str, field: str) -> str:
pattern = rf"<{field}>(.*?){field}>"
match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
return ""
def _parse_dedupe_response(content: str) -> dict[str, Any]:
result_match = re.search(
r"(.*?)", content, re.DOTALL | re.IGNORECASE
)
if not result_match:
logger.warning(f"No block found in response: {content[:500]}")
raise ValueError("No block found in response")
result_content = result_match.group(1)
is_duplicate_str = _extract_xml_field(result_content, "is_duplicate")
duplicate_id = _extract_xml_field(result_content, "duplicate_id")
confidence_str = _extract_xml_field(result_content, "confidence")
reason = _extract_xml_field(result_content, "reason")
is_duplicate = is_duplicate_str.lower() == "true"
try:
confidence = float(confidence_str) if confidence_str else 0.0
except ValueError:
confidence = 0.0
return {
"is_duplicate": is_duplicate,
"duplicate_id": duplicate_id[:64] if duplicate_id else "",
"confidence": confidence,
"reason": reason[:500] if reason else "",
}
def check_duplicate(
candidate: dict[str, Any], existing_reports: list[dict[str, Any]]
) -> dict[str, Any]:
if not existing_reports:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 1.0,
"reason": "No existing reports to compare against",
}
try:
candidate_cleaned = _prepare_report_for_comparison(candidate)
existing_cleaned = [_prepare_report_for_comparison(r) for r in existing_reports]
comparison_data = {"candidate": candidate_cleaned, "existing_reports": existing_cleaned}
model_name, api_key, api_base = resolve_llm_config()
litellm_model, _ = resolve_strix_model(model_name)
litellm_model = litellm_model or model_name
messages = [
{"role": "system", "content": DEDUPE_SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"Compare this candidate vulnerability against existing reports:\n\n"
f"{json.dumps(comparison_data, indent=2)}\n\n"
f"Respond with ONLY the XML block."
),
},
]
completion_kwargs: dict[str, Any] = {
"model": litellm_model,
"messages": messages,
"timeout": 120,
}
if api_key:
completion_kwargs["api_key"] = api_key
if api_base:
completion_kwargs["api_base"] = api_base
response = litellm.completion(**completion_kwargs)
content = response.choices[0].message.content
if not content:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": "Empty response from LLM",
}
result = _parse_dedupe_response(content)
logger.info(
f"Deduplication check: is_duplicate={result['is_duplicate']}, "
f"confidence={result['confidence']}, reason={result['reason'][:100]}"
)
except Exception as e:
logger.exception("Error during vulnerability deduplication check")
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": f"Deduplication check failed: {e}",
"error": str(e),
}
else:
return result
================================================
FILE: strix/llm/llm.py
================================================
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
import litellm
from jinja2 import Environment, FileSystemLoader, select_autoescape
from litellm import acompletion, completion_cost, stream_chunk_builder, supports_reasoning
from litellm.utils import supports_prompt_caching, supports_vision
from strix.config import Config
from strix.llm.config import LLMConfig
from strix.llm.memory_compressor import MemoryCompressor
from strix.llm.utils import (
_truncate_to_first_function,
fix_incomplete_tool_call,
normalize_tool_format,
parse_tool_invocations,
)
from strix.skills import load_skills
from strix.tools import get_tools_prompt
from strix.utils.resource_paths import get_strix_resource_path
litellm.drop_params = True
litellm.modify_params = True
class LLMRequestFailedError(Exception):
def __init__(self, message: str, details: str | None = None):
super().__init__(message)
self.message = message
self.details = details
@dataclass
class LLMResponse:
content: str
tool_invocations: list[dict[str, Any]] | None = None
thinking_blocks: list[dict[str, Any]] | None = None
@dataclass
class RequestStats:
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
cost: float = 0.0
requests: int = 0
def to_dict(self) -> dict[str, int | float]:
return {
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cached_tokens": self.cached_tokens,
"cost": round(self.cost, 4),
"requests": self.requests,
}
class LLM:
def __init__(self, config: LLMConfig, agent_name: str | None = None):
self.config = config
self.agent_name = agent_name
self.agent_id: str | None = None
self._active_skills: list[str] = list(config.skills or [])
self._total_stats = RequestStats()
self.memory_compressor = MemoryCompressor(model_name=config.litellm_model)
self.system_prompt = self._load_system_prompt(agent_name)
reasoning = Config.get("strix_reasoning_effort")
if reasoning:
self._reasoning_effort = reasoning
elif config.scan_mode == "quick":
self._reasoning_effort = "medium"
else:
self._reasoning_effort = "high"
def _load_system_prompt(self, agent_name: str | None) -> str:
if not agent_name:
return ""
try:
prompt_dir = get_strix_resource_path("agents", agent_name)
skills_dir = get_strix_resource_path("skills")
env = Environment(
loader=FileSystemLoader([prompt_dir, skills_dir]),
autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),
)
skills_to_load = self._get_skills_to_load()
skill_content = load_skills(skills_to_load)
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
result = env.get_template("system_prompt.jinja").render(
get_tools_prompt=get_tools_prompt,
loaded_skill_names=list(skill_content.keys()),
interactive=self.config.interactive,
**skill_content,
)
return str(result)
except Exception: # noqa: BLE001
return ""
def _get_skills_to_load(self) -> list[str]:
ordered_skills = [*self._active_skills]
ordered_skills.append(f"scan_modes/{self.config.scan_mode}")
deduped: list[str] = []
seen: set[str] = set()
for skill_name in ordered_skills:
if skill_name not in seen:
deduped.append(skill_name)
seen.add(skill_name)
return deduped
def add_skills(self, skill_names: list[str]) -> list[str]:
added: list[str] = []
for skill_name in skill_names:
if not skill_name or skill_name in self._active_skills:
continue
self._active_skills.append(skill_name)
added.append(skill_name)
if not added:
return []
updated_prompt = self._load_system_prompt(self.agent_name)
if updated_prompt:
self.system_prompt = updated_prompt
return added
def set_agent_identity(self, agent_name: str | None, agent_id: str | None) -> None:
if agent_name:
self.agent_name = agent_name
if agent_id:
self.agent_id = agent_id
async def generate(
self, conversation_history: list[dict[str, Any]]
) -> AsyncIterator[LLMResponse]:
messages = self._prepare_messages(conversation_history)
max_retries = int(Config.get("strix_llm_max_retries") or "5")
for attempt in range(max_retries + 1):
try:
async for response in self._stream(messages):
yield response
return # noqa: TRY300
except Exception as e: # noqa: BLE001
if attempt >= max_retries or not self._should_retry(e):
self._raise_error(e)
wait = min(10, 2 * (2**attempt))
await asyncio.sleep(wait)
async def _stream(self, messages: list[dict[str, Any]]) -> AsyncIterator[LLMResponse]:
accumulated = ""
chunks: list[Any] = []
done_streaming = 0
self._total_stats.requests += 1
response = await acompletion(**self._build_completion_args(messages), stream=True)
async for chunk in response:
chunks.append(chunk)
if done_streaming:
done_streaming += 1
if getattr(chunk, "usage", None) or done_streaming > 5:
break
continue
delta = self._get_chunk_content(chunk)
if delta:
accumulated += delta
if "" in accumulated or "" in accumulated:
end_tag = "" if "" in accumulated else ""
pos = accumulated.find(end_tag)
accumulated = accumulated[: pos + len(end_tag)]
yield LLMResponse(content=accumulated)
done_streaming = 1
continue
yield LLMResponse(content=accumulated)
if chunks:
self._update_usage_stats(stream_chunk_builder(chunks))
accumulated = normalize_tool_format(accumulated)
accumulated = fix_incomplete_tool_call(_truncate_to_first_function(accumulated))
yield LLMResponse(
content=accumulated,
tool_invocations=parse_tool_invocations(accumulated),
thinking_blocks=self._extract_thinking(chunks),
)
def _prepare_messages(self, conversation_history: list[dict[str, Any]]) -> list[dict[str, Any]]:
messages = [{"role": "system", "content": self.system_prompt}]
if self.agent_name:
messages.append(
{
"role": "user",
"content": (
f"\n\n\n"
f"Internal metadata: do not echo or reference.\n"
f"{self.agent_name}\n"
f"{self.agent_id}\n"
f"\n\n"
),
}
)
compressed = list(self.memory_compressor.compress_history(conversation_history))
conversation_history.clear()
conversation_history.extend(compressed)
messages.extend(compressed)
if messages[-1].get("role") == "assistant" and not self.config.interactive:
messages.append({"role": "user", "content": "Continue the task."})
if self._is_anthropic() and self.config.enable_prompt_caching:
messages = self._add_cache_control(messages)
return messages
def _build_completion_args(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
if not self._supports_vision():
messages = self._strip_images(messages)
args: dict[str, Any] = {
"model": self.config.litellm_model,
"messages": messages,
"timeout": self.config.timeout,
"stream_options": {"include_usage": True},
}
if self.config.api_key:
args["api_key"] = self.config.api_key
if self.config.api_base:
args["api_base"] = self.config.api_base
if self._supports_reasoning():
args["reasoning_effort"] = self._reasoning_effort
return args
def _get_chunk_content(self, chunk: Any) -> str:
if chunk.choices and hasattr(chunk.choices[0], "delta"):
return getattr(chunk.choices[0].delta, "content", "") or ""
return ""
def _extract_thinking(self, chunks: list[Any]) -> list[dict[str, Any]] | None:
if not chunks or not self._supports_reasoning():
return None
try:
resp = stream_chunk_builder(chunks)
if resp.choices and hasattr(resp.choices[0].message, "thinking_blocks"):
blocks: list[dict[str, Any]] = resp.choices[0].message.thinking_blocks
return blocks
except Exception: # noqa: BLE001, S110 # nosec B110
pass
return None
def _update_usage_stats(self, response: Any) -> None:
try:
if hasattr(response, "usage") and response.usage:
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
cached_tokens = 0
if hasattr(response.usage, "prompt_tokens_details"):
prompt_details = response.usage.prompt_tokens_details
if hasattr(prompt_details, "cached_tokens"):
cached_tokens = prompt_details.cached_tokens or 0
cost = self._extract_cost(response)
else:
input_tokens = 0
output_tokens = 0
cached_tokens = 0
cost = 0.0
self._total_stats.input_tokens += input_tokens
self._total_stats.output_tokens += output_tokens
self._total_stats.cached_tokens += cached_tokens
self._total_stats.cost += cost
except Exception: # noqa: BLE001, S110 # nosec B110
pass
def _extract_cost(self, response: Any) -> float:
if hasattr(response, "usage") and response.usage:
direct_cost = getattr(response.usage, "cost", None)
if direct_cost is not None:
return float(direct_cost)
try:
if hasattr(response, "_hidden_params"):
response._hidden_params.pop("custom_llm_provider", None)
return completion_cost(response, model=self.config.canonical_model) or 0.0
except Exception: # noqa: BLE001
return 0.0
def _should_retry(self, e: Exception) -> bool:
code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
return code is None or litellm._should_retry(code)
def _raise_error(self, e: Exception) -> None:
from strix.telemetry import posthog
posthog.error("llm_error", type(e).__name__)
raise LLMRequestFailedError(f"LLM request failed: {type(e).__name__}", str(e)) from e
def _is_anthropic(self) -> bool:
if not self.config.model_name:
return False
return any(p in self.config.model_name.lower() for p in ["anthropic/", "claude"])
def _supports_vision(self) -> bool:
try:
return bool(supports_vision(model=self.config.canonical_model))
except Exception: # noqa: BLE001
return False
def _supports_reasoning(self) -> bool:
try:
return bool(supports_reasoning(model=self.config.canonical_model))
except Exception: # noqa: BLE001
return False
def _strip_images(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif isinstance(item, dict) and item.get("type") == "image_url":
text_parts.append("[Image removed - model doesn't support vision]")
result.append({**msg, "content": "\n".join(text_parts)})
else:
result.append(msg)
return result
def _add_cache_control(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not messages or not supports_prompt_caching(self.config.canonical_model):
return messages
result = list(messages)
if result[0].get("role") == "system":
content = result[0]["content"]
result[0] = {
**result[0],
"content": [
{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
]
if isinstance(content, str)
else content,
}
return result
================================================
FILE: strix/llm/memory_compressor.py
================================================
import logging
from typing import Any
import litellm
from strix.config.config import Config, resolve_llm_config
logger = logging.getLogger(__name__)
MAX_TOTAL_TOKENS = 100_000
MIN_RECENT_MESSAGES = 15
SUMMARY_PROMPT_TEMPLATE = """You are an agent performing context
condensation for a security agent. Your job is to compress scan data while preserving
ALL operationally critical information for continuing the security assessment.
CRITICAL ELEMENTS TO PRESERVE:
- Discovered vulnerabilities and potential attack vectors
- Scan results and tool outputs (compressed but maintaining key findings)
- Access credentials, tokens, or authentication details found
- System architecture insights and potential weak points
- Progress made in the assessment
- Failed attempts and dead ends (to avoid duplication)
- Any decisions made about the testing approach
COMPRESSION GUIDELINES:
- Preserve exact technical details (URLs, paths, parameters, payloads)
- Summarize verbose tool outputs while keeping critical findings
- Maintain version numbers, specific technologies identified
- Keep exact error messages that might indicate vulnerabilities
- Compress repetitive or similar findings into consolidated form
Remember: Another security agent will use this summary to continue the assessment.
They must be able to pick up exactly where you left off without losing any
operational advantage or context needed to find vulnerabilities.
CONVERSATION SEGMENT TO SUMMARIZE:
{conversation}
Provide a technically precise summary that preserves all operational security context while
keeping the summary concise and to the point."""
def _count_tokens(text: str, model: str) -> int:
try:
count = litellm.token_counter(model=model, text=text)
return int(count)
except Exception:
logger.exception("Failed to count tokens")
return len(text) // 4 # Rough estimate
def _get_message_tokens(msg: dict[str, Any], model: str) -> int:
content = msg.get("content", "")
if isinstance(content, str):
return _count_tokens(content, model)
if isinstance(content, list):
return sum(
_count_tokens(item.get("text", ""), model)
for item in content
if isinstance(item, dict) and item.get("type") == "text"
)
return 0
def _extract_message_text(msg: dict[str, Any]) -> str:
content = msg.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(item.get("text", ""))
elif item.get("type") == "image_url":
parts.append("[IMAGE]")
return " ".join(parts)
return str(content)
def _summarize_messages(
messages: list[dict[str, Any]],
model: str,
timeout: int = 30,
) -> dict[str, Any]:
if not messages:
empty_summary = "{text}"
return {
"role": "user",
"content": empty_summary.format(text="No messages to summarize"),
}
formatted = []
for msg in messages:
role = msg.get("role", "unknown")
text = _extract_message_text(msg)
formatted.append(f"{role}: {text}")
conversation = "\n".join(formatted)
prompt = SUMMARY_PROMPT_TEMPLATE.format(conversation=conversation)
_, api_key, api_base = resolve_llm_config()
try:
completion_args: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"timeout": timeout,
}
if api_key:
completion_args["api_key"] = api_key
if api_base:
completion_args["api_base"] = api_base
response = litellm.completion(**completion_args)
summary = response.choices[0].message.content or ""
if not summary.strip():
return messages[0]
summary_msg = "{text}"
return {
"role": "user",
"content": summary_msg.format(count=len(messages), text=summary),
}
except Exception:
logger.exception("Failed to summarize messages")
return messages[0]
def _handle_images(messages: list[dict[str, Any]], max_images: int) -> None:
image_count = 0
for msg in reversed(messages):
content = msg.get("content", [])
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "image_url":
if image_count >= max_images:
item.update(
{
"type": "text",
"text": "[Previously attached image removed to preserve context]",
}
)
else:
image_count += 1
class MemoryCompressor:
def __init__(
self,
max_images: int = 3,
model_name: str | None = None,
timeout: int | None = None,
):
self.max_images = max_images
self.model_name = model_name or Config.get("strix_llm")
self.timeout = timeout or int(Config.get("strix_memory_compressor_timeout") or "120")
if not self.model_name:
raise ValueError("STRIX_LLM environment variable must be set and not empty")
def compress_history(
self,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Compress conversation history to stay within token limits.
Strategy:
1. Handle image limits first
2. Keep all system messages
3. Keep minimum recent messages
4. Summarize older messages when total tokens exceed limit
The compression preserves:
- All system messages unchanged
- Most recent messages intact
- Critical security context in summaries
- Recent images for visual context
- Technical details and findings
"""
if not messages:
return messages
_handle_images(messages, self.max_images)
system_msgs = []
regular_msgs = []
for msg in messages:
if msg.get("role") == "system":
system_msgs.append(msg)
else:
regular_msgs.append(msg)
recent_msgs = regular_msgs[-MIN_RECENT_MESSAGES:]
old_msgs = regular_msgs[:-MIN_RECENT_MESSAGES]
# Type assertion since we ensure model_name is not None in __init__
model_name: str = self.model_name # type: ignore[assignment]
total_tokens = sum(
_get_message_tokens(msg, model_name) for msg in system_msgs + regular_msgs
)
if total_tokens <= MAX_TOTAL_TOKENS * 0.9:
return messages
compressed = []
chunk_size = 10
for i in range(0, len(old_msgs), chunk_size):
chunk = old_msgs[i : i + chunk_size]
summary = _summarize_messages(chunk, model_name, self.timeout)
if summary:
compressed.append(summary)
return system_msgs + compressed + recent_msgs
================================================
FILE: strix/llm/utils.py
================================================
import html
import re
from typing import Any
_INVOKE_OPEN = re.compile(r'')
_PARAM_NAME_ATTR = re.compile(r'')
_FUNCTION_CALLS_TAG = re.compile(r"?function_calls>")
_STRIP_TAG_QUOTES = re.compile(r"<(function|parameter)\s*=\s*([^>]*?)>")
def normalize_tool_format(content: str) -> str:
"""Convert alternative tool-call XML formats to the expected one.
Handles:
... → stripped
→ → →
→ →
"""
if "", content)
content = _PARAM_NAME_ATTR.sub(r"", content)
content = content.replace("", "")
return _STRIP_TAG_QUOTES.sub(
lambda m: f"<{m.group(1)}={m.group(2).strip().strip(chr(34) + chr(39))}>", content
)
STRIX_MODEL_MAP: dict[str, str] = {
"claude-sonnet-4.6": "anthropic/claude-sonnet-4-6",
"claude-opus-4.6": "anthropic/claude-opus-4-6",
"gpt-5.2": "openai/gpt-5.2",
"gpt-5.1": "openai/gpt-5.1",
"gpt-5": "openai/gpt-5",
"gemini-3-pro-preview": "gemini/gemini-3-pro-preview",
"gemini-3-flash-preview": "gemini/gemini-3-flash-preview",
"glm-5": "openrouter/z-ai/glm-5",
"glm-4.7": "openrouter/z-ai/glm-4.7",
}
def resolve_strix_model(model_name: str | None) -> tuple[str | None, str | None]:
"""Resolve a strix/ model into names for API calls and capability lookups.
Returns (api_model, canonical_model):
- api_model: openai/ for API calls (Strix API is OpenAI-compatible)
- canonical_model: actual provider model name for litellm capability lookups
Non-strix models return the same name for both.
"""
if not model_name or not model_name.startswith("strix/"):
return model_name, model_name
base_model = model_name[6:]
api_model = f"openai/{base_model}"
canonical_model = STRIX_MODEL_MAP.get(base_model, api_model)
return api_model, canonical_model
def _truncate_to_first_function(content: str) -> str:
if not content:
return content
function_starts = [
match.start() for match in re.finditer(r"= 2:
second_function_start = function_starts[1]
return content[:second_function_start].rstrip()
return content
def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content)
tool_invocations: list[dict[str, Any]] = []
fn_regex_pattern = r"]+)>\n?(.*?)"
fn_param_regex_pattern = r"]+)>(.*?)"
fn_matches = re.finditer(fn_regex_pattern, content, re.DOTALL)
for fn_match in fn_matches:
fn_name = fn_match.group(1)
fn_body = fn_match.group(2)
param_matches = re.finditer(fn_param_regex_pattern, fn_body, re.DOTALL)
args = {}
for param_match in param_matches:
param_name = param_match.group(1)
param_value = param_match.group(2).strip()
param_value = html.unescape(param_value)
args[param_name] = param_value
tool_invocations.append({"toolName": fn_name, "args": args})
return tool_invocations if tool_invocations else None
def fix_incomplete_tool_call(content: str) -> str:
"""Fix incomplete tool calls by adding missing closing tag.
Handles both ```` and ```` formats.
"""
has_open = "" in content
if has_open and count_open == 1 and not has_close:
content = content.rstrip()
content = content + "function>" if content.endswith("") else content + "\n"
return content
def format_tool_call(tool_name: str, args: dict[str, Any]) -> str:
xml_parts = [f""]
for key, value in args.items():
xml_parts.append(f"{value}")
xml_parts.append("")
return "\n".join(xml_parts)
def clean_content(content: str) -> str:
if not content:
return ""
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content)
tool_pattern = r"]+>.*?"
cleaned = re.sub(tool_pattern, "", content, flags=re.DOTALL)
incomplete_tool_pattern = r"]+>.*$"
cleaned = re.sub(incomplete_tool_pattern, "", cleaned, flags=re.DOTALL)
partial_tag_pattern = r"]*)?)?)?)?)?)?)?)?)?$"
cleaned = re.sub(partial_tag_pattern, "", cleaned)
hidden_xml_patterns = [
r".*?",
r".*?",
]
for pattern in hidden_xml_patterns:
cleaned = re.sub(pattern, "", cleaned, flags=re.DOTALL | re.IGNORECASE)
cleaned = re.sub(r"\n\s*\n", "\n\n", cleaned)
return cleaned.strip()
================================================
FILE: strix/runtime/__init__.py
================================================
from strix.config import Config
from .runtime import AbstractRuntime
class SandboxInitializationError(Exception):
"""Raised when sandbox initialization fails (e.g., Docker issues)."""
def __init__(self, message: str, details: str | None = None):
super().__init__(message)
self.message = message
self.details = details
_global_runtime: AbstractRuntime | None = None
def get_runtime() -> AbstractRuntime:
global _global_runtime # noqa: PLW0603
runtime_backend = Config.get("strix_runtime_backend")
if runtime_backend == "docker":
from .docker_runtime import DockerRuntime
if _global_runtime is None:
_global_runtime = DockerRuntime()
return _global_runtime
raise ValueError(
f"Unsupported runtime backend: {runtime_backend}. Only 'docker' is supported for now."
)
def cleanup_runtime() -> None:
global _global_runtime # noqa: PLW0603
if _global_runtime is not None:
_global_runtime.cleanup()
_global_runtime = None
__all__ = ["AbstractRuntime", "SandboxInitializationError", "cleanup_runtime", "get_runtime"]
================================================
FILE: strix/runtime/docker_runtime.py
================================================
import contextlib
import os
import secrets
import socket
import time
from pathlib import Path
from typing import cast
import docker
import httpx
from docker.errors import DockerException, ImageNotFound, NotFound
from docker.models.containers import Container
from requests.exceptions import ConnectionError as RequestsConnectionError
from requests.exceptions import Timeout as RequestsTimeout
from strix.config import Config
from . import SandboxInitializationError
from .runtime import AbstractRuntime, SandboxInfo
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
DOCKER_TIMEOUT = 60
CONTAINER_TOOL_SERVER_PORT = 48081
CONTAINER_CAIDO_PORT = 48080
class DockerRuntime(AbstractRuntime):
def __init__(self) -> None:
try:
self.client = docker.from_env(timeout=DOCKER_TIMEOUT)
except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
raise SandboxInitializationError(
"Docker is not available",
"Please ensure Docker Desktop is installed and running.",
) from e
self._scan_container: Container | None = None
self._tool_server_port: int | None = None
self._tool_server_token: str | None = None
self._caido_port: int | None = None
def _find_available_port(self) -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return cast("int", s.getsockname()[1])
def _get_scan_id(self, agent_id: str) -> str:
try:
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer and tracer.scan_config:
return str(tracer.scan_config.get("scan_id", "default-scan"))
except (ImportError, AttributeError):
pass
return f"scan-{agent_id.split('-')[0]}"
def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None:
for attempt in range(max_retries):
try:
image = self.client.images.get(image_name)
if not image.id or not image.attrs:
raise ImageNotFound(f"Image {image_name} metadata incomplete") # noqa: TRY301
except (ImageNotFound, DockerException):
if attempt == max_retries - 1:
raise
time.sleep(2**attempt)
else:
return
def _recover_container_state(self, container: Container) -> None:
for env_var in container.attrs["Config"]["Env"]:
if env_var.startswith("TOOL_SERVER_TOKEN="):
self._tool_server_token = env_var.split("=", 1)[1]
break
port_bindings = container.attrs.get("NetworkSettings", {}).get("Ports", {})
port_key = f"{CONTAINER_TOOL_SERVER_PORT}/tcp"
if port_bindings.get(port_key):
self._tool_server_port = int(port_bindings[port_key][0]["HostPort"])
caido_port_key = f"{CONTAINER_CAIDO_PORT}/tcp"
if port_bindings.get(caido_port_key):
self._caido_port = int(port_bindings[caido_port_key][0]["HostPort"])
def _wait_for_tool_server(self, max_retries: int = 30, timeout: int = 5) -> None:
host = self._resolve_docker_host()
health_url = f"http://{host}:{self._tool_server_port}/health"
time.sleep(5)
for attempt in range(max_retries):
try:
with httpx.Client(trust_env=False, timeout=timeout) as client:
response = client.get(health_url)
if response.status_code == 200:
data = response.json()
if data.get("status") == "healthy":
return
except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError):
pass
time.sleep(min(2**attempt * 0.5, 5))
raise SandboxInitializationError(
"Tool server failed to start",
"Container initialization timed out. Please try again.",
)
def _create_container(self, scan_id: str, max_retries: int = 2) -> Container:
container_name = f"strix-scan-{scan_id}"
image_name = Config.get("strix_image")
if not image_name:
raise ValueError("STRIX_IMAGE must be configured")
self._verify_image_available(image_name)
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
with contextlib.suppress(NotFound):
existing = self.client.containers.get(container_name)
with contextlib.suppress(Exception):
existing.stop(timeout=5)
existing.remove(force=True)
time.sleep(1)
self._tool_server_port = self._find_available_port()
self._caido_port = self._find_available_port()
self._tool_server_token = secrets.token_urlsafe(32)
execution_timeout = Config.get("strix_sandbox_execution_timeout") or "120"
container = self.client.containers.run(
image_name,
command="sleep infinity",
detach=True,
name=container_name,
hostname=container_name,
ports={
f"{CONTAINER_TOOL_SERVER_PORT}/tcp": self._tool_server_port,
f"{CONTAINER_CAIDO_PORT}/tcp": self._caido_port,
},
cap_add=["NET_ADMIN", "NET_RAW"],
labels={"strix-scan-id": scan_id},
environment={
"PYTHONUNBUFFERED": "1",
"TOOL_SERVER_PORT": str(CONTAINER_TOOL_SERVER_PORT),
"TOOL_SERVER_TOKEN": self._tool_server_token,
"STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout),
"HOST_GATEWAY": HOST_GATEWAY_HOSTNAME,
},
extra_hosts={HOST_GATEWAY_HOSTNAME: "host-gateway"},
tty=True,
)
self._scan_container = container
self._wait_for_tool_server()
except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
last_error = e
if attempt < max_retries:
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
time.sleep(2**attempt)
else:
return container
raise SandboxInitializationError(
"Failed to create container",
f"Container creation failed after {max_retries + 1} attempts: {last_error}",
) from last_error
def _get_or_create_container(self, scan_id: str) -> Container:
container_name = f"strix-scan-{scan_id}"
if self._scan_container:
try:
self._scan_container.reload()
if self._scan_container.status == "running":
return self._scan_container
except NotFound:
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
try:
container = self.client.containers.get(container_name)
container.reload()
if container.status != "running":
container.start()
time.sleep(2)
self._scan_container = container
self._recover_container_state(container)
except NotFound:
pass
else:
return container
try:
containers = self.client.containers.list(
all=True, filters={"label": f"strix-scan-id={scan_id}"}
)
if containers:
container = containers[0]
if container.status != "running":
container.start()
time.sleep(2)
self._scan_container = container
self._recover_container_state(container)
return container
except DockerException:
pass
return self._create_container(scan_id)
def _copy_local_directory_to_container(
self, container: Container, local_path: str, target_name: str | None = None
) -> None:
import tarfile
from io import BytesIO
try:
local_path_obj = Path(local_path).resolve()
if not local_path_obj.exists() or not local_path_obj.is_dir():
return
tar_buffer = BytesIO()
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
for item in local_path_obj.rglob("*"):
if item.is_file():
rel_path = item.relative_to(local_path_obj)
arcname = Path(target_name) / rel_path if target_name else rel_path
tar.add(item, arcname=arcname)
tar_buffer.seek(0)
container.put_archive("/workspace", tar_buffer.getvalue())
container.exec_run(
"chown -R pentester:pentester /workspace && chmod -R 755 /workspace",
user="root",
)
except (OSError, DockerException):
pass
async def create_sandbox(
self,
agent_id: str,
existing_token: str | None = None,
local_sources: list[dict[str, str]] | None = None,
) -> SandboxInfo:
scan_id = self._get_scan_id(agent_id)
container = self._get_or_create_container(scan_id)
source_copied_key = f"_source_copied_{scan_id}"
if local_sources and not hasattr(self, source_copied_key):
for index, source in enumerate(local_sources, start=1):
source_path = source.get("source_path")
if not source_path:
continue
target_name = (
source.get("workspace_subdir") or Path(source_path).name or f"target_{index}"
)
self._copy_local_directory_to_container(container, source_path, target_name)
setattr(self, source_copied_key, True)
if container.id is None:
raise RuntimeError("Docker container ID is unexpectedly None")
token = existing_token or self._tool_server_token
if self._tool_server_port is None or self._caido_port is None or token is None:
raise RuntimeError("Tool server not initialized")
host = self._resolve_docker_host()
api_url = f"http://{host}:{self._tool_server_port}"
await self._register_agent(api_url, agent_id, token)
return {
"workspace_id": container.id,
"api_url": api_url,
"auth_token": token,
"tool_server_port": self._tool_server_port,
"caido_port": self._caido_port,
"agent_id": agent_id,
}
async def _register_agent(self, api_url: str, agent_id: str, token: str) -> None:
try:
async with httpx.AsyncClient(trust_env=False) as client:
response = await client.post(
f"{api_url}/register_agent",
params={"agent_id": agent_id},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
response.raise_for_status()
except httpx.RequestError:
pass
async def get_sandbox_url(self, container_id: str, port: int) -> str:
try:
self.client.containers.get(container_id)
return f"http://{self._resolve_docker_host()}:{port}"
except NotFound:
raise ValueError(f"Container {container_id} not found.") from None
def _resolve_docker_host(self) -> str:
docker_host = os.getenv("DOCKER_HOST", "")
if docker_host:
from urllib.parse import urlparse
parsed = urlparse(docker_host)
if parsed.scheme in ("tcp", "http", "https") and parsed.hostname:
return parsed.hostname
return "127.0.0.1"
async def destroy_sandbox(self, container_id: str) -> None:
try:
container = self.client.containers.get(container_id)
container.stop()
container.remove()
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
except (NotFound, DockerException):
pass
def cleanup(self) -> None:
if self._scan_container is not None:
container_name = self._scan_container.name
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
if container_name is None:
return
import subprocess
subprocess.Popen( # noqa: S603
["docker", "rm", "-f", container_name], # noqa: S607
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
================================================
FILE: strix/runtime/runtime.py
================================================
from abc import ABC, abstractmethod
from typing import TypedDict
class SandboxInfo(TypedDict):
workspace_id: str
api_url: str
auth_token: str | None
tool_server_port: int
caido_port: int
agent_id: str
class AbstractRuntime(ABC):
@abstractmethod
async def create_sandbox(
self,
agent_id: str,
existing_token: str | None = None,
local_sources: list[dict[str, str]] | None = None,
) -> SandboxInfo:
raise NotImplementedError
@abstractmethod
async def get_sandbox_url(self, container_id: str, port: int) -> str:
raise NotImplementedError
@abstractmethod
async def destroy_sandbox(self, container_id: str) -> None:
raise NotImplementedError
def cleanup(self) -> None:
raise NotImplementedError
================================================
FILE: strix/runtime/tool_server.py
================================================
from __future__ import annotations
import argparse
import asyncio
import os
import signal
import sys
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ValidationError
SANDBOX_MODE = os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
if not SANDBOX_MODE:
raise RuntimeError("Tool server should only run in sandbox mode (STRIX_SANDBOX_MODE=true)")
parser = argparse.ArgumentParser(description="Start Strix tool server")
parser.add_argument("--token", required=True, help="Authentication token")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") # nosec
parser.add_argument("--port", type=int, required=True, help="Port to bind to")
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Hard timeout in seconds for each request execution (default: 120)",
)
args = parser.parse_args()
EXPECTED_TOKEN = args.token
REQUEST_TIMEOUT = args.timeout
app = FastAPI()
security = HTTPBearer()
security_dependency = Depends(security)
agent_tasks: dict[str, asyncio.Task[Any]] = {}
def verify_token(credentials: HTTPAuthorizationCredentials) -> str:
if not credentials or credentials.scheme != "Bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme. Bearer token required.",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials.credentials != EXPECTED_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return credentials.credentials
class ToolExecutionRequest(BaseModel):
agent_id: str
tool_name: str
kwargs: dict[str, Any]
class ToolExecutionResponse(BaseModel):
result: Any | None = None
error: str | None = None
async def _run_tool(agent_id: str, tool_name: str, kwargs: dict[str, Any]) -> Any:
from strix.tools.argument_parser import convert_arguments
from strix.tools.context import set_current_agent_id
from strix.tools.registry import get_tool_by_name
set_current_agent_id(agent_id)
tool_func = get_tool_by_name(tool_name)
if not tool_func:
raise ValueError(f"Tool '{tool_name}' not found")
converted_kwargs = convert_arguments(tool_func, kwargs)
return await asyncio.to_thread(tool_func, **converted_kwargs)
@app.post("/execute", response_model=ToolExecutionResponse)
async def execute_tool(
request: ToolExecutionRequest, credentials: HTTPAuthorizationCredentials = security_dependency
) -> ToolExecutionResponse:
verify_token(credentials)
agent_id = request.agent_id
if agent_id in agent_tasks:
old_task = agent_tasks[agent_id]
if not old_task.done():
old_task.cancel()
task = asyncio.create_task(
asyncio.wait_for(
_run_tool(agent_id, request.tool_name, request.kwargs), timeout=REQUEST_TIMEOUT
)
)
agent_tasks[agent_id] = task
try:
result = await task
return ToolExecutionResponse(result=result)
except asyncio.CancelledError:
return ToolExecutionResponse(error="Cancelled by newer request")
except TimeoutError:
return ToolExecutionResponse(error=f"Tool timed out after {REQUEST_TIMEOUT}s")
except ValidationError as e:
return ToolExecutionResponse(error=f"Invalid arguments: {e}")
except (ValueError, RuntimeError, ImportError) as e:
return ToolExecutionResponse(error=f"Tool execution error: {e}")
except Exception as e: # noqa: BLE001
return ToolExecutionResponse(error=f"Unexpected error: {e}")
finally:
if agent_tasks.get(agent_id) is task:
del agent_tasks[agent_id]
@app.post("/register_agent")
async def register_agent(
agent_id: str, credentials: HTTPAuthorizationCredentials = security_dependency
) -> dict[str, str]:
verify_token(credentials)
return {"status": "registered", "agent_id": agent_id}
@app.get("/health")
async def health_check() -> dict[str, Any]:
return {
"status": "healthy",
"sandbox_mode": str(SANDBOX_MODE),
"environment": "sandbox" if SANDBOX_MODE else "main",
"auth_configured": "true" if EXPECTED_TOKEN else "false",
"active_agents": len(agent_tasks),
"agents": list(agent_tasks.keys()),
}
def signal_handler(_signum: int, _frame: Any) -> None:
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
for task in agent_tasks.values():
task.cancel()
sys.exit(0)
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
================================================
FILE: strix/skills/README.md
================================================
# 📚 Strix Skills
## 🎯 Overview
Skills are specialized knowledge packages that enhance Strix agents with deep expertise in specific vulnerability types, technologies, and testing methodologies. Each skill provides advanced techniques, practical examples, and validation methods that go beyond baseline security knowledge.
---
## 🏗️ Architecture
### How Skills Work
When an agent is created, it can load up to 5 specialized skills relevant to the specific subtask and context at hand:
```python
# Agent creation with specialized skills
create_agent(
task="Test authentication mechanisms in API",
name="Auth Specialist",
skills="authentication_jwt,business_logic"
)
```
The skills are dynamically injected into the agent's system prompt, allowing it to operate with deep expertise tailored to the specific vulnerability types or technologies required for the task at hand.
---
## 📁 Skill Categories
| Category | Purpose |
|----------|---------|
| **`/vulnerabilities`** | Advanced testing techniques for core vulnerability classes like authentication bypasses, business logic flaws, and race conditions |
| **`/frameworks`** | Specific testing methods for popular frameworks e.g. Django, Express, FastAPI, and Next.js |
| **`/technologies`** | Specialized techniques for third-party services such as Supabase, Firebase, Auth0, and payment gateways |
| **`/protocols`** | Protocol-specific testing patterns for GraphQL, WebSocket, OAuth, and other communication standards |
| **`/tooling`** | Command-line playbooks for core sandbox tools (nmap, nuclei, httpx, ffuf, subfinder, naabu, katana, sqlmap) |
| **`/cloud`** | Cloud provider security testing for AWS, Azure, GCP, and Kubernetes environments |
| **`/reconnaissance`** | Advanced information gathering and enumeration techniques for comprehensive attack surface mapping |
| **`/custom`** | Community-contributed skills for specialized or industry-specific testing scenarios |
---
## 🎨 Creating New Skills
### What Should a Skill Contain?
A good skill is a structured knowledge package that typically includes:
- **Advanced techniques** - Non-obvious methods specific to the task and domain
- **Practical examples** - Working payloads, commands, or test cases with variations
- **Validation methods** - How to confirm findings and avoid false positives
- **Context-specific insights** - Environment and version nuances, configuration-dependent behavior, and edge cases
- **YAML frontmatter** - `name` and `description` fields for skill metadata
Skills focus on deep, specialized knowledge to significantly enhance agent capabilities. They are dynamically injected into agent context when needed.
---
## 🤝 Contributing
Community contributions are more than welcome — contribute new skills via [pull requests](https://github.com/usestrix/strix/pulls) or [GitHub issues](https://github.com/usestrix/strix/issues) to help expand the collection and improve extensibility for Strix agents.
---
> [!NOTE]
> **Work in Progress** - We're actively expanding the skills collection with specialized techniques and new categories.
================================================
FILE: strix/skills/__init__.py
================================================
import re
from strix.utils.resource_paths import get_strix_resource_path
_EXCLUDED_CATEGORIES = {"scan_modes", "coordination"}
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
def get_available_skills() -> dict[str, list[str]]:
skills_dir = get_strix_resource_path("skills")
available_skills: dict[str, list[str]] = {}
if not skills_dir.exists():
return available_skills
for category_dir in skills_dir.iterdir():
if category_dir.is_dir() and not category_dir.name.startswith("__"):
category_name = category_dir.name
if category_name in _EXCLUDED_CATEGORIES:
continue
skills = []
for file_path in category_dir.glob("*.md"):
skill_name = file_path.stem
skills.append(skill_name)
if skills:
available_skills[category_name] = sorted(skills)
return available_skills
def get_all_skill_names() -> set[str]:
all_skills = set()
for category_skills in get_available_skills().values():
all_skills.update(category_skills)
return all_skills
def validate_skill_names(skill_names: list[str]) -> dict[str, list[str]]:
available_skills = get_all_skill_names()
valid_skills = []
invalid_skills = []
for skill_name in skill_names:
if skill_name in available_skills:
valid_skills.append(skill_name)
else:
invalid_skills.append(skill_name)
return {"valid": valid_skills, "invalid": invalid_skills}
def parse_skill_list(skills: str | None) -> list[str]:
if not skills:
return []
return [s.strip() for s in skills.split(",") if s.strip()]
def validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str | None:
if len(skill_list) > max_skills:
return "Cannot specify more than 5 skills for an agent (use comma-separated format)"
if not skill_list:
return None
validation = validate_skill_names(skill_list)
if validation["invalid"]:
available_skills = list(get_all_skill_names())
return (
f"Invalid skills: {validation['invalid']}. "
f"Available skills: {', '.join(available_skills)}"
)
return None
def generate_skills_description() -> str:
available_skills = get_available_skills()
if not available_skills:
return "No skills available"
all_skill_names = get_all_skill_names()
if not all_skill_names:
return "No skills available"
sorted_skills = sorted(all_skill_names)
skills_str = ", ".join(sorted_skills)
description = f"List of skills to load for this agent (max 5). Available skills: {skills_str}. "
example_skills = sorted_skills[:2]
if example_skills:
example = f"Example: {', '.join(example_skills)} for specialized agent"
description += example
return description
def _get_all_categories() -> dict[str, list[str]]:
"""Get all skill categories including internal ones (scan_modes, coordination)."""
skills_dir = get_strix_resource_path("skills")
all_categories: dict[str, list[str]] = {}
if not skills_dir.exists():
return all_categories
for category_dir in skills_dir.iterdir():
if category_dir.is_dir() and not category_dir.name.startswith("__"):
category_name = category_dir.name
skills = []
for file_path in category_dir.glob("*.md"):
skill_name = file_path.stem
skills.append(skill_name)
if skills:
all_categories[category_name] = sorted(skills)
return all_categories
def load_skills(skill_names: list[str]) -> dict[str, str]:
import logging
logger = logging.getLogger(__name__)
skill_content = {}
skills_dir = get_strix_resource_path("skills")
all_categories = _get_all_categories()
for skill_name in skill_names:
try:
skill_path = None
if "/" in skill_name:
skill_path = f"{skill_name}.md"
else:
for category, skills in all_categories.items():
if skill_name in skills:
skill_path = f"{category}/{skill_name}.md"
break
if not skill_path:
root_candidate = f"{skill_name}.md"
if (skills_dir / root_candidate).exists():
skill_path = root_candidate
if skill_path and (skills_dir / skill_path).exists():
full_path = skills_dir / skill_path
var_name = skill_name.split("/")[-1]
content = full_path.read_text(encoding="utf-8")
content = _FRONTMATTER_PATTERN.sub("", content).lstrip()
skill_content[var_name] = content
logger.info(f"Loaded skill: {skill_name} -> {var_name}")
else:
logger.warning(f"Skill not found: {skill_name}")
except (FileNotFoundError, OSError, ValueError) as e:
logger.warning(f"Failed to load skill {skill_name}: {e}")
return skill_content
================================================
FILE: strix/skills/cloud/.gitkeep
================================================
================================================
FILE: strix/skills/coordination/root_agent.md
================================================
---
name: root-agent
description: Orchestration layer that coordinates specialized subagents for security assessments
---
# Root Agent
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly.
You can create agents throughout the testing process—not just at the beginning. Spawn agents dynamically based on findings and evolving scope.
## Role
- Decompose targets into discrete, parallelizable tasks
- Spawn and monitor specialized subagents
- Aggregate findings into a cohesive final report
- Manage dependencies and handoffs between agents
## Scope Decomposition
Before spawning agents, analyze the target:
1. **Identify attack surfaces** - web apps, APIs, infrastructure, etc.
2. **Define boundaries** - in-scope domains, IP ranges, excluded assets
3. **Determine approach** - blackbox, greybox, or whitebox assessment
4. **Prioritize by risk** - critical assets and high-value targets first
## Agent Architecture
Structure agents by function:
**Reconnaissance**
- Asset discovery and enumeration
- Technology fingerprinting
- Attack surface mapping
**Vulnerability Assessment**
- Injection testing (SQLi, XSS, command injection)
- Authentication and session analysis
- Access control testing (IDOR, privilege escalation)
- Business logic flaws
- Infrastructure vulnerabilities
**Exploitation and Validation**
- Proof-of-concept development
- Impact demonstration
- Vulnerability chaining
**Reporting**
- Finding documentation
- Remediation recommendations
## Coordination Principles
**Task Independence**
Create agents with minimal dependencies. Parallel execution is faster than sequential.
**Clear Objectives**
Each agent should have a specific, measurable goal. Vague objectives lead to scope creep and redundant work.
**Avoid Duplication**
Before creating agents:
1. Analyze the target scope and break into independent tasks
2. Check existing agents to avoid overlap
3. Create agents with clear, specific objectives
**Hierarchical Delegation**
Complex findings warrant specialized subagents:
- Discovery agent finds potential vulnerability
- Validation agent confirms exploitability
- Reporting agent documents with reproduction steps
- Fix agent provides remediation (if needed)
**Resource Efficiency**
- Avoid duplicate coverage across agents
- Terminate agents when objectives are met or no longer relevant
- Use message passing only when essential (requests/answers, critical handoffs)
- Prefer batched updates over routine status messages
## Completion
When all agents report completion:
1. Collect and deduplicate findings across agents
2. Assess overall security posture
3. Compile executive summary with prioritized recommendations
4. Invoke finish tool with final report
================================================
FILE: strix/skills/custom/.gitkeep
================================================
================================================
FILE: strix/skills/frameworks/fastapi.md
================================================
---
name: fastapi
description: Security testing playbook for FastAPI applications covering ASGI, dependency injection, and API vulnerabilities
---
# FastAPI
Security testing for FastAPI/Starlette applications. Focus on dependency injection flaws, middleware gaps, and authorization drift across routers and channels.
## Attack Surface
**Core Components**
- ASGI middlewares: CORS, TrustedHost, ProxyHeaders, Session, exception handlers, lifespan events
- Routers and sub-apps: APIRouter prefixes/tags, mounted apps (StaticFiles, admin), `include_router`, versioned paths
- Dependency injection: `Depends`, `Security`, `OAuth2PasswordBearer`, `HTTPBearer`, scopes
**Data Handling**
- Pydantic models: v1/v2, unions/Annotated, custom validators, extra fields policy, coercion
- File operations: UploadFile, File, FileResponse, StaticFiles mounts
- Templates: Jinja2Templates rendering
**Channels**
- HTTP (sync/async), WebSocket, SSE/StreamingResponse
- BackgroundTasks and task queues
**Deployment**
- Uvicorn/Gunicorn, reverse proxies/CDN, TLS termination, header trust
## High-Value Targets
- `/openapi.json`, `/docs`, `/redoc` in production (full attack surface map, securitySchemes, server URLs)
- Auth flows: token endpoints, session/cookie bridges, OAuth device/PKCE
- Admin/staff routers, feature-flagged routes, `include_in_schema=False` endpoints
- File upload/download, import/export/report endpoints, signed URL generators
- WebSocket endpoints (notifications, admin channels, commands)
- Background job endpoints (`/jobs/{id}`, `/tasks/{id}/result`)
- Mounted subapps (admin UI, storage browsers, metrics/health)
## Reconnaissance
**OpenAPI Mining**
```
GET /openapi.json
GET /docs
GET /redoc
GET /api/openapi.json
GET /internal/openapi.json
```
Extract: paths, parameters, securitySchemes, scopes, servers. Endpoints with `include_in_schema=False` won't appear—fuzz based on discovered prefixes and common admin/debug names.
**Dependency Mapping**
For each route, identify:
- Router-level dependencies (applied to all routes)
- Route-level dependencies (per endpoint)
- Which dependencies enforce auth vs just parse input
## Key Vulnerabilities
### Authentication & Authorization
**Dependency Injection Gaps**
- Routes missing security dependencies present on other routes
- `Depends` used instead of `Security` (ignores scope enforcement)
- Token presence treated as authentication without signature verification
- `OAuth2PasswordBearer` only yields a token string—verify routes don't treat presence as auth
**JWT Misuse**
- Decode without verify: test unsigned tokens, attacker-signed tokens
- Algorithm confusion: HS256/RS256 cross-use if not pinned
- `kid` header injection for custom key lookup paths
- Missing issuer/audience validation, cross-service token reuse
**Session Weaknesses**
- SessionMiddleware with weak `secret_key`
- Session fixation via predictable signing
- Cookie-based auth without CSRF protection
**OAuth/OIDC**
- Device/PKCE flows: verify strict PKCE S256 and state/nonce enforcement
### Access Control
**IDOR via Dependencies**
- Object IDs in path/query not validated against caller
- Tenant headers trusted without binding to authenticated user
- BackgroundTasks acting on IDs without re-validating ownership at execution time
- Export/import pipelines with IDOR and cross-tenant leaks
**Scope Bypass**
- Minimal scope satisfaction (any valid token accepted)
- Router vs route scope enforcement inconsistency
### Input Handling
**Pydantic Exploitation**
- Type coercion: strings to ints/bools, empty strings to None, truthiness edge cases
- Extra fields: `extra = "allow"` permits injecting control fields (role, ownerId, scope)
- Union types and `Annotated`: craft shapes hitting unintended validation branches
**Content-Type Switching**
```
application/json ↔ application/x-www-form-urlencoded ↔ multipart/form-data
```
Different content types hit different validators or code paths (parser differentials).
**Parameter Manipulation**
- Case variations in header/cookie names
- Duplicate parameters exploiting DI precedence
- Method override via `X-HTTP-Method-Override` (upstream respects, app doesn't)
### CORS & CSRF
**CORS Misconfiguration**
- Overly broad `allow_origin_regex`
- Origin reflection without validation
- Credentialed requests with permissive origins
- Verify preflight vs actual request deltas
**CSRF Exposure**
- No built-in CSRF in FastAPI/Starlette
- Cookie-based auth without origin validation
- Missing SameSite attribute
### Proxy & Host Trust
**Header Spoofing**
- ProxyHeadersMiddleware without network boundary: spoof `X-Forwarded-For/Proto` to influence auth/IP gating
- Absent TrustedHostMiddleware: Host header poisoning in password reset links, absolute URL generation
- Cache key confusion: missing Vary on Authorization/Cookie/Tenant
### Server-Side Vulnerabilities
**Template Injection (Jinja2)**
```python
{{7*7}} # Arithmetic confirmation
{{cycler.__init__.__globals__['os'].popen('id').read()}} # RCE
```
Check autoescape settings and custom filters/globals.
**SSRF**
- User-supplied URLs in imports, previews, webhooks validation
- Test: loopback, RFC1918, IPv6, redirects, DNS rebinding, header control
- Library behavior (httpx/requests): redirect policy, header forwarding, protocol support
- Protocol smuggling: `file://`, `ftp://`, gopher-like shims if custom clients
**File Upload**
- Path traversal in `UploadFile.filename` with control characters
- Missing storage root enforcement, symlink following
- Vary filename encodings, dot segments, NUL-like bytes
- Verify storage paths and served URLs
### WebSocket Security
- Missing per-connection authentication
- Cross-origin WebSocket without origin validation
- Topic/channel IDOR (subscribing to other users' channels)
- Authorization only at handshake, not per-message
### Mounted Apps
Sub-apps at `/admin`, `/static`, `/metrics` may bypass global middlewares. Verify auth enforcement parity across all mounts.
### Alternative Stacks
- If GraphQL (Strawberry/Graphene) is mounted: validate resolver-level authorization, IDOR on node/global IDs
- If SQLModel/SQLAlchemy present: probe for raw query usage and row-level authorization gaps
## Bypass Techniques
- Content-type switching to traverse alternate validators
- Parameter duplication and case variants exploiting DI precedence
- Method confusion via proxies (`X-HTTP-Method-Override`)
- Race windows around dependency-validated state transitions (issue token then mutate with parallel requests)
## Testing Methodology
1. **Enumerate** - Fetch OpenAPI, diff with 404-fuzzing for hidden endpoints
2. **Matrix testing** - Test each route across: unauth/user/admin × HTTP/WebSocket × JSON/form/multipart
3. **Dependency analysis** - Map which dependencies enforce auth vs parse input
4. **Cross-environment** - Compare dev/stage/prod for middleware and docs exposure differences
5. **Channel consistency** - Verify same authorization on HTTP and WebSocket for equivalent operations
## Validation Requirements
- Side-by-side requests showing unauthorized access (owner vs non-owner, cross-tenant)
- Cross-channel proof (HTTP and WebSocket for same rule)
- Header/proxy manipulation showing altered outcomes (Host/XFF/CORS)
- Minimal payloads for template injection, SSRF, token misuse with safe/OAST oracles
- Document exact dependency paths (router-level, route-level) that missed enforcement
================================================
FILE: strix/skills/frameworks/nestjs.md
================================================
---
name: nestjs
description: Security testing playbook for NestJS applications covering guards, pipes, decorators, module boundaries, and multi-transport auth
---
# NestJS
Security testing for NestJS applications. Focus on guard gaps across decorator stacks, validation pipe bypasses, module boundary leaks, and inconsistent auth enforcement across HTTP, WebSocket, and microservice transports.
## Attack Surface
**Decorator Pipeline**
- Guards: `@UseGuards`, `CanActivate`, execution context (HTTP/WS/RPC), `Reflector` metadata
- Pipes: `ValidationPipe` (whitelist, transform, forbidNonWhitelisted), `ParseIntPipe`, custom pipes
- Interceptors: response mapping, caching, logging, timeout — can modify request/response flow
- Filters: exception filters that may leak information
- Metadata: `@SetMetadata`, `@Public()`, `@Roles()`, `@Permissions()`
**Module System**
- `@Module` boundaries, provider scoping (DEFAULT/REQUEST/TRANSIENT)
- Dynamic modules: `forRoot`/`forRootAsync`, global modules
- DI container: provider overrides, custom providers
**Controllers & Transports**
- REST: `@Controller`, versioning (URI/Header/MediaType)
- GraphQL: `@Resolver`, playground/sandbox exposure
- WebSocket: `@WebSocketGateway`, gateway guards, room authorization
- Microservices: TCP, Redis, NATS, MQTT, gRPC, Kafka — often lack HTTP-level auth
**Data Layer**
- TypeORM: repositories, QueryBuilder, raw queries, relations
- Prisma: `$queryRaw`, `$queryRawUnsafe`
- Mongoose: operator injection, `$where`, `$regex`
**Auth & Config**
- `@nestjs/passport` strategies, `@nestjs/jwt`, session-based auth
- `@nestjs/config`, ConfigService, `.env` files
- `@nestjs/throttler`, rate limiting with `@SkipThrottle`
**API Documentation**
- `@nestjs/swagger`: OpenAPI exposure, DTO schemas, auth schemes
## High-Value Targets
- Swagger/OpenAPI endpoints in production (`/api`, `/api-docs`, `/api-json`, `/swagger`)
- Auth endpoints: login, register, token refresh, password reset, OAuth callbacks
- Admin controllers decorated with `@Roles('admin')` — test with user-level tokens
- File upload endpoints using `FileInterceptor`/`FilesInterceptor`
- WebSocket gateways sharing business logic with HTTP controllers
- Microservice handlers (`@MessagePattern`, `@EventPattern`) — often unguarded
- CRUD generators (`@nestjsx/crud`) with auto-generated endpoints
- Background jobs and scheduled tasks (`@nestjs/schedule`)
- Health/metrics endpoints (`@nestjs/terminus`, `/health`, `/metrics`)
- GraphQL playground/sandbox in production (`/graphql`)
## Reconnaissance
**Swagger Discovery**
```
GET /api
GET /api-docs
GET /api-json
GET /swagger
GET /docs
GET /v1/api-docs
GET /api/v2/docs
```
Extract: paths, parameter schemas, DTOs, auth schemes, example values. Swagger may reveal internal endpoints, deprecated routes, and admin-only paths not visible in the UI.
**Guard Mapping**
For each controller and method, identify:
- Global guards (applied in `main.ts` or app module)
- Controller-level guards (`@UseGuards` on the class)
- Method-level guards (`@UseGuards` on individual handlers)
- `@Public()` or `@SkipThrottle()` decorators that bypass protection
## Key Vulnerabilities
### Guard Bypass
**Decorator Stack Gaps**
- Guards execute: global → controller → method. A method missing `@UseGuards` when siblings have it is the #1 finding.
- `@Public()` metadata causing global `AuthGuard` to skip enforcement — check if applied too broadly.
- New methods added to existing controllers without inheriting the expected guard.
**ExecutionContext Switching**
- Guards handling only HTTP context (`getRequest()`) may fail silently on WebSocket or RPC, returning `true` by default.
- Test same business logic through alternate transports to find context-specific bypasses.
**Reflector Mismatches**
- Guard reads `SetMetadata('roles', [...])` but decorator sets `'role'` (singular) — guard sees no metadata, defaults to allow.
- `applyDecorators()` compositions accidentally overriding stricter guards with permissive ones.
### Validation Pipe Exploits
**Whitelist Bypass**
- `whitelist: true` without `forbidNonWhitelisted: true`: extra properties silently stripped but may have been processed by earlier middleware/interceptors.
- Missing `@Type(() => ChildDto)` on nested objects: `@ValidateNested()` without `@Type` means nested payload is never validated.
- Array elements: `@IsArray()` doesn't validate elements without `@ValidateNested({ each: true })` and `@Type`.
**Type Coercion**
- `transform: true` enables implicit coercion: strings → numbers, `"true"` → `true`, `"null"` → `null`.
- Exploit truthiness assumptions in business logic downstream.
**Conditional Validation**
- `@ValidateIf()` and validation groups creating paths where fields skip validation entirely.
**Missing Parse Pipes**
- `@Param('id')` without `ParseIntPipe`/`ParseUUIDPipe` — string values reach ORM queries directly.
### Auth & Passport
**JWT Strategy**
- Check `ignoreExpiration` is false, `algorithms` is pinned (no `none` or HS/RS confusion)
- Weak `secretOrKey` values
- Cross-service token reuse when audience/issuer not enforced
**Passport Strategy Issues**
- `validate()` return value becomes `req.user` — if it returns full DB record, sensitive fields leak downstream
- Multiple strategies (JWT + session): one may bypass restrictions of the other
- Custom guards returning `true` for unauthenticated as "optional auth"
**Timing Attacks**
- Plain string comparison instead of bcrypt/argon2 in local strategy
### Serialization Leaks
**Missing ClassSerializerInterceptor**
- If not applied globally, `@Exclude()` fields (passwords, internal IDs) returned in responses.
- `@Expose()` with groups: admin-only fields exposed when groups not enforced per-request.
**Circular Relations**
- Eager-loaded TypeORM/Prisma relations exposing entire object graph without careful serialization.
### Interceptor Abuse
**Cache Poisoning**
- `CacheInterceptor` without user/tenant identity in cache key — responses from one user served to another.
- Test: authenticated request, then unauthenticated request returning cached data.
**Response Mapping**
- Transformation interceptors may leak internal entity fields if mapping is incomplete.
### Module Boundary Leaks
**Global Module Exposure**
- `@Global()` modules expose all providers to every module without explicit imports.
- Sensitive services (admin operations, internal APIs) accessible from untrusted modules.
**Config Leaks**
- `forRoot`/`forRootAsync` configuration secrets accessible via `ConfigService` injection in any module.
**Scope Issues**
- Request-scoped providers (`Scope.REQUEST`) incorrectly scoped as DEFAULT (singleton) — request context leaks across concurrent requests.
### WebSocket Gateway
- HTTP guards don't automatically apply to WebSocket gateways — `@UseGuards` must be explicit.
- Authentication deferred from `handleConnection` to message handlers allows unauthenticated message sending.
- Room/namespace authorization: users joining rooms they shouldn't access.
- `@SubscribeMessage()` handlers relying on connection-level auth instead of per-message validation.
### Microservice Transport
- `@MessagePattern`/`@EventPattern` handlers often lack guards (considered "internal").
- If transport (Redis, NATS, Kafka) is network-accessible, messages can be injected bypassing all HTTP security.
- `ValidationPipe` may only be configured for HTTP — microservice payloads skip validation.
### ORM Injection
**TypeORM**
- `QueryBuilder` and `.query()` with template literal interpolation → SQL injection.
- Relations: API allowing specification of which relations to load via query params.
**Mongoose**
- Query operator injection: `{ password: { $gt: "" } }` via unsanitized request body.
- `$where` and `$regex` operators from user input.
**Prisma**
- `$queryRaw`/`$executeRaw` with string interpolation (but not tagged template).
- `$queryRawUnsafe` usage.
### Rate Limiting
- `@SkipThrottle()` on sensitive endpoints (login, password reset, OTP).
- In-memory throttler storage: resets on restart, doesn't work across instances.
- Behind proxy without `trust proxy`: all requests share same IP, or header spoofable.
### CRUD Generators
- Auto-generated CRUD endpoints may not inherit manual guard configurations.
- Bulk operations (`createMany`, `updateMany`) bypassing per-entity authorization.
- Query parameter injection in CRUD libraries: `filter`, `sort`, `join`, `select` exposing unauthorized data.
## Bypass Techniques
- `@Public()` / skip-metadata applied via composed decorators at method level causing global guards to skip via `Reflector` metadata checks
- Route param pollution: `/users/123?id=456` — which `id` wins in guards vs handlers?
- Version routing: v1 of endpoint may still be registered without the guard added to v2
- `X-HTTP-Method-Override` or `_method` processed by Express before guards
- Content-type switching: `application/x-www-form-urlencoded` instead of JSON to bypass JSON-specific validation
- Exception filter differences: guard throwing results in generic error that leaks route existence info
## Testing Methodology
1. **Enumerate** — Fetch Swagger/OpenAPI, map all controllers, resolvers, and gateways
2. **Guard audit** — Map decorator stack per method: which guards, pipes, interceptors are applied at each level
3. **Matrix testing** — Test each endpoint across: unauth/user/admin × HTTP/WS/microservice
4. **Validation probing** — Send extra fields, wrong types, nested objects, arrays to find pipe gaps
5. **Transport parity** — Same operation via HTTP, WebSocket, and microservice transport
6. **Module boundaries** — Check if providers from one module are accessible without proper imports
7. **Serialization check** — Compare raw entity fields with API response fields
## Validation Requirements
- Guard bypass: request to guarded endpoint succeeding without auth, showing guard chain break point
- Validation bypass: payload with extra/malformed fields affecting business logic
- Cross-transport inconsistency: same action authorized via HTTP but exploitable via WebSocket/microservice
- Module boundary leak: accessing provider or data across unauthorized module boundaries
- Serialization leak: response containing excluded fields (passwords, internal metadata)
- IDOR: side-by-side requests from different users showing unauthorized data access
- ORM injection: raw query with user-controlled input returning unauthorized data, or error-based evidence of query structure
- Cache poisoning: response from unauthenticated or different-user request matching a prior authenticated user's cached response
================================================
FILE: strix/skills/frameworks/nextjs.md
================================================
---
name: nextjs
description: Security testing playbook for Next.js covering App Router, Server Actions, RSC, and Edge runtime vulnerabilities
---
# Next.js
Security testing for Next.js applications. Focus on authorization drift across runtimes (Edge/Node), caching boundaries, server actions, and middleware bypass.
## Attack Surface
**Routers**
- App Router (`app/`) and Pages Router (`pages/`) often coexist
- Route Handlers (`app/api/**`) and API routes (`pages/api/**`)
- Middleware: `middleware.ts` at project root
**Runtimes**
- Node.js (full API access)
- Edge (V8 isolates, restricted APIs)
**Rendering & Caching**
- SSR, SSG, ISR, on-demand revalidation
- RSC (React Server Components) with fetch cache
- Draft/preview mode
**Data Paths**
- Server Components, Client Components
- Server Actions (streamed POST with `Next-Action` header)
- `getServerSideProps`, `getStaticProps`
**Integrations**
- NextAuth.js (callbacks, CSRF, callbackUrl)
- `next/image` optimization and remote loaders
## High-Value Targets
- Middleware-protected routes (auth, geo, A/B)
- Admin/staff paths, draft/preview content, on-demand revalidate endpoints
- RSC payloads and flight data, streamed responses
- Image optimizer and custom loaders, remotePatterns/domains
- NextAuth callbacks (`/api/auth/callback/*`), sign-in providers
- Edge-only features (bot protection, IP gates) and their Node equivalents
## Reconnaissance
**Route Discovery**
```javascript
// Browser console - list all routes
console.log(__BUILD_MANIFEST.sortedPages.join('\n'))
// Inspect server-fetched data
JSON.parse(document.getElementById('__NEXT_DATA__').textContent).props.pageProps
// List public environment variables
Object.keys(process.env).filter(k => k.startsWith('NEXT_PUBLIC_'))
```
**Build Artifacts**
```
GET /_next/static//_buildManifest.js
GET /_next/static//_ssgManifest.js
GET /_next/static/chunks/pages/
GET /_next/static/chunks/app/
```
Chunk filenames map to routes (e.g., `admin.js` → `/admin`).
**Source Maps**
Check `/_next/static/` for exposed `.map` files revealing route structure, server action IDs, and internal functions.
**Client Bundle Mining**
Search main-*.js for: `pathname:`, `href:`, `__next_route__`, `serverActions`, API endpoints. Grep for `API_KEY`, `SECRET`, `TOKEN`, `PASSWORD` to find accidentally leaked credentials.
**Server Action Discovery**
Inspect Network tab for POST requests with `Next-Action` header. Extract action IDs from response streams and hydration data.
**Additional Leakage**
- `/sitemap.xml`, `/robots.txt`, `/sitemap-*.xml` for unintended admin/internal/preview paths
- Client bundles/env for secret paths and preview/admin flags (many teams hide routes via UI only)
## Key Vulnerabilities
### Middleware Bypass
**Known Techniques**
- `x-middleware-subrequest` header crafting (CVE-class bypass)
- `x-nextjs-data` probing
- Look for 307 + `x-middleware-rewrite`/`x-nextjs-redirect` headers
**Path Normalization**
```
/api/users
/api/users/
/api//users
/api/./users
```
Middleware may normalize differently than route handlers. Test double slashes, trailing slashes, dot segments.
**Parameter Pollution**
```
?id=1&id=2
?filter[]=a&filter[]=b
```
Middleware checks first value, handler uses last or array.
### Server Actions
- Invoke actions outside UI flow with alternate content-types
- Authorization assumed from client state rather than enforced server-side
- IDOR via object references in action payloads
- Map action IDs from source maps to discover hidden actions
### RSC & Caching
**Cache Boundary Failures**
- User-bound data cached without identity keys (ETag/Set-Cookie unaware)
- Personalized content served from shared cache/CDN
- Missing `no-store` on sensitive fetches
**Flight Data Leakage**
Inspect streamed RSC payloads for serialized sensitive fields in props.
**ISR Issues**
- Stale-while-revalidate responses containing user-specific or tenant-dependent data
- Weak secrets in on-demand revalidation endpoint URLs
- Referer-disclosed tokens or unvalidated hosts triggering `revalidatePath`/`revalidateTag`
- Header-smuggling or method variations to trigger revalidation
### Authentication
**NextAuth Pitfalls**
- Missing/relaxed state/nonce/PKCE per provider (login CSRF, token mix-up)
- Open redirect in `callbackUrl` or mis-scoped allowed hosts
- JWT audience/issuer not enforced across routes
- Cross-service token reuse
- Session hijacking by forcing callbacks
**Session Boundaries**
- Different auth enforcement between App Router and Pages Router
- API routes vs Route Handlers authorization inconsistency
### Data Exposure
**__NEXT_DATA__ Over-fetching**
Server-fetched data passed to client but not rendered:
- Full user objects when only username needed
- Internal IDs, tokens, admin-only fields
- ORM select-all patterns exposing entire records
- API responses forwarded without sanitization (metadata, cursors, debug info)
**Environment-Dependent Exposure**
- Staging/dev accidentally exposes more fields than production
- Inconsistent serialization logic across environments
**Props Inspection**
```javascript
// Check for sensitive data in page props
JSON.parse(document.getElementById('__NEXT_DATA__').textContent).props
```
Look for `_metadata`, `_internal`, `__typename` (GraphQL), nested sensitive objects.
### Image Optimizer SSRF
**Remote Patterns**
- Broad `images.domains`/`remotePatterns` in `next.config.js`
- Test: internal hosts, IPv4/IPv6 variants, DNS rebinding
**Custom Loaders**
- Protocol smuggling via redirect chains
- Cache poisoning via URL normalization differences affecting other users
### Runtime Divergence
**Edge vs Node**
- Defenses relying on Node-only modules skipped on Edge
- Header trust differs (`x-forwarded-*` handling)
- Same route may behave differently across runtimes
### Client-Side
**XSS Vectors**
- `dangerouslySetInnerHTML`
- Markdown renderers
- User-controlled href/src attributes
- Validate CSP/Trusted Types coverage for SSR/CSR/hydration
**Hydration Mismatches**
Server vs client render differences can enable gadget-based XSS.
### Draft/Preview Mode
- Secret URLs/cookies enabling preview
- Preview secrets leaked in client bundles/env
- Setting preview cookies from subdomains or via open redirects
## Bypass Techniques
- Content-type switching: `application/json` ↔ `multipart/form-data` ↔ `application/x-www-form-urlencoded`
- Method override: `_method`, `X-HTTP-Method-Override`, GET on endpoints accepting writes
- Case/param aliasing and query duplication affecting middleware vs handler parsing
- Cache key confusion at CDN/proxy (lack of Vary on auth cookies/headers)
## Testing Methodology
1. **Enumerate** - Use `__BUILD_MANIFEST`, source maps, build artifacts, sitemap/robots to map all routes
2. **Runtime matrix** - Test each route under Edge and Node runtimes
3. **Role matrix** - Test as unauth/user/admin across SSR, API routes, Route Handlers, Server Actions
4. **Cache probing** - Verify caching respects identity (strip cookies, alter Vary headers, check ETags)
5. **Middleware validation** - Test path variants and header manipulation for bypass
6. **Cross-router** - Compare authorization between App Router and Pages Router paths
## Validation Requirements
- Side-by-side requests showing cross-user/tenant access
- Cache boundary failure proof (response diffs, ETag collisions)
- Server action invocation outside UI with insufficient auth
- Middleware bypass with explicit headers showing protected content access
- Runtime parity checks (Edge vs Node inconsistent enforcement)
- Discovered routes verified as deployed (200/403) not just build artifacts (404)
- Leaked credentials tested with minimal read-only calls; filter placeholders
- `__NEXT_DATA__` exposure: verify cross-user (User A's props shouldn't contain User B's PII), confirm exposed fields not in DOM
- Path normalization bypasses: show differential responses (403 vs 200), redirects don't count
================================================
FILE: strix/skills/protocols/graphql.md
================================================
---
name: graphql
description: GraphQL security testing covering introspection, resolver injection, batching attacks, and authorization bypass
---
# GraphQL
Security testing for GraphQL APIs. Focus on resolver-level authorization, field/edge access control, batching abuse, and federation trust boundaries.
## Attack Surface
**Operations**
- Queries, mutations, subscriptions
- Persisted queries / Automatic Persisted Queries (APQ)
**Transports**
- HTTP POST/GET with `application/json` or `application/graphql`
- WebSocket: graphql-ws, graphql-transport-ws protocols
- Multipart for file uploads
**Schema Features**
- Introspection (`__schema`, `__type`)
- Directives: `@defer`, `@stream`, custom auth directives (@auth, @private)
- Custom scalars: Upload, JSON, DateTime
- Relay: global node IDs, connections/cursors, interfaces/unions
**Architecture**
- Federation (Apollo, GraphQL Mesh): `_service`, `_entities`
- Gateway vs subgraph authorization boundaries
## Reconnaissance
**Endpoint Discovery**
```
POST /graphql {"query":"{__typename}"}
POST /api/graphql {"query":"{__typename}"}
POST /v1/graphql {"query":"{__typename}"}
POST /gql {"query":"{__typename}"}
GET /graphql?query={__typename}
```
Check for GraphiQL/Playground exposure with credentials enabled (cross-origin with cookies can leak data via postMessage bridges).
**Schema Acquisition**
If introspection enabled:
```graphql
{__schema{types{name fields{name args{name}}}}}
```
If disabled, infer schema via:
- `__typename` probes on candidate fields
- Field suggestion errors (submit near-miss names to harvest suggestions)
- "Expected one of" errors revealing enum values
- Type coercion errors exposing field structure
- Error taxonomy: different codes for "unknown field" vs "unauthorized field" reveal existence
**Schema Mapping**
Map: root operations, object types, interfaces/unions, directives, custom scalars. Identify sensitive fields: email, tokens, roles, billing, API keys, admin flags, file URLs. Note cascade paths where child resolvers may skip auth under parent assumptions.
## Key Vulnerabilities
### Authorization Bypass
**Field-Level IDOR**
Test with aliases comparing owned vs foreign objects in single request:
```graphql
query {
own: order(id:"OWNED_ID") { id total owner { email } }
foreign: order(id:"FOREIGN_ID") { id total owner { email } }
}
```
**Edge/Child Resolver Gaps**
Parent resolver checks auth, child resolver assumes it's already validated:
```graphql
query {
user(id:"FOREIGN") {
id
privateData { secrets } # Child may skip auth check
}
}
```
**Relay Node Resolution**
Decode base64 global IDs, swap type/id pairs:
```graphql
query {
node(id:"VXNlcjoxMjM=") { ... on User { email } }
}
```
Ensure per-type authorization is enforced inside resolvers. Verify connection filters (owner/tenant) apply before pagination; cursor tampering should not cross ownership boundaries.
**Mutation Bypass**
- Probe mutations for partial updates bypassing validation (JSON Merge Patch semantics)
- Test mutations that accept extra fields passed to downstream logic
### Batching & Alias Abuse
**Enumeration via Aliases**
```graphql
query {
u1:user(id:"1"){email}
u2:user(id:"2"){email}
u3:user(id:"3"){email}
}
```
Bypasses per-request rate limits; exposes per-field vs per-request auth inconsistencies.
**Array Batching**
If supported (non-standard), submit multiple operations to achieve partial failures and bypass limits.
### Input Manipulation
**Type Confusion**
```
{id: 123} vs {id: "123"}
{id: [123]} vs {id: null}
{id: 0} vs {id: -1}
```
**Duplicate Keys**
```json
{"id": 1, "id": 2}
```
Parser precedence varies; may bypass validation. Also test default argument values.
**Extra Fields**
Send unexpected keys in input objects; backends may pass them to resolvers or downstream logic.
### Cursor Manipulation
Decode cursors (usually base64) to:
- Manipulate offsets/IDs
- Skip filters
- Cross ownership boundaries
### Directive Abuse
**@defer/@stream**
```graphql
query {
me { id }
... @defer { adminPanel { secrets } }
}
```
May return gated data in incremental delivery. Confirm server supports incremental delivery.
**Custom Directives**
@auth, @private and similar directives often annotate intent but do not enforce—verify actual checks in each resolver path.
### Complexity Attacks
**Fragment Bombs**
```graphql
fragment x on User { friends { ...x } }
query { me { ...x } }
```
Test depth/complexity limits, query cost analyzers, timeouts.
**Wide Selection Sets**
Abuse selection sets and fragments to force overfetching of sensitive subfields.
### Federation Exploitation
**SDL Exposure**
```graphql
query { _service { sdl } }
```
**Entity Materialization**
```graphql
query {
_entities(representations:[
{__typename:"User", id:"TARGET_ID"}
]) { ... on User { email roles } }
}
```
Gateway may enforce auth; subgraph resolvers may not. Look for cross-subgraph IDOR via inconsistent ownership checks.
### Subscription Security
- Authorization at handshake only, not per-message
- Subscribe to other users' channels via filter args
- Cross-tenant event leakage
- Abuse filter args in subscription resolvers to reference foreign IDs
### Persisted Query Abuse
- APQ hashes leaked from client bundles
- Replay privileged operations with attacker variables
- Hash bruteforce for common operations
- Validate hash→operation mapping enforces principal and operation allowlists
### CORS & CSRF
- Cookie-auth with GET queries enables CSRF on mutations via query parameters
- GraphiQL/Playground cross-origin with credentials leaks data
- Missing SameSite and origin validation
### File Uploads
GraphQL multipart spec:
- Multiple Upload scalars
- Filename/path traversal tricks
- Unexpected content-types, oversize chunks
- Server-side ownership/scoping for returned URLs
## WAF Evasion
**Query Reshaping**
- Comments and block strings (`"""..."""`)
- Unicode escapes
- Alias/fragment indirection
- JSON variables vs inline args
- GET vs POST vs `application/graphql`
**Fragment Splitting**
Split fields across fragments and inline spreads to avoid naive signatures:
```graphql
fragment a on User { email }
fragment b on User { password }
query { me { ...a ...b } }
```
## Bypass Techniques
**Transport Switching**
```
Content-Type: application/json
Content-Type: application/graphql
Content-Type: multipart/form-data
GET with query params
```
**Timing & Rate Limits**
- HTTP/2 multiplexing and connection reuse to widen timing windows
- Batching to bypass rate limits
**Naming Tricks**
- Case/underscore variations
- Unicode homoglyphs (server-dependent)
- Aliases masking sensitive field names
**Cache Confusion**
- CDN caching without Vary on Authorization
- Variable manipulation affecting cache keys
- Redirects and 304/206 behaviors leaking partial responses
## Testing Methodology
1. **Fingerprint** - Identify endpoints, transports, stack (Apollo, Hasura, etc.), GraphiQL exposure
2. **Schema mapping** - Introspection or inference to build complete type graph
3. **Principal matrix** - Collect tokens for unauth, user, premium, admin roles with at least one valid object ID per subject
4. **Field sweep** - Test each resolver with owned vs foreign IDs via aliases in same request
5. **Transport parity** - Verify same auth on HTTP, WebSocket, persisted queries
6. **Federation probe** - Test `_service` and `_entities` for subgraph auth gaps
7. **Edge cases** - Cursors, @defer/@stream, subscriptions, file uploads
## Validation Requirements
- Paired requests (owner vs non-owner) showing unauthorized access
- Resolver-level bypass: parent checks present, child field exposes data
- Transport parity proof: HTTP and WebSocket for same operation
- Federation bypass: `_entities` accessing data without subgraph auth
- Minimal payloads with exact selection sets and variable shapes
- Document exact resolver paths that missed enforcement
================================================
FILE: strix/skills/reconnaissance/.gitkeep
================================================
================================================
FILE: strix/skills/scan_modes/deep.md
================================================
---
name: deep
description: Exhaustive security assessment with maximum coverage, depth, and vulnerability chaining
---
# Deep Testing Mode
Exhaustive security assessment. Maximum coverage, maximum depth. Finding what others miss is the goal.
## Approach
Thorough understanding before exploitation. Test every parameter, every endpoint, every edge case. Chain findings for maximum impact.
## Phase 1: Exhaustive Reconnaissance
**Whitebox (source available)**
- Map every file, module, and code path in the repository
- Trace all entry points from HTTP handlers to database queries
- Document all authentication mechanisms and implementations
- Map authorization checks and access control model
- Identify all external service integrations and API calls
- Analyze configuration for secrets and misconfigurations
- Review database schemas and data relationships
- Map background jobs, cron tasks, async processing
- Identify all serialization/deserialization points
- Review file handling: upload, download, processing
- Understand the deployment model and infrastructure assumptions
- Check all dependency versions against CVE databases
**Blackbox (no source)**
- Exhaustive subdomain enumeration with multiple sources and tools
- Full port scanning across all services
- Complete content discovery with multiple wordlists
- Technology fingerprinting on all assets
- API discovery via docs, JavaScript analysis, fuzzing
- Identify all parameters including hidden and rarely-used ones
- Map all user roles with different account types
- Document rate limiting, WAF rules, security controls
- Document complete application architecture as understood from outside
## Phase 2: Business Logic Deep Dive
Create a complete storyboard of the application:
- **User flows** - document every step of every workflow
- **State machines** - map all transitions (Created → Paid → Shipped → Delivered)
- **Trust boundaries** - identify where privilege changes hands
- **Invariants** - what rules should the application always enforce
- **Implicit assumptions** - what does the code assume that might be violated
- **Multi-step attack surfaces** - where can normal functionality be abused
- **Third-party integrations** - map all external service dependencies
Use the application extensively as every user type to understand the full data lifecycle.
## Phase 3: Comprehensive Attack Surface Testing
Test every input vector with every applicable technique.
**Input Handling**
- Multiple injection types: SQL, NoSQL, LDAP, XPath, command, template
- Encoding bypasses: double encoding, unicode, null bytes
- Boundary conditions and type confusion
- Large payloads and buffer-related issues
**Authentication & Session**
- Exhaustive brute force protection testing
- Session fixation, hijacking, prediction
- JWT/token manipulation
- OAuth flow abuse scenarios
- Password reset vulnerabilities: token leakage, reuse, timing
- MFA bypass techniques
- Account enumeration through all channels
**Access Control**
- Test every endpoint for horizontal and vertical access control
- Parameter tampering on all object references
- Forced browsing to all discovered resources
- HTTP method tampering (GET vs POST vs PUT vs DELETE)
- Access control after session state changes (logout, role change)
**File Operations**
- Exhaustive file upload bypass: extension, content-type, magic bytes
- Path traversal on all file parameters
- SSRF through file inclusion
- XXE through all XML parsing points
**Business Logic**
- Race conditions on all state-changing operations
- Workflow bypass on every multi-step process
- Price/quantity manipulation in transactions
- Parallel execution attacks
- TOCTOU (time-of-check to time-of-use) vulnerabilities
**Advanced Techniques**
- HTTP request smuggling (multiple proxies/servers)
- Cache poisoning and cache deception
- Subdomain takeover
- Prototype pollution (JavaScript applications)
- CORS misconfiguration exploitation
- WebSocket security testing
- GraphQL-specific attacks (introspection, batching, nested queries)
## Phase 4: Vulnerability Chaining
Individual bugs are starting points. Chain them for maximum impact:
- Combine information disclosure with access control bypass
- Chain SSRF to reach internal services
- Use low-severity findings to enable high-impact attacks
- Build multi-step attack paths that automated tools miss
- Cross component boundaries: user → admin, external → internal, read → write, single-tenant → cross-tenant
**Chaining Principles**
- Treat every finding as a pivot point: ask "what does this unlock next?"
- Continue until reaching maximum privilege / maximum data exposure / maximum control
- Prefer end-to-end exploit paths over isolated bugs: initial foothold → pivot → privilege gain → sensitive action/data
- Validate chains by executing the full sequence (proxy + browser for workflows, python for automation)
- When a pivot is found, spawn focused agents to continue the chain in the next component
## Phase 5: Persistent Testing
When initial attempts fail:
- Research technology-specific bypasses
- Try alternative exploitation techniques
- Test edge cases and unusual functionality
- Test with different client contexts
- Revisit areas with new information from other findings
- Consider timing-based and blind exploitation
- Look for logic flaws that require deep application understanding
## Phase 6: Comprehensive Reporting
- Document every confirmed vulnerability with full details
- Include all severity levels—low findings may enable chains
- Complete reproduction steps and working PoC
- Remediation recommendations with specific guidance
- Note areas requiring additional review beyond current scope
## Agent Strategy
After reconnaissance, decompose the application hierarchically:
1. **Component level** - Auth System, Payment Gateway, User Profile, Admin Panel
2. **Feature level** - Login Form, Registration API, Password Reset
3. **Vulnerability level** - SQLi Agent, XSS Agent, Auth Bypass Agent
Spawn specialized agents at each level. Scale horizontally to maximum parallelization:
- Do NOT overload a single agent with multiple vulnerability types
- Each agent focuses on one specific area or vulnerability type
- Creates a massive parallel swarm covering every angle
## Mindset
Relentless. Creative. Patient. Thorough. Persistent.
This is about finding what others miss. Test every parameter, every endpoint, every edge case. If one approach fails, try ten more. Understand how components interact to find systemic issues.
================================================
FILE: strix/skills/scan_modes/quick.md
================================================
---
name: quick
description: Time-boxed rapid assessment targeting high-impact vulnerabilities
---
# Quick Testing Mode
Time-boxed assessment focused on high-impact vulnerabilities. Prioritize breadth over depth.
## Approach
Optimize for fast feedback on critical security issues. Skip exhaustive enumeration in favor of targeted testing on high-value attack surfaces.
## Phase 1: Rapid Orientation
**Whitebox (source available)**
- Focus on recent changes: git diffs, new commits, modified files—these are most likely to contain fresh bugs
- Identify security-sensitive patterns in changed code: auth checks, input handling, database queries, file operations
- Trace user input through modified code paths
- Check if security controls were modified or bypassed
**Blackbox (no source)**
- Map authentication and critical user flows
- Identify exposed endpoints and entry points
- Skip deep content discovery—test what's immediately accessible
## Phase 2: High-Impact Targets
Test in priority order:
1. **Authentication bypass** - login flaws, session issues, token weaknesses
2. **Broken access control** - IDOR, privilege escalation, missing authorization
3. **Remote code execution** - command injection, deserialization, SSTI
4. **SQL injection** - authentication endpoints, search, filters
5. **SSRF** - URL parameters, webhooks, integrations
6. **Exposed secrets** - hardcoded credentials, API keys, config files
Skip for quick scans:
- Exhaustive subdomain enumeration
- Full directory bruteforcing
- Low-severity information disclosure
- Theoretical issues without working PoC
## Phase 3: Validation
- Confirm exploitability with minimal proof-of-concept
- Demonstrate real impact, not theoretical risk
- Report findings immediately as discovered
## Chaining
When a strong primitive is found (auth weakness, injection point, internal access), immediately attempt one high-impact pivot to demonstrate maximum severity. Don't stop at a low-context "maybe"—turn it into a concrete exploit sequence that reaches privileged action or sensitive data.
## Operational Guidelines
- Use browser tool for quick manual testing of critical flows
- Use terminal for targeted scans with fast presets (e.g., nuclei with critical/high templates only)
- Use proxy to inspect traffic on key endpoints
- Skip extensive fuzzing—use targeted payloads only
- Create subagents only for parallel high-priority tasks
## Mindset
Think like a time-boxed bug bounty hunter going for quick wins. Prioritize breadth over depth on critical areas. If something looks exploitable, validate quickly and move on. Don't get stuck—if an attack vector isn't yielding results quickly, pivot.
================================================
FILE: strix/skills/scan_modes/standard.md
================================================
---
name: standard
description: Balanced security assessment with systematic methodology and full attack surface coverage
---
# Standard Testing Mode
Balanced security assessment with structured methodology. Thorough coverage without exhaustive depth.
## Approach
Systematic testing across the full attack surface. Understand the application before exploiting it.
## Phase 1: Reconnaissance
**Whitebox (source available)**
- Map codebase structure: modules, entry points, routing
- Identify architecture pattern (MVC, microservices, monolith)
- Trace input vectors: forms, APIs, file uploads, headers, cookies
- Review authentication and authorization flows
- Analyze database interactions and ORM usage
- Check dependencies for known CVEs
- Understand the data model and sensitive data locations
**Blackbox (no source)**
- Crawl application thoroughly, interact with every feature
- Enumerate endpoints, parameters, and functionality
- Fingerprint technology stack
- Map user roles and access levels
- Capture traffic with proxy to understand request/response patterns
## Phase 2: Business Logic Analysis
Before testing for vulnerabilities, understand the application:
- **Critical flows** - payments, registration, data access, admin functions
- **Role boundaries** - what actions are restricted to which users
- **Data access rules** - what data should be isolated between users
- **State transitions** - order lifecycle, account status changes
- **Trust boundaries** - where does privilege or sensitive data flow
## Phase 3: Systematic Testing
Test each attack surface methodically. Spawn focused subagents for different areas.
**Input Validation**
- Injection testing on all input fields (SQL, XSS, command, template)
- File upload bypass attempts
- Search and filter parameter manipulation
- Redirect and URL parameter handling
**Authentication & Session**
- Brute force protection
- Session token entropy and handling
- Password reset flow analysis
- Logout session invalidation
- Authentication bypass techniques
**Access Control**
- Horizontal: user A accessing user B's resources
- Vertical: unprivileged user accessing admin functions
- API endpoints vs UI access control consistency
- Direct object reference manipulation
**Business Logic**
- Multi-step process bypass (skip steps, reorder)
- Race conditions on state-changing operations
- Boundary conditions: negative values, zero, extremes
- Transaction replay and manipulation
## Phase 4: Exploitation
- Every finding requires a working proof-of-concept
- Demonstrate actual impact, not theoretical risk
- Chain vulnerabilities to show maximum severity
- Document full attack path from entry to impact
- Use python tool for complex exploit development
## Phase 5: Reporting
- Document all confirmed vulnerabilities with reproduction steps
- Severity based on exploitability and business impact
- Remediation recommendations
- Note areas requiring further investigation
## Chaining
Always ask: "If I can do X, what does that enable next?" Keep pivoting until reaching maximum privilege or data exposure.
Prefer complete end-to-end paths (entry point → pivot → privileged action/data) over isolated findings. Use the application as a real user would—exploit must survive actual workflow and state transitions.
When you discover a useful pivot (info leak, weak boundary, partial access), immediately pursue the next step rather than stopping at the first win.
## Mindset
Methodical and systematic. Document as you go. Validate everything—no assumptions about exploitability. Think about business impact, not just technical severity.
================================================
FILE: strix/skills/technologies/firebase_firestore.md
================================================
---
name: firebase-firestore
description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues
---
# Firebase / Firestore
Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.
## Attack Surface
**Data Stores**
- Firestore (documents/collections, rules, REST/SDK)
- Realtime Database (JSON tree, rules)
- Cloud Storage (rules, signed URLs)
**Authentication**
- Auth ID tokens, custom claims, anonymous/sign-in providers
- App Check attestation (and its limits)
**Server-Side**
- Cloud Functions (onCall/onRequest, triggers)
- Admin SDK (bypasses rules)
**Infrastructure**
- Hosting rewrites, CDN/caching, CORS
## Architecture
**Endpoints**
- Firestore REST: `https://firestore.googleapis.com/v1/projects//databases/(default)/documents/`
- Realtime DB: `https://.firebaseio.com/.json`
- Storage REST: `https://storage.googleapis.com/storage/v1/b/`
**Auth**
- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/`)
- Audience: `` or ``, identity in `sub`/`uid`
- Rules engines: separate for Firestore, Realtime DB, and Storage
- Functions bypass rules when using Admin SDK
## High-Value Targets
- Firestore collections with sensitive data (users, orders, payments)
- Realtime Database root and high-level nodes
- Cloud Storage buckets with private files
- Cloud Functions (especially triggers that grant roles or issue signed URLs)
- Admin/staff routes and privilege-granting endpoints
- Export/report functions that generate signed outputs
## Reconnaissance
**Extract Project Config**
From client bundle:
```javascript
// apiKey, authDomain, projectId, appId, storageBucket, messagingSenderId
firebase.apps[0].options
```
**Obtain Principals**
- Unauthenticated
- Anonymous (if enabled)
- Basic user A, user B
- Staff/admin (if available)
Capture ID tokens for each.
## Key Vulnerabilities
### Firestore Rules
Rules are not filters—a query must include constraints that make the rule true for all returned documents.
**Common Gaps**
- `allow read: if request.auth != null` — any authenticated user reads all data
- `allow write: if request.auth != null` — mass write access
- Missing per-field validation (allows adding `isAdmin`/`role`/`tenantId` fields)
- Using client-supplied `ownerId`/`orgId` instead of `resource.data.ownerId == request.auth.uid`
- Over-broad list rules on root collections (per-doc checks exist but list still leaks)
**Secure Patterns**
```javascript
// Restrict write fields
request.resource.data.keys().hasOnly(['field1', 'field2', 'field3'])
// Enforce ownership
resource.data.ownerId == request.auth.uid &&
request.resource.data.ownerId == request.auth.uid
// Org membership check
exists(/databases/(default)/documents/orgs/$(org)/members/$(request.auth.uid))
```
**Tests**
- Compare results for users A/B on identical queries; diff counts and IDs
- Cross-tenant reads: `where orgId == otherOrg`; try queries without org filter
- Write-path: set/patch with foreign `ownerId`/`orgId`; attempt to flip privilege flags
### Firestore Queries
- Use REST to avoid SDK client-side constraints
- Probe composite index requirements (UI-driven queries may hide missing rule coverage)
- Explore `collectionGroup` queries that may bypass per-collection rules
- Use `startAt`/`endAt`/`in`/`array-contains` to probe rule edges and pagination cursors
### Realtime Database
- Misconfigured rules frequently expose entire JSON trees
- Probe `https://.firebaseio.com/.json` with and without auth
- Confirm rules use `auth.uid` and granular path checks
- Avoid `.read/.write: true` or `auth != null` at high-level nodes
- Attempt to write privilege-bearing nodes (roles, org membership)
### Cloud Storage
**Common Issues**
- Public reads on sensitive buckets/paths
- Signed URLs with long TTL, no content-disposition controls, replayable across tenants
- List operations exposed: `/o?prefix=` enumerates object keys
**Tests**
- GET gs:// paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
- Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants
- Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution
### Cloud Functions
`onCall` provides `context.auth` automatically; `onRequest` must verify ID tokens explicitly. Admin SDK bypasses rules—all ownership/tenant checks must be in code.
**Common Gaps**
- Trusting client `uid`/`orgId` from request body instead of `context.auth`
- Missing `aud`/`iss` verification when manually parsing tokens
- Over-broad CORS allowing credentialed cross-origin requests
- Triggers (onCreate/onWrite) granting roles based on document content controlled by client
**Tests**
- Call both onCall and onRequest endpoints with varied tokens; expect identical decisions
- Create crafted docs to trigger privilege-granting functions
- Attempt SSRF via Functions to project/metadata endpoints
### Auth & Token Issues
**Verification Requirements**
- Issuer, audience (project), signature (Google JWKS), expiration
- Optionally App Check binding when used
**Pitfalls**
- Accepting any JWT with valid signature but wrong audience/project
- Trusting `uid`/account IDs from request body instead of `context.auth.uid`
- Mixing session cookies and ID tokens without verifying both paths equivalently
- Custom claims copied into docs then trusted by app code
**Tests**
- Replay tokens across environments/projects; expect strict `aud`/`iss` rejection
- Call Functions with and without Authorization; verify identical checks
### App Check
App Check is not a substitute for authorization.
**Bypasses**
- REST calls directly to googleapis endpoints with ID token succeed regardless of App Check
- Mobile reverse engineering: hook client and reuse ID token flows without attestation
**Tests**
- Compare SDK vs REST behavior with/without App Check headers
- Confirm no elevated authorization via App Check alone
### Tenant Isolation
Apps often implement multi-tenant data models (`orgs//...`). Bind tenant from server context (membership doc or custom claim), not client payload.
**Tests**
- Vary org header/subdomain/query while keeping token fixed; verify server denies cross-tenant access
- Export/report Functions: ensure queries execute under caller scope
## Bypass Techniques
- Content-type switching: JSON vs form vs multipart to hit alternate code paths in onRequest
- Parameter/field pollution: duplicate JSON keys (last-one-wins in many parsers); sneak privilege fields
- Caching/CDN: Hosting rewrites keying responses without Authorization or tenant headers
- Race windows: write then read before background enforcements complete
## Blind Enumeration
- Firestore: use error shape, document count, ETag/length to infer existence
- Storage: length/timing differences on signed URL attempts leak validity
- Functions: constant-time comparisons vs variable messages reveal authorization branches
## Testing Methodology
1. **Extract config** - Get project config from client bundle
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin
3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions
4. **SDK vs REST** - Exercise every action via both to detect parity gaps
5. **Seed IDs** - Start from list/query paths to gather document IDs
6. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
## Tooling
- SDK + REST: httpie/curl + jq for REST; Firebase emulator and Rules Playground for rapid iteration
- Rules analysis: script probes for common patterns (`auth != null`, missing field validation)
- Functions: fuzz onRequest with varied content-types and missing/forged Authorization
- Storage: enumerate prefixes; test signed URL generation and reuse patterns
## Validation Requirements
- Owner vs non-owner Firestore queries showing unauthorized access or metadata leak
- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure)
- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`
- Minimal reproducible requests with roles/tokens used and observed deltas
================================================
FILE: strix/skills/technologies/supabase.md
================================================
---
name: supabase
description: Supabase security testing covering Row Level Security, PostgREST, Edge Functions, and service key exposure
---
# Supabase
Security testing for Supabase applications. Focus on mis-scoped Row Level Security (RLS), unsafe RPCs, leaked `service_role` keys, lax Storage policies, and Edge Functions trusting headers without binding to issuer/audience/tenant.
## Attack Surface
**Data Access**
- PostgREST: table CRUD, filters, embeddings, RPC (remote functions)
- GraphQL: pg_graphql over Postgres schema with RLS interaction
- Realtime: replication subscriptions, broadcast/presence channels
**Storage**
- Buckets, objects, signed URLs, public/private policies
**Authentication**
- Auth (GoTrue): JWTs, cookie/session, magic links, OAuth flows
**Server-Side**
- Edge Functions (Deno): server-side code calling Supabase with secrets
## Architecture
**Endpoints**
- REST: `https://.supabase.co/rest/v1/
`
- RPC: `https://.supabase.co/rest/v1/rpc/`
- Storage: `https://.supabase.co/storage/v1`
- GraphQL: `https://.supabase.co/graphql/v1`
- Realtime: `wss://.supabase.co/realtime/v1`
- Auth: `https://.supabase.co/auth/v1`
- Functions: `https://.functions.supabase.co/`
**Headers**
- `apikey: ` — identifies project
- `Authorization: Bearer ` — binds user context
**Roles**
- `anon`, `authenticated` — standard roles
- `service_role` — bypasses RLS, must never be client-exposed
**Key Principle**
`auth.uid()` returns current user UUID from JWT. Policies must never trust client-supplied IDs over server context.
## High-Value Targets
- Tables with sensitive data (users, orders, payments, PII)
- RPC functions (especially `SECURITY DEFINER`)
- Storage buckets with private files
- Edge Functions with `service_role` access
- Export/report endpoints generating signed outputs
- Admin/staff routes and privilege-granting endpoints
## Reconnaissance
**Enumerate Surfaces**
```
/rest/v1/
/rest/v1/rpc/
/storage/v1/object/public//
/storage/v1/object/list/?prefix=
/graphql/v1
/auth/v1
```
**Obtain Principals**
- Unauthenticated (anon key only)
- Basic user A, user B
- Admin/staff (if available)
- Check if `service_role` key leaked in client bundle or Edge Function responses
## Key Vulnerabilities
### Row Level Security (RLS)
Enable RLS on every non-public table; absence or "permit-all" policies → bulk exposure.
**Common Gaps**
- Policies check `auth.uid()` for SELECT but forget UPDATE/DELETE/INSERT
- Missing tenant constraints (`org_id`/`tenant_id`) allow cross-tenant access
- Policies rely on client-provided columns (`user_id` in payload) instead of JWT
- Complex joins where policy is applied after filters, enabling inference via counts
**Tests**
```bash
# Compare row counts for two users
GET /rest/v1/
?select=*&Prefer=count=exact
# Cross-tenant probe
GET /rest/v1/